原子槽位迁移深度拆解:当 Redis/Valkey 决定把「重分片」从数据面搬回控制面
一句话结论:Atomic Slot Migration(ASM)真正做的不是「迁得更快」,而是把 slot 所有权的转移,从一个持续数分钟、暴露给客户端的数据面过程,压缩成一次控制面的瞬时切换。速度提升只是这次语义手术的副产品。
如果你运维过 Redis Cluster,大概率有过这样的夜晚:凌晨两点执行 redis-cli --cluster reshard,盯着进度条,同时盯着监控大盘上那条不安分的 P99 曲线,一边祈祷业务方的 Lua 脚本别在这个窗口里报 TRYAGAIN。
这篇文章想把这件事讲透:为什么分片系统的在线重分布这么难,旧机制的问题到底出在哪一层,ASM 用什么代价换来了原子性,以及这个代价在你的集群里具体值多少毫秒。
文章结构:
- 背景:分片系统的阿喀琉斯之踵
- 核心概念:slot、所有权与三种重定向
- 旧机制拆解:逐 Key 迁移的五宗罪
- ASM 架构分析:快照 + 流复制 + 原子切换
- 代码实战:迁移安全客户端、热点识别、最小可运行迁移引擎
- 性能优化:cutover 窗口的数学模型与调参
- 踩坑清单与调优清单
- 总结展望:这套范式的普适性
一、背景:为什么在线重分布是分片系统的阿喀琉斯之踵
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}:profile 和 user:{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_serialize、T_deserialize、T_lookup、T_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 INFO 的 cluster_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 后执行,不建议自动执行重分片')
这个脚本里嵌了三个我认为很重要的工程判断:
- 不迁最热的那个 slot。超级热点迁走只是换个节点继续热,真正的解法是业务侧拆 key 或改 hash tag。工具应该帮你做均衡,不应该帮你掩盖设计问题。
- 加了震荡保护。如果迁过去会让目标变成新的最热节点,就停止。贪心算法不加这个保护很容易在两个节点之间来回搬。
- 默认不自动执行。重分片是有状态的高风险操作,
--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-STATS 的 MEMORY-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 十条踩坑
客户端把 ASK 当 MOVED 处理。最高频的 bug,导致迁移期请求在两节点间乒乓。检查方法:在测试环境手动构造一次迁移,抓包看是否有异常的 MOVED 往返。
Lua 脚本没处理 TRYAGAIN。日常永远不触发,迁移那晚集体爆炸。就算上了 ASM,混合版本集群里旧机制仍可能被用到。
迁移前没算目标节点内存。见 6.4。
在 failover 刚发生后立刻迁移。集群视图还没收敛,迁移大概率中止。先确认
cluster_state:ok且cluster_known_nodes符合预期。跨机房迁移低估了 cutover 窗口。RTT 直接进入公式。跨机房场景建议把批量拆小,并在业务低峰做。
超级热点 slot 硬迁。STREAMING 永远不收敛,白白消耗资源。先解决热点本身。
迁移期间同时开着自动 RDB。资源竞争,快照阶段耗时翻倍。
一次性迁太多 slot。失败重来的成本随批量线性上升,而失败概率随时长上升,两者相乘是超线性的。
没有做迁移后校验。ASM 语义上是安全的,但工程上任何数据搬迁都应该有对拍。至少校验 key 数量和随机抽样的 value。
依赖
--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 协议改为按字节流"的自然结果,任何系统做同样的改造都会得到类似量级的提升。真正重要的是三点:
- 中间态从分钟级压缩到毫秒级。客户端不再需要在长时间窗口内处理降级语义。
- 失败有了原子回滚。cutover 之前源数据始终权威,这让重分片从"高风险操作"变成"可重试操作"。这个心理门槛的降低,会直接改变运维行为——从"能不迁就不迁"变成"该迁就迁"。
- 多 key 语义在迁移期得以保持。业务代码不需要为运维动作做特殊适配。
第 2 点我认为最被低估。**一个操作能不能被自动化,取决于它失败时能不能自动恢复。**旧机制失败要人工介入,所以 resharding 永远是人肉操作、永远排在凌晨。ASM 有了干净的回滚路径,才让"自动再均衡"这件事在工程上变得可能。
8.2 这套范式的普适性
有意思的是,"快照 + 增量流 + 原子切换"这个模式,几乎是所有成熟分片系统的收敛答案:
| 系统 | 分片单元 | 迁移机制 |
|---|---|---|
| TiKV | Region | Raft snapshot + log replication + leader transfer |
| CockroachDB | Range | Raft snapshot + 增量 + 原子 lease 转移 |
| Vitess | Shard | VReplication 流 + cutover 时短暂 write block |
| Kafka | Partition | Follower fetch 追平 + leader election |
| Redis/Valkey | Hash 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 里只有一行字——"支持原子槽位迁移"。但读懂它需要理解所有权语义、复制协议、故障回滚、资源竞争这一整套东西。技术的密度从来不在功能列表里,在它解决的那个具体的痛处里。 而能不能读出这个密度,某种程度上就是工程师之间真正的差距。