动态再平衡

本页目录

再平衡是把已有数据和服务责任从当前分布调整到目标分布。触发原因包括扩容、缩容、热点和故障恢复。它同时涉及三个问题:搬什么、什么时候可以让目标服务、失败后依据什么恢复。

“一致性哈希只改变少量归属”只回答第一个问题的一部分。迁移不是修改路由表后复制文件;在复制期间,业务数据还会继续变化,旧客户端也可能继续请求原位置。

迁移粒度:固定分区、动态分裂与 token 范围

设计移动的单位好处主要边界
固定逻辑分区一个或一组分区key 到分区的映射稳定粒度可能过粗,分区负载未必相同
动态分区可分裂、合并的键范围可适应数据量和热点变化要维护边界、版本与并发操作
哈希环token 覆盖的区间成员变化只影响部分映射区间字节量和请求量可能不均

例如 1000 个等大小分区原来均匀放在 10 台机器上,新增一台后,可以让原来的每台各移出 9 个分区。结果是原节点各 91 个,新节点 90 个,总数仍为 1000。无需把全部分区重新连续编号;只更新被迁移分区的所有者即可。

这是等大小的教学例子。实际调度还要看字节量、读写速率、磁盘空间和故障域,不能仅按分区个数求平均。为了消除热点,有时需要比纯容量均衡更多的迁移。

分裂和迁移也不同:分裂改变逻辑边界,迁移改变放置位置。一个分区拆成两个后可以仍在原机器上;待边界稳定后再移走其中一个。元数据可以由复制的控制平面管理,不意味着必须依赖一台不容失效的中心机器。

为什么“先复制,再改路由”仍然不够

假设开始复制时 x=1,复制过程中用户把 x 改成 2。若目标最终只有快照中的 1,路由切过去后就会读到旧值。若同时向新旧节点写入,旧节点成功而新节点超时,两个位置仍然可能不一致。

正确协议需要回答:哪一份日志记录了这次更新?目标从哪个位置补齐?超时重试是否重复生效?快照中较旧的记录会不会覆盖先到达的新值?删除如何同步?这些问题不能用“双写保证不丢数据”一句话带过。

一个可推演的单写入者迁移协议

以下用源 S、目标 T 和带版本的路由描述通用设计,假设系统能够持久记录迁移状态并约束写入权限。这不是所有数据库都采用的固定实现。

单写入者迁移示例:数据追平与权限交接是两个步骤 建立迁移记录 记录源、目标、范围、任务 ID 和路由版本 1 源继续服务,目标接收快照 固定快照对应的日志位置 L0 2 目标重放 L0 之后的增量 包含更新与删除,持久记录重放位置 3 建立切换屏障 Lcut 阻止旧 epoch 的新写入,目标追平至屏障 4 发布新所有者与新 epoch 旧入口拒绝、重定向或按协议转发 5 验证并清理旧副本 确认恢复与保留条件后再回收 6 这是一个协议示例;副本组成员变更必须遵守其自身的复制与共识规则。

快照必须对应一个日志边界

取一致快照并确定日志位置 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 是一张瞬时照片,不能充当禁止后续写入的屏障。

瞬时零 lag 无法阻止源下一刻接受新写 t1:S=12,T=12 此刻追平 t2:S 接受 13 T 仍在 12 t3:不能启用 T 否则遗漏已接受的 13 正确切换:关闭旧写入资格 → 确定 Lcut → 等 T 应用到 Lcut 先完成旧授权已接受的在途操作,再固定最终屏障。

建立有效屏障的关键是先停止旧授权接受新写,并处理已经接受的在途操作,才能得到不会继续增长的 Lcut。若最后一个已接受操作是 13,目标必须追到 13;不能因为它曾经追到过源的末尾 12 就提前启用。这段短暂拒绝写入的窗口,是该简化协议为了换取清晰交接边界所支付的可用性代价。

路由发布可以有原子决定,客户端不会同时刷新

元数据中可以存在一个明确的所有权切换点,但客户端、代理和长连接看到新状态的时间不同。旧入口因此需要过渡行为,而不能假设全网路由瞬间一致。重定向和转发还要限制次数,防止两个节点互相指向对方形成循环。

迁移一个副本也不等于可以直接替换投票成员。Raft 等复制组需要用自己的成员变更协议保证 quorum 的安全性;迁移工具不能绕过它自行宣布新副本拥有完整权利。

刚才追平,不代表现在可以切换

先让目标追平,再向源写一条,就能构造“零 lag 之后又落后”的反例。冻结按钮包含停止接纳与完成在途写入,得到固定 Lcut;它不是客户端刷新路由缓存。发布新所有者前必须同时满足旧权已隔离、目标覆盖屏障,切换后也不能把旧源当作仍然有效的写入口。

正在呈现知识画面
刚才追平,不代表现在可以切换

源仍可写时,零 lag 只是瞬时观测。先建立不再增长的 Lcut,再让目标应用到它,才能在此模型中交接所有权。

失败恢复:由持久状态决定下一步

失败位置恢复时应确认什么
快照传到一半,目标重启任务与快照标识是否匹配,是否能续传或应重建
增量重放中断最后持久应用的位置,重复事件如何处理
切换请求超时持久的所有权决定是什么,不能按超时猜测失败
切换后旧源重新上线旧 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   # 在目标连接上先 ASKING,再发送这次请求
    if kind == "MOVED":
        routes[slot] = target
        return target, False
    raise ValueError("不是支持的重定向类型")

assert redirected("ASK", 8, "T") == ("T", True)
assert routes[8] == "S"  # 下一次独立请求仍先尝试 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