再平衡是把已有数据和服务责任从当前分布调整到目标分布。触发原因包括扩容、缩容、热点和故障恢复。它同时涉及三个问题:搬什么、什么时候可以让目标服务、失败后依据什么恢复。
“一致性哈希只改变少量归属”只回答第一个问题的一部分。迁移不是修改路由表后复制文件;在复制期间,业务数据还会继续变化,旧客户端也可能继续请求原位置。
迁移粒度:固定分区、动态分裂与 token 范围
| 设计 | 移动的单位 | 好处 | 主要边界 |
| 固定逻辑分区 | 一个或一组分区 | key 到分区的映射稳定 | 粒度可能过粗,分区负载未必相同 |
| 动态分区 | 可分裂、合并的键范围 | 可适应数据量和热点变化 | 要维护边界、版本与并发操作 |
| 哈希环 | token 覆盖的区间 | 成员变化只影响部分映射 | 区间字节量和请求量可能不均 |
例如 1000 个等大小分区原来均匀放在 10 台机器上,新增一台后,可以让原来的每台各移出 9 个分区。结果是原节点各 91 个,新节点 90 个,总数仍为 1000。无需把全部分区重新连续编号;只更新被迁移分区的所有者即可。
这是等大小的教学例子。实际调度还要看字节量、读写速率、磁盘空间和故障域,不能仅按分区个数求平均。为了消除热点,有时需要比纯容量均衡更多的迁移。
分裂和迁移也不同:分裂改变逻辑边界,迁移改变放置位置。一个分区拆成两个后可以仍在原机器上;待边界稳定后再移走其中一个。元数据可以由复制的控制平面管理,不意味着必须依赖一台不容失效的中心机器。
为什么“先复制,再改路由”仍然不够
假设开始复制时 x=1,复制过程中用户把 x 改成 2。若目标最终只有快照中的 1,路由切过去后就会读到旧值。若同时向新旧节点写入,旧节点成功而新节点超时,两个位置仍然可能不一致。
正确协议需要回答:哪一份日志记录了这次更新?目标从哪个位置补齐?超时重试是否重复生效?快照中较旧的记录会不会覆盖先到达的新值?删除如何同步?这些问题不能用“双写保证不丢数据”一句话带过。
一个可推演的单写入者迁移协议
以下用源 S、目标 T 和带版本的路由描述通用设计,假设系统能够持久记录迁移状态并约束写入权限。这不是所有数据库都采用的固定实现。
快照必须对应一个日志边界
取一致快照并确定日志位置 L0。快照覆盖截至该边界的状态,增量覆盖后续变化。目标先装载快照再重放增量,或者通过可靠的版本规则合并,避免旧快照覆盖新更新。日志保留期必须足够长,否则目标还没追上,所需增量就已被回收,只能重新建立快照。
重放需要可恢复的位置和幂等语义。重复写入同一版本可以被识别;删除必须以日志事件或 tombstone 等形式传播,不能因为目标没有看到记录就认为删除已经同步。
一个目标副本如何拒绝缺口与迟到快照
设快照对应位置 10,状态为 {x:1,y:9};后续日志 11 把 x 改为 2,12 删除 y。下面假设这是一个专用、位置连续的分区日志,与可以跳过其他 key revision 的 Watch 不同。每次应用都同时发布数据与位置;实际存储需要用事务或恢复协议维持这个原子关系。
class Target:
def __init__(self):
self.position = None
self.data = {}
def install_snapshot(self, position, data):
if self.position is not None:
raise ValueError("该任务已安装快照,不能覆盖增量后的状态")
self.data, self.position = dict(data), position
def replay(self, position, operation, key, value=None):
if self.position is None:
raise ValueError("先完整安装快照")
if position <= self.position:
return False if position != self.position + 1:
raise ValueError("日志有缺口,不能跳过")
updated = dict(self.data)
if operation == "put":
updated[key] = value
elif operation == "delete":
updated.pop(key, None)
else:
raise ValueError("未知日志类型")
self.data, self.position = updated, position
return True
target = Target()
target.install_snapshot(10, {"x": 1, "y": 9})
assert target.replay(11, "put", "x", 2)
assert not target.replay(11, "put", "x", 2)
try:
target.replay(13, "put", "z", 3)
except ValueError:
pass
assert target.position == 11 and "z" not in target.data
assert target.replay(12, "delete", "y")
assert target.data == {"x": 2}
try:
target.install_snapshot(10, {"x": 1, "y": 9})
except ValueError:
pass
assert target.data == {"x": 2} and target.position == 12
模型采用“快照完整安装后才开始重放”的串行策略。因此 12 的删除不需要永远保留墓碑来抵挡这个任务中的旧快照,旧快照安装本身已被拒绝;如果允许乱序块与增量并行合并,就需要更细的版本规则来防止 x 倒退、y 复活。
同一个 position 只能对应同一条不可变日志记录。真实系统还要校验迁移任务 ID、源日志身份、快照校验和及范围,不能把另一个任务的位置 11 当作已处理。示例中的位置只在内存里,未模拟进程或磁盘崩溃;持久实现必须确保不会留下“position 已到 12,但删除尚未保存”的状态。
追平数据之后,还要交接写入权限
“目标延迟为零”只是某次观察。源下一瞬间仍可能接受新写入,因此需要明确的切换屏障 Lcut,并保证旧写入者不能再产生不受约束的成功写入。
一种简单实现是在切换窗口暂时阻止该范围的新写入,等待已接受的操作完成,令目标应用至 Lcut,再发布新路由版本。更复杂的实现可以通过转发、日志协议或复制组重配置缩短中断,但仍须证明旧写入不会被遗漏。
epoch 是权限代次。请求携带旧版本时,服务端应依据协议拒绝或转发;仅让客户端更新缓存不足以隔离一个恢复后仍自认为是主节点的旧进程。权限检查必须落实在实际接受写入的资源边界。
“刚才追平了”与“屏障后追平了”相差一次写入
假设 t1 观察到源和目标都在 12。t2 源接受了日志 13,t3 控制器却按照 t1 的观察直接切路由,此时目标缺少已经成功的 13。零 lag 是一张瞬时照片,不能充当禁止后续写入的屏障。
建立有效屏障的关键是先停止旧授权接受新写,并处理已经接受的在途操作,才能得到不会继续增长的 Lcut。若最后一个已接受操作是 13,目标必须追到 13;不能因为它曾经追到过源的末尾 12 就提前启用。这段短暂拒绝写入的窗口,是该简化协议为了换取清晰交接边界所支付的可用性代价。
路由发布可以有原子决定,客户端不会同时刷新
元数据中可以存在一个明确的所有权切换点,但客户端、代理和长连接看到新状态的时间不同。旧入口因此需要过渡行为,而不能假设全网路由瞬间一致。重定向和转发还要限制次数,防止两个节点互相指向对方形成循环。
迁移一个副本也不等于可以直接替换投票成员。Raft 等复制组需要用自己的成员变更协议保证 quorum 的安全性;迁移工具不能绕过它自行宣布新副本拥有完整权利。
刚才追平,不代表现在可以切换
先让目标追平,再向源写一条,就能构造“零 lag 之后又落后”的反例。冻结按钮包含停止接纳与完成在途写入,得到固定 Lcut;它不是客户端刷新路由缓存。发布新所有者前必须同时满足旧权已隔离、目标覆盖屏障,切换后也不能把旧源当作仍然有效的写入口。
正在呈现知识画面
刚才追平,不代表现在可以切换源仍可写时,零 lag 只是瞬时观测。先建立不再增长的 Lcut,再让目标应用到它,才能在此模型中交接所有权。
{"id":"migration-cutover-barrier","title":"刚才追平,不代表现在可以切换","summary":"源仍可写时,零 lag 只是瞬时观测。先建立不再增长的 Lcut,再让目标应用到它,才能在此模型中交接所有权。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"metrics\"><div><span data-i18n=\"source\"></span><strong id=\"source\"></strong></div><div><span data-i18n=\"target\"></span><strong id=\"target\"></strong></div><div><span data-i18n=\"owner\"></span><strong id=\"owner\"></strong></div></div><div id=\"log\" class=\"track\"></div><p class=\"formula\" id=\"cut\"></p><div class=\"actions\"><button id=\"write\" data-i18n=\"write\"></button><button id=\"catchup\" data-i18n=\"catchup\"></button><button id=\"freeze\" data-i18n=\"freeze\"></button><button id=\"cutover\" data-i18n=\"cutover\"></button><button id=\"reset\" data-i18n=\"reset\"></button></div><div class=\"copy\"><p id=\"explanation\" role=\"status\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"open\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"wait\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"done\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"rejected\"></p></div><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n\n.metrics{grid-template-columns:repeat(3,minmax(0,1fr))}#log{min-height:52px;display:grid;grid-template-columns:repeat(6,minmax(0,1fr))}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}let source,target,cut,owner;function reset(){source=target=10;cut=null;owner='S';draw();}function draw(note){set('source',source);set('target',target);set('owner',owner);set('cut','Lcut = '+(cut===null?'—':cut)+'; lag = '+(source-target));cells('log',Array.from({length:6},(_,i)=>({text:11+i,cls:11+i<=target?'on':11+i<=source?'gold':'empty'})));$('#catchup').disabled=target>=source;$('#freeze').disabled=cut!==null;$('#cutover').disabled=cut===null||target<cut||owner==='T';$('#write').disabled=source>=16;set('explanation',t(note||(owner==='T'?'done':cut===null?'open':'wait')));}$('#write').onclick=()=>{if(cut!==null){draw('rejected');return;}source++;draw();};$('#catchup').onclick=()=>{target++;draw();};$('#freeze').onclick=()=>{cut=source;draw();};$('#cutover').onclick=()=>{owner='T';draw();};$('#reset').onclick=reset;reset();","audio":false,"strings":{"catchup":"目标追赶一条","cutover":"发布新所有者","done":"所有权已交接;旧源即使收到迟到请求也不能接受写入。","freeze":"冻结旧写入权","heading":"刚才追平,不代表现在可以切换","intro":"源 S 从 10 开始,目标 T 已装载快照 10。交错执行源写入与追赶,再冻结旧写入权,确定切换边界。","open":"源仍可接受新写;零 lag 不构成切换许可。","owner":"当前所有者","rejected":"旧源写入被拒绝,日志没有增加。","reset":"重置","scope":"单写入者、连续不可变日志、目标快照已经安装。冻结动作包含排空已接受写入;切换原子更新授权,旧源请求被拒绝。不模拟具体产品迁移。","scopeLabel":"模型范围与约定","source":"源日志位置","target":"目标应用位置","wait":"屏障已建立;目标必须应用至 Lcut 后才能启用。","write":"向旧源写入"}}
失败恢复:由持久状态决定下一步
| 失败位置 | 恢复时应确认什么 |
| 快照传到一半,目标重启 | 任务与快照标识是否匹配,是否能续传或应重建 |
| 增量重放中断 | 最后持久应用的位置,重复事件如何处理 |
| 切换请求超时 | 持久的所有权决定是什么,不能按超时猜测失败 |
| 切换后旧源重新上线 | 旧 epoch 是否已失效,能否错误接受写入 |
| 目标启用后发现性能不足 | 是否执行新的受控迁移;旧副本可能已经过时 |
切换前通常可以停止迁移、保留源服务;切换后目标已经接受新写入,则不能直接把旧副本重新启用当作“回滚”。需要同步目标的新变化并重新交接权限。
容量和限流:追赶必须快于新增变化
设快照大小 D、有效复制带宽 b,在不考虑压缩和其他瓶颈时,全量阶段至少需要 D/b。若随后有积压 B,增量产生速率为 r,目标有效应用速率为 a,则在稳定速率的简化模型下,追平约需 B/(a-r),前提是 a>r。若 a≤r,单靠等待不会追上。
因此限流不能只看网络:还要监控磁盘延迟、写放大、CPU、日志保留空间、目标应用速率与前台请求尾延迟。调度可限制并发迁移范围,预留失败恢复容量,并在服务指标恶化时暂停。并非所有场景都必须逐台扩容;并发度应由资源预算和协议限制决定。
Redis Cluster:区分临时转向与归属变化
Redis Cluster 使用 16384 个哈希槽,槽与节点的对应关系可以变化。在迁移相关的客户端协议中,ASK 表示本次请求需要临时到指定节点执行,并配合 ASKING;它不要求把该槽的长期路由直接改到新节点。MOVED 表示应更新对槽归属的认识。这正说明迁移中的临时服务位置和稳定路由不能混为一谈。Redis Cluster 规范
用下面的纯路由模型区分两类响应。槽 8 最初缓存为 S;收到 ASK 时只生成本次重试计划,收到 MOVED 才更新稳定槽缓存。
routes = {8: "S"}
def redirected(kind, slot, target):
if kind == "ASK":
return target, True if kind == "MOVED":
routes[slot] = target
return target, False
raise ValueError("不是支持的重定向类型")
assert redirected("ASK", 8, "T") == ("T", True)
assert routes[8] == "S" assert redirected("MOVED", 8, "T") == ("T", False)
assert routes[8] == "T"
ASKING 是连接上的一次性许可,不能在一条连接发 ASKING、再从池里拿另一条连接发送原请求。实际客户端需保证两条命令在同一连接上正确衔接,并处理重定向次数与总 deadline。这里只模拟单槽缓存变化,没有实现连接池、命令执行或全槽表刷新;后者也是 MOVED 后常用的更新方式。ASK 与客户端路由语义
这个例子用于理解路由交接,不代表 Redis 采用上文完整的快照与日志迁移流程。
继续阅读:一致性哈希、分片策略、故障检测。
参考
Keywords: rebalancing, resharding, snapshot, log catch-up, fencing, epoch, routing, ASK, MOVED