编程 原子槽位迁移深度拆解:当 Redis/Valkey 决定把「重分片」从数据面搬回控制面——从逐 Key MIGRATE 到快照+流复制+原子切换的语义手术

2026-08-09 02:46:20 +0800 CST views 8

原子槽位迁移深度拆解:当 Redis/Valkey 决定把「重分片」从数据面搬回控制面

一句话结论:Atomic Slot Migration(ASM)真正做的不是「迁得更快」,而是把 slot 所有权的转移,从一个持续数分钟、暴露给客户端的数据面过程,压缩成一次控制面的瞬时切换。速度提升只是这次语义手术的副产品。

如果你运维过 Redis Cluster,大概率有过这样的夜晚:凌晨两点执行 redis-cli --cluster reshard,盯着进度条,同时盯着监控大盘上那条不安分的 P99 曲线,一边祈祷业务方的 Lua 脚本别在这个窗口里报 TRYAGAIN

这篇文章想把这件事讲透:为什么分片系统的在线重分布这么难,旧机制的问题到底出在哪一层,ASM 用什么代价换来了原子性,以及这个代价在你的集群里具体值多少毫秒。

文章结构:

  1. 背景:分片系统的阿喀琉斯之踵
  2. 核心概念:slot、所有权与三种重定向
  3. 旧机制拆解:逐 Key 迁移的五宗罪
  4. ASM 架构分析:快照 + 流复制 + 原子切换
  5. 代码实战:迁移安全客户端、热点识别、最小可运行迁移引擎
  6. 性能优化:cutover 窗口的数学模型与调参
  7. 踩坑清单与调优清单
  8. 总结展望:这套范式的普适性

一、背景:为什么在线重分布是分片系统的阿喀琉斯之踵

1.1 所有分片系统都逃不掉的三件事

任何一个把数据按 key 打散到 N 个节点上的系统,都会面临三个绕不开的运维动作:

  • 扩容:加了新节点,但新节点不会自动长出数据,必须把一部分数据搬过去
  • 缩容:要下线节点,它手上的数据不能凭空消失,必须先搬走
  • 再均衡:key 数量均匀 ≠ 资源消耗均匀,热点必然出现,必须动态调整

前两个是计划内的,第三个是被动的、频繁的、往往还是紧急的。而恰恰是第三个,最考验系统的在线重分布能力。

这里有个容易被忽略的事实:"哪个 slot 需要迁"这个判断本身,长期以来在 Redis 里是缺失能力的。你能看到节点级的内存、CPU、QPS,但看不到 slot 级的。这意味着运维在做再均衡决策时,只能凭 key 数量这个极其粗糙的代理指标。

Redis 8.2 引入 CLUSTER SLOT-STATS、8.4 又给它补上 MEMORY-BYTES 维度,其实和 ASM 是同一个工程叙事的两半:先让你看得见,再让你搬得动

1.2 难在哪:所有权转移的本质

把问题抽象一下。分片系统的重分布,本质是一个分布式所有权转移问题:

初始态:  Owner(slot_S) = NodeA,且 Data(slot_S) 物理上在 A
目标态:  Owner(slot_S) = NodeB,且 Data(slot_S) 物理上在 B
约束:    转移期间,任意客户端对 slot_S 内 key 的读写,
          必须能被正确路由,且不能读到不一致的数据

难点在于,数据的物理搬迁需要时间(可能是分钟级),而所有权的逻辑切换应该是瞬间的。这两者之间的时间差,就是所有的复杂度来源。

工程上只有两条路:

  • 路线 A:边搬边切。数据搬到哪,所有权就跟到哪。代价是长期存在一个"部分已迁、部分未迁"的中间态,这个中间态必须暴露给客户端处理。
  • 路线 B:先复制,后切换。数据先完整复制一份到目标节点,两边保持同步,然后在某个瞬间原子地切换所有权。代价是需要双倍存储、需要一个(短暂的)写停顿窗口。

Redis Cluster 从 3.0 到 8.2 走的都是路线 A。ASM 是转向路线 B。

这里就是本文最核心的观点:路线 A 把复杂度推给了客户端和业务;路线 B 把复杂度收回到服务端,用一个可测量、可控制的短窗口,替换了一个不可控的长窗口。这不是性能优化,这是复杂度的重新分配。


二、核心概念:slot、所有权与三种重定向

2.1 Hash Slot 是什么

Redis Cluster 不把 key 直接映射到节点,而是插了一层:16384 个 hash slot。

slot = CRC16(key) mod 16384

如果 key 中包含 {...},则只对花括号内的内容做 hash,这就是 hash tag

user:{1001}:profile   ->  CRC16("1001") mod 16384
user:{1001}:orders    ->  CRC16("1001") mod 16384   # 同一个 slot

这层间接的意义是:节点数可变,slot 数恒定。扩缩容时你只需要重新分配 slot 归属,而不需要重算所有 key 的位置(对比一致性哈希的虚拟节点,本质是同一个思路)。

用 Python 手动算一下,验证 hash tag 的效果:

# crc16.py —— Redis 使用的 CRC16-CCITT (XMODEM) 变体
CRC16_TAB = []
def _init_tab():
    for i in range(256):
        crc = i << 8
        for _ in range(8):
            crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF
        CRC16_TAB.append(crc)
_init_tab()

def crc16(data: bytes) -> int:
    crc = 0
    for b in data:
        crc = ((crc << 8) & 0xFFFF) ^ CRC16_TAB[((crc >> 8) ^ b) & 0xFF]
    return crc

def key_slot(key: str) -> int:
    b = key.encode()
    # hash tag 提取:找第一个 '{',再找它之后第一个 '}'
    start = b.find(b'{')
    if start != -1:
        end = b.find(b'}', start + 1)
        if end != -1 and end != start + 1:
            b = b[start + 1:end]
    return crc16(b) % 16384

if __name__ == '__main__':
    for k in ['user:1001:profile', 'user:{1001}:profile',
              'user:{1001}:orders', 'foo', 'bar']:
        print(f'{k:24s} -> slot {key_slot(k)}')

输出会显示 user:{1001}:profileuser:{1001}:orders 落在同一 slot,而没有 hash tag 的两个 key 则天各一方。

这个函数在后面的实战部分会反复用到——做迁移计划、做数据对拍、做客户端路由,都绕不开它。

2.2 三种重定向:MOVED / ASK / TRYAGAIN

Redis Cluster 的客户端协议里有三种"我不处理,你去别处"的响应,理解它们的区别是理解迁移语义的前提:

响应含义客户端应做是否更新路由表
MOVED <slot> <host:port>slot 已经不归我了,永久性重定向到新节点,更新缓存
ASK <slot> <host:port>slot 还归我,但这个 key 已经搬走了,临时性先发 ASKING,再重发命令,一次性
TRYAGAIN多 key 命令涉及的 key 跨越了迁移边界退避后重试

ASK 是路线 A 的直接产物:它的存在本身就证明了系统处于一个"部分迁移"的中间态。而 TRYAGAIN 则是这个中间态在多 key 语义上的破口——它意味着 Redis 承认:"这个操作现在我做不了,你等会儿再来"。

这里值得停下来品一品:一个号称提供原子多 key 操作的系统,在运维动作期间会返回"你等会儿再来"。这不是 bug,这是路线 A 的必然结果。


三、旧机制拆解:逐 Key 迁移的五宗罪

3.1 传统流程的完整时序

先把旧流程完整写出来,很多人只知道 --cluster reshard 这个封装,不知道底下发生了什么:

# 假设:把 slot 1234 从 SRC 迁到 DST
# NODE_ID_SRC / NODE_ID_DST 是两个节点的 40 字符 ID

# ── 步骤 1:目标节点标记 IMPORTING ──
redis-cli -h DST -p 6379 CLUSTER SETSLOT 1234 IMPORTING NODE_ID_SRC

# ── 步骤 2:源节点标记 MIGRATING ──
redis-cli -h SRC -p 6379 CLUSTER SETSLOT 1234 MIGRATING NODE_ID_DST

# ── 步骤 3:循环搬运 ──
while true; do
  KEYS=$(redis-cli -h SRC -p 6379 CLUSTER GETKEYSINSLOT 1234 100)
  [ -z "$KEYS" ] && break
  # MIGRATE 是同步阻塞的:序列化 -> 网络 -> 目标 RESTORE -> 源删除
  redis-cli -h SRC -p 6379 MIGRATE DST 6379 "" 0 5000 KEYS $KEYS
done

# ── 步骤 4:切换所有权(先目标,后源,再广播) ──
redis-cli -h DST -p 6379 CLUSTER SETSLOT 1234 NODE NODE_ID_DST
redis-cli -h SRC -p 6379 CLUSTER SETSLOT 1234 NODE NODE_ID_DST
# 其余节点通过 gossip 收敛,或由工具显式通知

步骤 3 是重点:它是一个可能持续几分钟的循环,而整个循环期间,slot 1234 处于 MIGRATING/IMPORTING 双标记状态

3.2 罪状一:中间态被暴露给客户端

MIGRATING 状态下,源节点收到对 slot 1234 内 key 的请求时的逻辑大致是:

if key 存在于本地:
    正常执行                      # 还没搬走
else:
    返回 ASK <slot> <DST>         # 可能已经搬走了,去问问目标

注意这个 else 分支的微妙之处:Redis 无法区分"这个 key 已经迁走了"和"这个 key 压根不存在"。所以对于一个查询不存在 key 的 GET,在迁移期间也会吃到一次 ASK 重定向和一次额外 RTT。

对高 QPS 的缓存场景,缓存未命中本来就是常态。迁移期间,每一次 miss 都变成了两次 RTT。这就是为什么迁移时 P99 会抬头——不是因为搬数据占带宽,而是因为路径变长了。

3.3 罪状二:多 key 操作语义降级

# 两个 key 在同一个 slot,本应是原子的
MGET user:{1001}:profile user:{1001}:orders

# 但如果 profile 已迁走、orders 还在源节点:
(error) TRYAGAIN Multiple keys request during rehashing of slot

事务(MULTI/EXEC)和 Lua 脚本同理。业务代码里那些"同 slot 就一定原子"的假设,在迁移窗口内全部失效。

这里有个特别隐蔽的坑:很多团队的 Lua 脚本没有做 TRYAGAIN 重试,因为在日常运行中它永远不会触发。于是迁移那晚,一批本来跑得好好的脚本集体报错,排查方向还容易跑偏到脚本本身。

3.4 罪状三:失败后的半完成状态

旧流程是"搬一批、删一批"。中途失败(目标 OOM、MIGRATE 超时、网络抖动、运维 Ctrl-C)会留下:

  • 一部分 key 在目标节点
  • 一部分 key 在源节点
  • slot 仍处于 MIGRATING/IMPORTING 双标记
  • 集群视图不一致

清理这个状态需要人工介入,而且没有一个原子的"回滚"操作——你只能选择继续迁完,或者手动把已迁的 key 搬回来。后者在有写入的情况下本身就不安全。

这是为什么很多团队对大规模 resharding 保持谨慎:不是怕慢,是怕失败后没有干净的退路。

3.5 罪状四:per-key overhead 的数学必然性

逐 key 迁移的成本模型:

T_total = N_keys × (T_lookup + T_serialize + T_rtt/batch + T_deserialize + T_delete)

其中 T_rtt/batch 因为有批量而被摊薄,但 T_serializeT_deserializeT_lookupT_delete 都是严格 per-key 的。

对比一下全量复制(RDB/replication stream)的模型:

T_total ≈ Size_bytes / Bandwidth + T_fork_or_snapshot

后者是按字节线性的,且序列化格式是批量优化过的连续写。这就是为什么官方 benchmark 里 ASM 能给出数量级级别的提升(社区/官方口径提到最高约 30 倍,具体倍数强依赖 key 大小分布——小 key 多的场景提升最明显,因为 per-key overhead 占比最高)。

一个粗略的直觉:如果你的平均 value 只有 64 字节,那么每个 key 的协议开销、查找开销可能比数据本身还大。逐 key 迁移在这种负载下,有效带宽利用率可能不到 10%

3.6 罪状五:大 key 制造尾延迟尖峰

MIGRATE同步阻塞命令。迁移一个 500MB 的 Hash 意味着:

  • 源节点在序列化期间,主线程被占住
  • 目标节点在 RESTORE 反序列化期间,主线程被占住
  • 网络上有一个 500MB 的突发

这期间所有其他请求排队。平均延迟可能只涨了几个百分点,但 P999 会直接飞出天际。


四、ASM 架构分析:快照 + 流复制 + 原子切换

4.1 核心洞察:把 slot 当成一个微型 replica

ASM 的设计思路,用一句话概括:不要"搬" key,而是给目标节点建立一个针对特定 slot 集合的临时复制关系,等它追平之后,原子地把所有权交出去。

这套流程 Redis 早就有了,只不过用在整节点级别——就是主从全量同步(PSYNC)。ASM 做的是把它降维到 slot 粒度

阶段 1  SNAPSHOT     源节点为目标 slot 集合生成一致性快照,流式发给 DST
                     期间 SRC 正常服务读写,新写入进 backlog
阶段 2  STREAMING    快照发完,开始持续发送 backlog 中的增量写
                     DST 持续 apply,两边差距逐渐收敛
阶段 3  CUTOVER      当 lag 足够小时,SRC 短暂暂停该 slot 的写入
                     等 DST 完全追平 -> 原子切换所有权 -> 恢复写入
阶段 4  CLEANUP      SRC 删除本地该 slot 数据,广播新的 slot 归属

关键差异在阶段 3:写停顿窗口只覆盖"最后一点 lag 的追平时间",而不是整个数据传输时间。前者是毫秒级,后者是分钟级。

4.2 状态机与失败语义

ASM 相比旧机制最重要的工程改进,其实是失败时的可回滚性

                    ┌──────────┐
                    │   IDLE   │
                    └────┬─────┘
                         │ CLUSTER MIGRATION START
                         ▼
                  ┌─────────────┐   失败/取消
                  │  SNAPSHOT   ├──────────────┐
                  └──────┬──────┘              │
                         │ 快照传输完成          │
                         ▼                     │
                  ┌─────────────┐   失败/取消   │
                  │  STREAMING  ├──────────────┤
                  └──────┬──────┘              │
                         │ lag < 阈值           │
                         ▼                     ▼
                  ┌─────────────┐        ┌──────────┐
                  │   CUTOVER   │        │ ROLLBACK │
                  └──────┬──────┘        └────┬─────┘
                         │ 原子成功              │ 丢弃 DST 侧临时数据
                         ▼                     │ SRC 数据从未被删除
                  ┌─────────────┐              │
                  │  COMPLETED  │◄─────────────┘(回到 IDLE)
                  └─────────────┘

**注意 ROLLBACK 分支的关键性质:在 CUTOVER 之前,源节点的数据一直是完整的、权威的、可服务的。**目标节点上的数据只是一份"影子副本",随时可以丢弃。

对比旧机制"搬一批删一批",这是本质区别:

维度旧机制(路线 A)ASM(路线 B)
权威数据位置迁移期间分裂在两侧切换前始终在源侧
失败回滚无原子回滚,需人工丢弃影子副本即可
客户端可见中间态整个迁移期(分钟级)仅 cutover 窗口(毫秒级)
多 key 语义迁移期降级保持完整
存储放大无(边搬边删)迁移期双倍占用该 slot
传输效率per-key,低按字节流,高

4.3 CUTOVER:原子性从哪来

这是全篇最值得琢磨的地方。所谓"原子切换",落到单机上是这样一串动作:

1. SRC:对目标 slot 集合的写命令开始排队(不拒绝,只是不执行)
2. SRC:把 backlog 里剩余的增量全部推给 DST
3. DST:apply 完毕,回 ACK
4. SRC:更新本地 slot 归属表 -> DST
5. SRC:广播新的 slot 配置(bump configEpoch)
6. SRC:释放排队的写命令 -> 这些命令现在会得到 MOVED 响应
7. SRC:异步删除本地该 slot 的数据

**原子性来自于步骤 1 的写排队。**因为在步骤 1 到步骤 6 之间,源节点上不会有任何新的写入产生,所以 DST 追平之后就是真正追平了,不会出现"我刚追平你又写了"的活锁。

那么 cutover 窗口时长 ≈ 步骤 2、3 的耗时 ≈ 残余 lag 的传输 + apply + 一次 RTT

这就带来一个可以直接用来调参的推论:

只要在进入 CUTOVER 之前,把 lag 压到足够小,写停顿窗口就足够短。

而 lag 能否压小,取决于目标节点的 apply 速率是否持续大于源节点该 slot 的写入速率。这给出了一个明确的判断准则:

可安全 cutover 的条件:  R_apply > R_write × (1 + margin)

如果某个 slot 的写入速率高到目标节点追不上(比如一个疯狂 INCR 的计数器 slot),STREAMING 阶段会永远收敛不了。这是 ASM 的真实边界,不是万能药。

4.4 与 failover 的交互

一个容易被忽略的问题:迁移过程中如果源节点或目标节点发生主从切换会怎样?

设计上的处理原则是:迁移状态不应该跨 failover 存活。新上任的主节点没有前任的 backlog 游标和快照上下文,最安全的做法是中止迁移、回滚,然后由运维/自动化重新发起。

这也意味着一条实践建议:**不要在集群刚做完 failover、状态还没完全稳定时立刻发起大批量迁移。**先让 gossip 收敛,确认 CLUSTER INFOcluster_state:ok


五、代码实战

理论讲完了,上代码。这部分给三个可以直接用的东西。

5.1 实战一:迁移安全的客户端封装

无论用不用 ASM,客户端都应该正确处理三种重定向。很多团队的客户端封装在这块是有洞的。

# safe_cluster_client.py
import time
import random
import socket
from typing import Optional, Tuple

class RedirectError(Exception):
    def __init__(self, kind: str, slot: int, addr: str):
        self.kind, self.slot, self.addr = kind, slot, addr

class TryAgainError(Exception):
    pass


class SafeClusterClient:
    """
    一个演示性质的 Cluster 客户端,重点在重定向处理策略,
    生产环境请用成熟客户端(redis-py / lettuce / go-redis)并确认其行为。
    """
    MAX_REDIRECTS = 5
    MAX_TRYAGAIN = 8

    def __init__(self, seed_addr: str):
        self.slot_map: dict[int, str] = {}   # slot -> "host:port"
        self.conns: dict[str, socket.socket] = {}
        self.seed = seed_addr
        self.refresh_slots()

    # ---------- 路由表 ----------
    def refresh_slots(self):
        """从任意节点拉取 CLUSTER SHARDS,重建 slot -> node 映射"""
        raw = self._raw_cmd(self.seed, ['CLUSTER', 'SHARDS'])
        new_map = {}
        for shard in _parse_shards(raw):
            primary = shard['primary_addr']
            for lo, hi in shard['slots']:
                for s in range(lo, hi + 1):
                    new_map[s] = primary
        # 原子替换,避免半更新状态被并发读到
        self.slot_map = new_map

    # ---------- 核心执行 ----------
    def execute(self, key: str, *args):
        slot = key_slot(key)
        addr = self.slot_map.get(slot, self.seed)
        redirects = 0
        tryagains = 0
        asking = False

        while True:
            try:
                return self._raw_cmd(addr, list(args), asking=asking)

            except RedirectError as e:
                asking = False
                if e.kind == 'MOVED':
                    # 永久性:更新路由表。
                    # 关键点:不要只改这一个 slot,MOVED 往往意味着
                    # 拓扑已经变了,整表刷新更稳妥(但要有节流)。
                    self.slot_map[e.slot] = e.addr
                    self._throttled_refresh()
                    addr = e.addr
                elif e.kind == 'ASK':
                    # 临时性:绝对不能更新路由表!
                    # 更新了会导致后续所有请求打到还没接管 slot 的节点上。
                    addr = e.addr
                    asking = True

                redirects += 1
                if redirects > self.MAX_REDIRECTS:
                    raise RuntimeError(f'too many redirects for slot {slot}')

            except TryAgainError:
                tryagains += 1
                if tryagains > self.MAX_TRYAGAIN:
                    raise
                # 指数退避 + 抖动:迁移窗口通常是秒级到分钟级,
                # 但 ASM 之后 TRYAGAIN 应该几乎绝迹,
                # 所以这里可以用比较激进的退避。
                delay = min(0.002 * (2 ** tryagains), 0.5)
                time.sleep(delay * (0.5 + random.random()))

    _last_refresh = 0.0
    def _throttled_refresh(self, min_interval=1.0):
        now = time.time()
        if now - self._last_refresh > min_interval:
            self._last_refresh = now
            try:
                self.refresh_slots()
            except Exception:
                pass  # 刷新失败不影响当前请求,靠重定向兜底

    def _raw_cmd(self, addr, args, asking=False):
        """省略 RESP 编解码细节,聚焦语义"""
        conn = self._get_conn(addr)
        if asking:
            _send(conn, ['ASKING'])
            _read(conn)
        _send(conn, args)
        resp = _read(conn)
        if isinstance(resp, Exception):
            msg = str(resp)
            if msg.startswith('MOVED '):
                _, slot, target = msg.split()
                raise RedirectError('MOVED', int(slot), target)
            if msg.startswith('ASK '):
                _, slot, target = msg.split()
                raise RedirectError('ASK', int(slot), target)
            if msg.startswith('TRYAGAIN'):
                raise TryAgainError(msg)
            raise resp
        return resp

这段代码里最重要的两行注释

  • MOVED 要更新路由表,但要整表节流刷新,而不是只改一个 slot。因为一次 reshard 通常会移动一批 slot,只改一个会导致后续请求继续挨个撞 MOVED。
  • ASK 绝对不能更新路由表。这是我见过最多的客户端 bug:把 ASK 当 MOVED 处理,结果迁移期间大量请求被路由到还没接管 slot 的目标节点,目标节点又 MOVED 回来,形成乒乓抖动。

5.2 实战二:基于 SLOT-STATS 的热点识别与迁移计划生成

有了 CLUSTER SLOT-STATS,我们可以做真正基于资源消耗的再均衡决策,而不是拍脑袋按 key 数量分。

#!/usr/bin/env python3
"""
slot_planner.py —— 基于多维资源指标生成迁移计划

用法:
    python3 slot_planner.py --host 10.0.0.1 --port 6379 \
        --metric cpu --top 20 --dry-run
"""
import argparse
import subprocess
import json
from collections import defaultdict
from dataclasses import dataclass, field


@dataclass
class SlotStat:
    slot: int
    key_count: int = 0
    cpu_usec: int = 0
    net_in: int = 0
    net_out: int = 0
    memory_bytes: int = 0
    owner: str = ''

    def score(self, weights: dict) -> float:
        """
        综合负载评分。默认权重体现一个观点:
        CPU 和内存是硬约束(打满就挂),网络是软约束(打满只是慢),
        key 数量几乎不该单独作为迁移依据。
        """
        return (
            weights['cpu']  * self.cpu_usec +
            weights['mem']  * self.memory_bytes +
            weights['net']  * (self.net_in + self.net_out) +
            weights['keys'] * self.key_count
        )


DEFAULT_WEIGHTS = {'cpu': 1.0, 'mem': 1.0, 'net': 0.3, 'keys': 0.05}


def fetch_slot_stats(host, port) -> dict[int, SlotStat]:
    """
    CLUSTER SLOT-STATS SLOTSRANGE 0 16383
    注意:这是一个较重的命令,不要高频调用(建议 >= 30s 间隔)
    """
    out = subprocess.check_output([
        'redis-cli', '-h', host, '-p', str(port), '--json',
        'CLUSTER', 'SLOT-STATS', 'SLOTSRANGE', '0', '16383'
    ], text=True)
    stats = {}
    for entry in json.loads(out):
        slot = int(entry[0])
        kv = dict(zip(entry[1][0::2], entry[1][1::2]))
        stats[slot] = SlotStat(
            slot=slot,
            key_count=int(kv.get('key-count', 0)),
            cpu_usec=int(kv.get('cpu-usec', 0)),
            net_in=int(kv.get('network-bytes-in', 0)),
            net_out=int(kv.get('network-bytes-out', 0)),
            memory_bytes=int(kv.get('memory-bytes', 0)),
        )
    return stats


def build_plan(stats: dict[int, SlotStat], node_of_slot: dict[int, str],
               weights=DEFAULT_WEIGHTS, tolerance=0.15):
    """
    贪心再均衡:反复把最重节点上"评分最高但不是最高"的 slot,
    迁到最轻节点,直到极差落在 tolerance 内。

    为什么不迁"评分最高"的那个 slot?
    因为超级热点 slot 迁走只是把问题搬家,不解决问题。
    真正的超级热点应该靠 hash tag 拆分或业务侧改造解决。
    """
    load = defaultdict(float)
    slots_by_node = defaultdict(list)
    for s, st in stats.items():
        node = node_of_slot.get(s)
        if not node:
            continue
        sc = st.score(weights)
        load[node] += sc
        slots_by_node[node].append((sc, s))

    for n in slots_by_node:
        slots_by_node[n].sort(reverse=True)

    plan = []
    guard = 0
    while guard < 1000:
        guard += 1
        hot = max(load, key=load.get)
        cold = min(load, key=load.get)
        avg = sum(load.values()) / len(load)
        if avg == 0 or (load[hot] - load[cold]) / avg < tolerance:
            break
        if not slots_by_node[hot] or len(slots_by_node[hot]) <= 1:
            break

        # 跳过 rank 0(超级热点),取 rank 1
        idx = 1 if len(slots_by_node[hot]) > 1 else 0
        sc, slot = slots_by_node[hot].pop(idx)

        # 迁过去反而让 cold 变成新 hot?那就不迁,避免震荡
        if load[cold] + sc > load[hot]:
            break

        load[hot] -= sc
        load[cold] += sc
        slots_by_node[cold].append((sc, slot))
        plan.append({'slot': slot, 'from': hot, 'to': cold, 'score': sc})

    return plan


def emit_asm_commands(plan):
    """
    把计划聚合成按 (from,to) 分组的批量迁移命令。
    ASM 的一个重要优势:支持一次迁移多个 slot,
    共享一次快照/流式会话,比逐个迁移高效得多。
    """
    grouped = defaultdict(list)
    for item in plan:
        grouped[(item['from'], item['to'])].append(item['slot'])

    cmds = []
    for (src, dst), slots in grouped.items():
        ranges = _compact_ranges(sorted(slots))
        slot_args = ' '.join(f'{lo} {hi}' for lo, hi in ranges)
        cmds.append(
            f"# {src} -> {dst}, {len(slots)} slots\n"
            f"redis-cli -h {dst.split(':')[0]} -p {dst.split(':')[1]} "
            f"CLUSTER MIGRATION START SLOTSRANGE {slot_args}"
        )
    return cmds


def _compact_ranges(slots):
    """[1,2,3,7,8,10] -> [(1,3),(7,8),(10,10)]"""
    out, start, prev = [], None, None
    for s in slots:
        if start is None:
            start = prev = s
        elif s == prev + 1:
            prev = s
        else:
            out.append((start, prev)); start = prev = s
    if start is not None:
        out.append((start, prev))
    return out


if __name__ == '__main__':
    ap = argparse.ArgumentParser()
    ap.add_argument('--host', required=True)
    ap.add_argument('--port', type=int, default=6379)
    ap.add_argument('--tolerance', type=float, default=0.15)
    ap.add_argument('--dry-run', action='store_true')
    args = ap.parse_args()

    stats = fetch_slot_stats(args.host, args.port)
    node_of_slot = load_slot_owners(args.host, args.port)  # 见 CLUSTER SHARDS 解析
    plan = build_plan(stats, node_of_slot, tolerance=args.tolerance)

    print(f'# 生成 {len(plan)} 条迁移动作')
    for c in emit_asm_commands(plan):
        print(c)
    if not args.dry_run:
        print('# 请人工 review 后执行,不建议自动执行重分片')

这个脚本里嵌了三个我认为很重要的工程判断

  1. 不迁最热的那个 slot。超级热点迁走只是换个节点继续热,真正的解法是业务侧拆 key 或改 hash tag。工具应该帮你做均衡,不应该帮你掩盖设计问题。
  2. 加了震荡保护。如果迁过去会让目标变成新的最热节点,就停止。贪心算法不加这个保护很容易在两个节点之间来回搬。
  3. 默认不自动执行。重分片是有状态的高风险操作,--dry-run 应该是默认行为。

5.3 实战三:一个最小可运行的原子迁移引擎(Go)

为了把 snapshot → stream → cutover 这套语义讲透,我用 Go 写了一个能跑的最小模型。它不是 Redis 的实现,但把关键的时序和并发控制都体现出来了。

// asm_model.go —— 原子槽位迁移的最小语义模型
package main

import (
	"errors"
	"fmt"
	"sync"
	"sync/atomic"
	"time"
)

// ─────────────── 数据结构 ───────────────

type WriteOp struct {
	Slot  int
	Key   string
	Value string
	Del   bool
}

// Shard 模拟一个节点上的分片存储
type Shard struct {
	mu    sync.RWMutex
	data  map[int]map[string]string // slot -> key -> value
	owned map[int]bool              // 本节点拥有的 slot

	// 迁移相关
	migMu      sync.Mutex
	migrating  map[int]*Migration // slot -> 迁移会话
	writePause map[int]chan struct{} // slot -> 写暂停信号
}

type Migration struct {
	Slots    []int
	Target   *Shard
	backlog  chan WriteOp
	state    atomic.Int32 // 0=snapshot 1=streaming 2=cutover 3=done 4=aborted
	applied  atomic.Int64
	produced atomic.Int64
	done     chan struct{}
	abortCh  chan struct{}
}

const (
	StSnapshot int32 = iota
	StStreaming
	StCutover
	StDone
	StAborted
)

func NewShard() *Shard {
	return &Shard{
		data:       make(map[int]map[string]string),
		owned:      make(map[int]bool),
		migrating:  make(map[int]*Migration),
		writePause: make(map[int]chan struct{}),
	}
}

// ─────────────── 正常读写路径 ───────────────

var ErrMoved = errors.New("MOVED")

func (s *Shard) Write(op WriteOp) error {
	// 1) 所有权检查
	s.mu.RLock()
	if !s.owned[op.Slot] {
		s.mu.RUnlock()
		return ErrMoved // 切换后的写会走到这里
	}
	s.mu.RUnlock()

	// 2) 写暂停检查 —— cutover 窗口的核心机制
	//    注意:这里是"阻塞等待",不是"拒绝"。
	//    对客户端表现为一次延迟抖动,而不是一个错误。
	s.migMu.Lock()
	pause := s.writePause[op.Slot]
	s.migMu.Unlock()
	if pause != nil {
		select {
		case <-pause: // 等待暂停解除
		case <-time.After(2 * time.Second):
			return errors.New("write pause timeout")
		}
		// 解除后重新检查所有权:可能已经不归我了
		s.mu.RLock()
		still := s.owned[op.Slot]
		s.mu.RUnlock()
		if !still {
			return ErrMoved
		}
	}

	// 3) 实际写入
	s.mu.Lock()
	if s.data[op.Slot] == nil {
		s.data[op.Slot] = make(map[string]string)
	}
	if op.Del {
		delete(s.data[op.Slot], op.Key)
	} else {
		s.data[op.Slot][op.Key] = op.Value
	}
	s.mu.Unlock()

	// 4) 如果该 slot 正在迁移,把写操作推进 backlog
	s.migMu.Lock()
	m := s.migrating[op.Slot]
	s.migMu.Unlock()
	if m != nil && m.state.Load() < StDone {
		select {
		case m.backlog <- op:
			m.produced.Add(1)
		case <-m.abortCh:
		default:
			// backlog 满 —— 说明目标追不上,中止迁移
			// 这比阻塞主写路径要好得多
			m.Abort()
		}
	}
	return nil
}

// ─────────────── 迁移流程 ───────────────

func (s *Shard) StartMigration(slots []int, target *Shard, backlogSize int) *Migration {
	m := &Migration{
		Slots:   slots,
		Target:  target,
		backlog: make(chan WriteOp, backlogSize),
		done:    make(chan struct{}),
		abortCh: make(chan struct{}),
	}
	s.migMu.Lock()
	for _, sl := range slots {
		s.migrating[sl] = m
	}
	s.migMu.Unlock()

	go s.runMigration(m)
	return m
}

func (m *Migration) Abort() {
	if m.state.CompareAndSwap(StSnapshot, StAborted) ||
		m.state.CompareAndSwap(StStreaming, StAborted) {
		close(m.abortCh)
	}
}

func (s *Shard) runMigration(m *Migration) {
	defer close(m.done)

	// ── 阶段 1:SNAPSHOT ──
	// 关键:快照必须和 backlog 的起点严格衔接,不能有空洞也不能重复到
	// 破坏幂等的程度。这里用一次全局读锁取一致性视图,
	// 真实系统会用 COW / fork / 迭代器 + 版本号来避免长时间持锁。
	s.mu.RLock()
	snap := make(map[int]map[string]string, len(m.Slots))
	for _, sl := range m.Slots {
		cp := make(map[string]string, len(s.data[sl]))
		for k, v := range s.data[sl] {
			cp[k] = v
		}
		snap[sl] = cp
	}
	s.mu.RUnlock()

	for sl, kv := range snap {
		if m.state.Load() == StAborted {
			s.rollback(m)
			return
		}
		m.Target.applySnapshotChunk(sl, kv)
	}
	m.state.Store(StStreaming)

	// ── 阶段 2:STREAMING ──
	// 持续 apply 增量,同时监控 lag 是否收敛
	deadline := time.Now().Add(30 * time.Second)
	for {
		if m.state.Load() == StAborted {
			s.rollback(m)
			return
		}
		lag := m.produced.Load() - m.applied.Load()

		// 收敛判据:lag 足够小 -> 可以进入 cutover
		if lag <= 64 {
			break
		}
		if time.Now().After(deadline) {
			// 写入速率持续高于 apply 速率,永远收敛不了
			fmt.Printf("[ASM] slot %v: lag 未收敛 (lag=%d),中止\n", m.Slots, lag)
			m.Abort()
			s.rollback(m)
			return
		}

		select {
		case op := <-m.backlog:
			m.Target.applyOp(op)
			m.applied.Add(1)
		case <-time.After(5 * time.Millisecond):
		}
	}

	// ── 阶段 3:CUTOVER ──
	cutStart := time.Now()
	m.state.Store(StCutover)

	// 3.1 暂停写(阻塞,不拒绝)
	s.migMu.Lock()
	for _, sl := range m.Slots {
		s.writePause[sl] = make(chan struct{})
	}
	s.migMu.Unlock()

	// 3.2 排空 backlog
	drain := true
	for drain {
		select {
		case op := <-m.backlog:
			m.Target.applyOp(op)
			m.applied.Add(1)
		default:
			drain = false
		}
	}

	// 3.3 原子切换所有权
	s.mu.Lock()
	m.Target.mu.Lock()
	for _, sl := range m.Slots {
		s.owned[sl] = false
		m.Target.owned[sl] = true
	}
	m.Target.mu.Unlock()
	s.mu.Unlock()

	// 3.4 释放写暂停 —— 之后的写会得到 MOVED
	s.migMu.Lock()
	for _, sl := range m.Slots {
		if ch := s.writePause[sl]; ch != nil {
			close(ch)
		}
		delete(s.writePause, sl)
		delete(s.migrating, sl)
	}
	s.migMu.Unlock()

	cutDur := time.Since(cutStart)
	m.state.Store(StDone)

	// ── 阶段 4:CLEANUP(异步,不占 cutover 窗口)──
	go func() {
		time.Sleep(100 * time.Millisecond) // 给在途请求一点缓冲
		s.mu.Lock()
		for _, sl := range m.Slots {
			delete(s.data, sl)
		}
		s.mu.Unlock()
	}()

	fmt.Printf("[ASM] slots %v 迁移完成,cutover 窗口 = %v\n", m.Slots, cutDur)
}

func (s *Shard) rollback(m *Migration) {
	// 回滚:丢弃目标侧影子数据,源侧数据从未被动过
	m.Target.mu.Lock()
	for _, sl := range m.Slots {
		delete(m.Target.data, sl)
	}
	m.Target.mu.Unlock()

	s.migMu.Lock()
	for _, sl := range m.Slots {
		delete(s.migrating, sl)
		if ch := s.writePause[sl]; ch != nil {
			close(ch)
			delete(s.writePause, sl)
		}
	}
	s.migMu.Unlock()
	fmt.Printf("[ASM] slots %v 已回滚,源数据完好\n", m.Slots)
}

func (s *Shard) applySnapshotChunk(slot int, kv map[string]string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.data[slot] == nil {
		s.data[slot] = make(map[string]string)
	}
	for k, v := range kv {
		s.data[slot][k] = v
	}
}

func (s *Shard) applyOp(op WriteOp) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.data[op.Slot] == nil {
		s.data[op.Slot] = make(map[string]string)
	}
	if op.Del {
		delete(s.data[op.Slot], op.Key)
	} else {
		s.data[op.Slot][op.Key] = op.Value
	}
}

配一个能观察 cutover 窗口的驱动程序:

func main() {
	src, dst := NewShard(), NewShard()
	const slot = 1234
	src.owned[slot] = true

	// 预置 20 万条数据
	src.data[slot] = make(map[string]string, 200000)
	for i := 0; i < 200000; i++ {
		src.data[slot][fmt.Sprintf("k%d", i)] = fmt.Sprintf("v%d", i)
	}

	// 持续写入 + 延迟采样
	stop := make(chan struct{})
	var maxLatency atomic.Int64
	var writes atomic.Int64
	for w := 0; w < 4; w++ {
		go func(id int) {
			i := 0
			for {
				select {
				case <-stop:
					return
				default:
				}
				t0 := time.Now()
				_ = src.Write(WriteOp{Slot: slot,
					Key:   fmt.Sprintf("hot%d_%d", id, i),
					Value: "x"})
				d := time.Since(t0).Microseconds()
				for {
					old := maxLatency.Load()
					if d <= old || maxLatency.CompareAndSwap(old, d) {
						break
					}
				}
				writes.Add(1)
				i++
				time.Sleep(200 * time.Microsecond)
			}
		}(w)
	}

	time.Sleep(300 * time.Millisecond)
	m := src.StartMigration([]int{slot}, dst, 16384)
	<-m.done
	close(stop)
	time.Sleep(50 * time.Millisecond)

	fmt.Printf("总写入: %d\n", writes.Load())
	fmt.Printf("最大单次写延迟: %d us  <- 这就是 cutover 对业务的真实影响\n",
		maxLatency.Load())
	fmt.Printf("目标节点 key 数: %d\n", len(dst.data[slot]))
}

跑这个程序你会看到什么:绝大多数写延迟在微秒级,只有 cutover 那一瞬间会出现一个毫秒级的尖峰。**这个尖峰就是 ASM 的全部代价。**对比旧机制——整个迁移期间每次 miss 都多一个 RTT,多 key 命令持续报错——这笔账怎么算都划算。

如果你把 backlog 容量调小到 128,会看到迁移被中止并回滚,源数据完好无损。这个失败路径才是 ASM 最值钱的地方。


六、性能优化:cutover 窗口的数学模型

6.1 建模

设:

  • L = 进入 cutover 时的残余 lag(条数)
  • R_apply = 目标节点 apply 速率(条/秒)
  • RTT = 源目标之间往返延迟
  • T_meta = 元数据切换 + configEpoch bump 耗时

则:

T_cutover ≈ L / R_apply + RTT + T_meta

典型量级(同机房万兆网,中等规格实例):

L = 64 条,R_apply = 500k 条/秒  ->  L/R_apply ≈ 0.13 ms
RTT ≈ 0.2 ms
T_meta ≈ 0.5 ms(含配置广播的本地部分)
────────────────────────────────────────
T_cutover ≈ 0.8 ms

**亚毫秒级。**这就是为什么 ASM 敢说"几乎无感"。

反过来,如果你的集群跨机房部署,RTT = 30ms,那 cutover 窗口至少 30ms 起步。跨机房迁移的写停顿会明显得多,这是物理定律,不是实现问题。

6.2 STREAMING 阶段的收敛条件

这是决定"能不能迁"的关键:

收敛条件:  R_apply > R_write
收敛时间:  T_converge = L_initial / (R_apply - R_write)

R_write 逼近 R_apply 时,T_converge → ∞

实践推论:

  • 写入率极高的热点 slot,可能根本迁不动。这时的解法不是硬迁,而是先降写入(限流、批量合并)或者拆分 key。
  • 迁移应该挑低峰期做,不是因为怕占带宽,而是因为低峰期 R_write 小,收敛快、lag 小、cutover 窗口短。这是三重收益。

6.3 单次迁移多少个 slot 合适

ASM 支持一次迁移多个 slot(SLOTSRANGE),这带来一个权衡:

批量大小优势劣势
小(1-16 slot)cutover 影响面小、失败重试成本低会话建立开销被摊得少、总耗时长
大(512+ slot)一次快照搞定、总吞吐高cutover 时暂停的 key 空间大、目标节点内存压力集中

我的经验判据:

批量大小 ≈ min(
    目标节点可用内存 × 0.5 / 单 slot 平均内存,
    让单次迁移总时长落在 30~120 秒的 slot 数
)

为什么要控制在 30~120 秒?太短则会话开销占比高;太长则遇到网络抖动、failover 的概率上升,而失败就要整批重来。

6.4 存储放大:容易被忽略的硬约束

ASM 是"先复制后删除",所以迁移期间目标节点要额外承载这批 slot 的完整数据

所需目标节点空闲内存 ≥ Σ(待迁 slot 的 memory-bytes) × (1 + fragmentation)

fragmentation 建议按 1.3~1.5 估(jemalloc 碎片 + 复制缓冲区)。

踩过的坑:某次扩容,目标节点看起来有 40% 空闲,一次性迁 2000 个 slot 直接把目标打到 maxmemory,触发 eviction。更糟的是,被 evict 的是已经复制过去但还没 cutover 的影子数据,导致迁移完成后数据缺失。

所以这条是硬规则:迁移前用 CLUSTER SLOT-STATSMEMORY-BYTES 精确算一遍,别靠感觉。

6.5 与持久化/复制的资源竞争

快照阶段会给源节点带来额外的读放大和 CPU 消耗。如果这时正好赶上:

  • RDB 定时快照(save 触发)
  • AOF rewrite
  • 某个 replica 在做全量同步

那就是三份资源竞争叠加。迁移前建议临时关闭自动 RDB(CONFIG SET save ""),迁完再恢复,并确认没有 replica 处于 sync_in_progress 状态:

redis-cli -h SRC INFO persistence | grep -E 'rdb_bgsave_in_progress|aof_rewrite_in_progress'
redis-cli -h SRC INFO replication | grep -E 'state=|sync_'

七、踩坑清单与调优清单

7.1 十条踩坑

  1. 客户端把 ASK 当 MOVED 处理。最高频的 bug,导致迁移期请求在两节点间乒乓。检查方法:在测试环境手动构造一次迁移,抓包看是否有异常的 MOVED 往返。

  2. Lua 脚本没处理 TRYAGAIN。日常永远不触发,迁移那晚集体爆炸。就算上了 ASM,混合版本集群里旧机制仍可能被用到。

  3. 迁移前没算目标节点内存。见 6.4。

  4. 在 failover 刚发生后立刻迁移。集群视图还没收敛,迁移大概率中止。先确认 cluster_state:okcluster_known_nodes 符合预期。

  5. 跨机房迁移低估了 cutover 窗口。RTT 直接进入公式。跨机房场景建议把批量拆小,并在业务低峰做。

  6. 超级热点 slot 硬迁。STREAMING 永远不收敛,白白消耗资源。先解决热点本身。

  7. 迁移期间同时开着自动 RDB。资源竞争,快照阶段耗时翻倍。

  8. 一次性迁太多 slot。失败重来的成本随批量线性上升,而失败概率随时长上升,两者相乘是超线性的。

  9. 没有做迁移后校验。ASM 语义上是安全的,但工程上任何数据搬迁都应该有对拍。至少校验 key 数量和随机抽样的 value。

  10. 依赖 --cluster rebalance 的默认权重。它按 slot 数量均衡,不按资源消耗。用 5.2 的脚本自己算。

7.2 迁移后校验脚本

#!/usr/bin/env bash
# verify_migration.sh —— 迁移后一致性抽检
set -euo pipefail

SRC=$1        # 迁移前的源节点 host:port(用于对比历史快照,可选)
DST=$2        # 目标节点 host:port
SLOT=$3
SAMPLE=${4:-200}

DST_H=${DST%:*}; DST_P=${DST#*:}

echo "── 1. slot 归属确认 ──"
OWNER=$(redis-cli -h "$DST_H" -p "$DST_P" CLUSTER KEYSLOT probe >/dev/null 2>&1; \
        redis-cli -h "$DST_H" -p "$DST_P" CLUSTER SHARDS | grep -A2 "\"$SLOT\"" | head -3)
echo "$OWNER"

echo "── 2. key 数量 ──"
CNT=$(redis-cli -h "$DST_H" -p "$DST_P" CLUSTER COUNTKEYSINSLOT "$SLOT")
echo "目标节点 slot $SLOT key 数: $CNT"
if [ "$CNT" -eq 0 ]; then
  echo "!! 警告:目标 slot 为空,迁移可能未生效或数据本就为空"
fi

echo "── 3. 随机抽样可读性 ──"
KEYS=$(redis-cli -h "$DST_H" -p "$DST_P" CLUSTER GETKEYSINSLOT "$SLOT" "$SAMPLE")
FAIL=0
while read -r k; do
  [ -z "$k" ] && continue
  TYPE=$(redis-cli -h "$DST_H" -p "$DST_P" TYPE "$k")
  if [ "$TYPE" = "none" ]; then
    echo "!! key 不可读: $k"; FAIL=$((FAIL+1))
  fi
done <<< "$KEYS"
echo "抽样 $(echo "$KEYS" | wc -l) 个,失败 $FAIL 个"

echo "── 4. 源节点残留检查 ──"
SRC_H=${SRC%:*}; SRC_P=${SRC#*:}
LEFT=$(redis-cli -h "$SRC_H" -p "$SRC_P" CLUSTER COUNTKEYSINSLOT "$SLOT" 2>/dev/null || echo "-1")
echo "源节点残留 key 数: $LEFT (异步清理中可能非 0,稍后复查)"

[ "$FAIL" -eq 0 ] && echo "✅ 抽检通过" || { echo "❌ 抽检失败"; exit 1; }

7.3 调优清单

# ── 迁移前 ──
# 1. 关闭自动 RDB,避免资源竞争
redis-cli -h SRC CONFIG SET save ""

# 2. 确认没有 replica 在全量同步
redis-cli -h SRC INFO replication | grep sync_full

# 3. 适当放宽 cluster-node-timeout,避免迁移期误判失联
#    (但不要过大,会拖慢真实故障的检测)
redis-cli -h SRC CONFIG SET cluster-node-timeout 15000

# 4. 确认目标节点内存充足(见 6.4 的计算)
redis-cli -h DST INFO memory | grep -E 'used_memory_human|maxmemory_human'

# ── 迁移中 ──
# 5. 观察迁移状态
watch -n1 'redis-cli -h DST CLUSTER MIGRATION STATUS'

# 6. 监控目标节点内存增长曲线,接近阈值立即中止
watch -n2 "redis-cli -h DST INFO memory | grep used_memory:"

# ── 迁移后 ──
# 7. 恢复 RDB 配置
redis-cli -h SRC CONFIG SET save "3600 1 300 100 60 10000"
redis-cli -h SRC CONFIG SET cluster-node-timeout 5000

# 8. 确认所有节点的 slot 视图一致(这一步经常被跳过)
for n in $NODES; do
  echo -n "$n: "
  redis-cli -h ${n%:*} -p ${n#*:} CLUSTER SLOTS | md5sum
done
# 所有 md5 应该相同,不同说明 gossip 未收敛

第 8 条特别值得强调:gossip 收敛不是瞬时的。迁移完成后立刻从某些节点读,可能还拿到旧的路由信息。校验所有节点的 CLUSTER SLOTS 输出哈希一致,是最简单可靠的收敛判据。


八、总结与展望

8.1 这次改动的真正价值

回到开头的判断:ASM 的价值不在速度。

速度提升(官方口径最高约 30 倍)是"从 per-key 协议改为按字节流"的自然结果,任何系统做同样的改造都会得到类似量级的提升。真正重要的是三点:

  1. 中间态从分钟级压缩到毫秒级。客户端不再需要在长时间窗口内处理降级语义。
  2. 失败有了原子回滚。cutover 之前源数据始终权威,这让重分片从"高风险操作"变成"可重试操作"。这个心理门槛的降低,会直接改变运维行为——从"能不迁就不迁"变成"该迁就迁"。
  3. 多 key 语义在迁移期得以保持。业务代码不需要为运维动作做特殊适配。

第 2 点我认为最被低估。**一个操作能不能被自动化,取决于它失败时能不能自动恢复。**旧机制失败要人工介入,所以 resharding 永远是人肉操作、永远排在凌晨。ASM 有了干净的回滚路径,才让"自动再均衡"这件事在工程上变得可能。

8.2 这套范式的普适性

有意思的是,"快照 + 增量流 + 原子切换"这个模式,几乎是所有成熟分片系统的收敛答案:

系统分片单元迁移机制
TiKVRegionRaft snapshot + log replication + leader transfer
CockroachDBRangeRaft snapshot + 增量 + 原子 lease 转移
VitessShardVReplication 流 + cutover 时短暂 write block
KafkaPartitionFollower fetch 追平 + leader election
Redis/ValkeyHash Slot快照 + backlog 流 + slot 所有权原子切换

它们的共同结构是:用"多一份副本"换"短一个窗口"。

这背后是一个更普适的工程原理:

当你无法让一个耗时操作变成瞬时的,就把它拆成"可以慢慢做的准备阶段"和"必须瞬时完成的提交阶段",然后想办法把提交阶段压到最小。

这不就是两阶段提交、不就是 MVCC 的提交点、不就是 copy-on-write 吗?同一个思想在不同抽象层反复出现。 Redis Cluster 花了这么多年才在 slot 层面用上它,某种意义上是这个系统"从缓存演进为数据库"这条路上的一个里程碑。

8.3 还没解决的问题

保持诚实,ASM 不是终点:

  • 超级热点 slot 依然无解。写入速率高于 apply 速率时,迁移永远不收敛。真正的解法需要 slot 级别的拆分能力(类似 TiKV 的 region split),而 16384 这个固定数字挡在前面。
  • 跨机房迁移的 cutover 窗口受 RTT 支配。物理限制,只能通过更小的批量来缓解。
  • 自动再均衡还没有成熟的控制器。有了安全的迁移原语,还缺一个能根据 SLOT-STATS 自动决策、限流、回滚的控制面。这大概是下一个值得做的东西——本文 5.2 的脚本只是个粗糙的起点。
  • Redis 与 Valkey 的分叉在加深。Valkey 9.0 走了多数据库集群、MPTCP 这些方向,Redis 8.4 走了 ASM。两边都在做,但 API 细节和实现路径已经开始分化。如果你的代码要同时兼容两边,运维脚本层面需要做抽象隔离,不要把 CLUSTER MIGRATION 这类命令硬编码在业务代码里。

8.4 给不同角色的行动建议

如果你在做客户端/SDK:立刻检查 ASK 和 MOVED 的处理是否分开,TRYAGAIN 是否有退避重试。这是最低成本、最高收益的改动。

如果你在做运维/SRE:把 CLUSTER SLOT-STATS 接进监控,建立 slot 级的资源画像。等你真的需要迁移时,才不至于凭 key 数量拍脑袋。

如果你在做架构:重新评估"因为 resharding 太痛所以一次性买大集群"这个决策。当迁移成本大幅下降,按需扩缩容的经济性会重新占优。

如果你在设计自己的分片系统:直接抄第 8.2 节那张表的结构。不要再走"边搬边切"的路线 A,那条路上的坑前人已经踩完了。


最后一句题外话:这类改动往往在 release note 里只有一行字——"支持原子槽位迁移"。但读懂它需要理解所有权语义、复制协议、故障回滚、资源竞争这一整套东西。技术的密度从来不在功能列表里,在它解决的那个具体的痛处里。 而能不能读出这个密度,某种程度上就是工程师之间真正的差距。

推荐文章

MySQL 优化利剑 EXPLAIN
2024-11-19 00:43:21 +0800 CST
纯CSS绘制iPhoneX的外观
2024-11-19 06:39:43 +0800 CST
Golang 中你应该知道的 noCopy 策略
2024-11-19 05:40:53 +0800 CST
php使用文件锁解决少量并发问题
2024-11-17 05:07:57 +0800 CST
程序员茄子在线接单