Redis Cluster 集群运维实战:槽位迁移、MOVED/ASK 重定向与扩容缩容的数据一致性
1. 从一个真实的告警说起
凌晨两点,监控系统弹出一条告警:某核心业务的缓存命中率从 98% 掉到 71%,接口平均响应时间上涨了 4 倍。值班同学登录集群查看,发现集群状态是 ok,但客户端日志里密密麻麻地刷着 MOVED 和 ASK 重定向信息,还有少量 CLUSTERDOWN 报错。进一步排查才发现,白天运维同学为了扩容,正在做槽位迁移,迁移过程中客户端频繁重定向,部分请求超时后被业务代码当成缓存未命中处理,回源把数据库压高了。
这个场景说明一件事:Redis Cluster 的槽位迁移不是孤立操作,它会直接改变客户端请求的路由路径,进而影响延迟、一致性和业务行为。很多工程师会用 redis-cli --cluster 完成扩容,但对"迁移时到底发生了什么""MOVED 和 ASK 有什么区别""迁移过程中数据会不会丢""客户端怎么处理"这些问题没有建立起整体框架,一旦线上出问题就很难快速定位。
这篇文章的目标是先帮你在脑子里装一张 Redis Cluster 的"地图",然后沿着一次请求的完整路径,把槽位迁移、MOVED、ASK、扩容缩容和数据一致性串起来。读完之后,你应该能对着这张地图解释:一个 key 存在哪、请求怎么走、迁移时状态怎么变、什么情况下会丢数据、客户端需要做什么。
先记住一个最小模型:Redis Cluster 把整个数据空间切成 16384 份,每份叫一个槽位,每个主节点负责其中一部分槽位;客户端先算出 key 属于哪个槽,再去找负责这个槽的节点。当槽位在节点之间搬家时,客户端会被临时"指路",这就是 MOVED 和 ASK 的由来。
2. 整体框架:角色、连接与一次请求的流转
2.1 集群里有哪些角色
Redis Cluster 是去中心化的,没有单独的控制平面。每个节点同时承担三重身份:数据节点、总线成员、故障检测参与者。为了不把概念混在一起,可以先把整体拆成三部分来看:
- 数据平面:主节点(master)负责读写自己槽位内的数据,从节点(replica)异步复制主节点数据,主节点故障时从节点可被提升。
- 元数据平面:每个节点都保存一份集群配置(clusterState),记录 16384 个槽位分别由哪个节点负责,以及所有节点的 IP、端口、角色和状态。
- 通信平面:集群总线(Cluster Bus)是节点之间的一条独立 TCP 连接,默认端口是数据端口加 10000(例如数据端口 6379,总线端口 16379)。它负责 gossip 协议交换节点信息、广播槽位归属变化、传播故障判定和配置纪元(epoch)。
这里最容易误解的是:集群总线不是用来传业务数据的,业务请求走的是普通客户端连接,节点之间复制走的是另一条复制连接。三条链路各司其职,别把总线端口当成数据端口去连。
2.2 一次请求的完整路径
现在让一次请求完整走一遍,你就能看到槽位、重定向和总线分别在什么位置介入:
text
客户端 目标节点A 目标节点B 集群总线
| | | |
|-- GET user:1 ---->| | |
| (本地算槽=5018) | | |
| |-- 查本地集群配置 ->| |
| | 槽5018属于B | |
|<-- MOVED 5018 B --| | |
| | | |
|-- GET user:1 ------------------------>| |
| | |-- 命中,返回值 --->| 返回数据
|<-------------------------------------- 值 |
| | | |
|<== 后续槽位变化通过总线 gossip 扩散 ==|===================|
步骤拆解:
- 客户端对 key 做 CRC16 计算,再对 16384 取模,得到槽位号。
- 客户端在本地缓存的槽位映射表里查这个槽由谁负责,直接连对应节点。
- 如果客户端映射表过期,连到了错误节点,该节点会返回
MOVED重定向,客户端更新映射表后重试。 - 如果该槽正在迁移,源节点对已迁走的 key 返回
ASK,客户端临时转向目标节点,但不更新本地映射表。 - 槽位归属一旦正式变更,会通过集群总线 gossip 扩散到所有节点,最终客户端也会通过
MOVED学到新映射。
2.3 关键概念速查
| 概念 | 解决什么问题 | 关键点 |
|---|---|---|
| 槽位(slot) | 把 key 空间水平切分 | 固定 16384 个,CRC16(key) mod 16384 |
| 集群总线 | 节点间元数据同步 | 端口 = 数据端口 + 10000,gossip 协议 |
| 配置纪元 | 解决配置冲突 | 谁的 epoch 大听谁的 |
| MOVED | 槽位已永久搬家 | 客户端应更新本地槽位映射 |
| ASK | 槽位正在搬家 | 临时转向,不更新映射 |
| 迁移状态 | 标记槽位在搬 | MIGRATING / IMPORTING |
3. 槽位迁移:谁在搬、怎么搬、搬到什么程度
3.1 迁移的本质:把 key 从一个节点搬到另一个节点
槽位迁移说到底是把一个或多个槽位内的所有 key,从源节点搬到目标节点。搬完之后,这个槽位的归属正式变更。整个迁移是分阶段的,不是一次性原子切换,这一点决定了后面所有一致性问题。
迁移前三方状态:源节点负责某槽,目标节点不负责该槽;集群里其他节点也都认为该槽属于源节点。迁移中:源节点把槽标记为 MIGRATING,目标节点把槽标记为 IMPORTING,此时该槽仍然"名义上"属于源节点,但数据正在往目标节点搬。迁移完成:源节点删除该槽的归属,目标节点正式接管,通过总线广播新配置。
3.2 迁移涉及的命令
运维常用的命令分两类:高层封装命令和底层原子命令。高层命令由 redis-cli --cluster 封装,底层命令由 CLUSTER 系列组成。
bash
# 高层命令:把节点 B 加入集群,然后把若干槽位从 A 迁到 B
redis-cli --cluster add-node 192.168.1.11:6379 192.168.1.10:6379
# 重新分片(会交互式询问迁移多少槽、从哪个节点迁)
redis-cli --cluster reshard 192.168.1.10:6379
# 底层命令:在目标节点上把槽 5018 设为导入状态
redis-cli -h 192.168.1.11 -p 6379 cluster setslot 5018 importing <源节点ID>
# 在源节点上把槽 5018 设为迁移状态
redis-cli -h 192.168.1.10 -p 6379 cluster setslot 5018 migrating <目标节点ID>
参数解释:importing 和 migrating 后面跟的是对端节点 ID,不是 IP。节点 ID 可以用 CLUSTER NODES 查到。命令执行前要先确认两个节点都认识对方,否则 setslot 会失败。
3.3 一次迁移的流水线
text
源节点A (MIGRATING 5018) 目标节点B (IMPORTING 5018)
| |
| 1. GETKEYSINSLOT 5018 100 ---------->| (列出槽内 key)
|<------------ 返回 key 列表 -----------|
| |
| 2. MIGRATE host port key ... -------->| 逐个/批量搬 key
|<------------ +OK --------------------|
| |
| 3. 重复直到槽内无 key |
| |
| 4. CLUSTER SETSLOT 5018 NODE B ------>| 双方都执行
| |
| 5. 广播新配置到集群总线 -------------->| 其他节点更新映射
迁移过程中,源节点对已迁走的 key 返回 ASK,对还没迁走的 key 正常处理;目标节点对未迁完的 key,只有在收到带 ASKING 标记的请求时才处理,否则返回 MOVED。
3.4 迁移的边界与常见错误
- 大 key 会让单次 MIGRATE 阻塞 :
MIGRATE搬一个几百万元素的 Hash,源节点会长时间阻塞,影响同节点其他请求。建议迁移前用--bigkeys或MEMORY USAGE排查,必要时先拆分大 key。 - 迁移不是事务 :中途失败可能导致部分 key 已在目标节点、部分还在源节点,此时槽位状态还是
MIGRATING,需要通过CLUSTER SETSLOT ... STABLE回滚或继续迁移。 - 不要在业务高峰期迁移:即使每个 key 很小,迁移也会增加两个节点的 CPU 和网络负载,重定向会放大客户端 QPS。
4. MOVED 与 ASK:两种重定向到底差在哪
4.1 一句话区分
先记住一句话模型:MOVED 是"这个槽永久搬走了,以后别再问我",ASK 是"这个 key 暂时在那边,这次去那边取,但先别改你的地图"。
为什么需要两种重定向?因为迁移期间槽位的"所有权"和"实际数据位置"会短暂分离。槽位名义上还属于源节点,所以客户端映射表不能立刻改;但有些 key 已经搬到目标节点了,这次请求必须去目标节点取。ASK 就是为这个短暂窗口设计的。
4.2 两种响应的对比
| 维度 | MOVED | ASK |
|---|---|---|
| 触发时机 | 槽位已正式变更归属 | 槽位正在迁移,key 已搬到目标节点 |
| 客户端动作 | 更新本地槽位映射,重试新节点 | 不更新映射,本次请求带 ASKING 转发目标节点 |
| 是否持久 | 是,最终所有节点都会知道 | 否,迁移结束后不再出现 |
| 常见错误 | 客户端不刷新映射导致反复重定向 | 忘记发 ASKING,目标节点返回 MOVED |
| 报文示例 | -MOVED 5018 192.168.1.11:6379 |
-ASK 5018 192.168.1.11:6379 |
4.3 用 redis-cli 手动观察两种重定向
准备一个三主三从集群,人为把槽 5018 设为迁移状态,就可以观察到两种响应:
bash
# 1. 在目标节点 B 上设置导入
redis-cli -h 192.168.1.11 -p 6379 cluster setslot 5018 importing <A的节点ID>
# 2. 在源节点 A 上设置迁移
redis-cli -h 192.168.1.10 -p 6379 cluster setslot 5018 migrating <B的节点ID>
# 3. 在源节点 A 上写入一个还没迁走的 key,正常返回
redis-cli -h 192.168.1.10 -p 6379 set user:1 v1
# 4. 用 MIGRATE 把 user:1 搬到 B
redis-cli -h 192.168.1.10 -p 6379 migrate 192.168.1.11 6379 user:1 0 5000
# 5. 此时客户端不直接连 B,而是通过 A 访问 user:1,会收到 ASK
redis-cli -h 192.168.1.10 -p 6379 get user:1
# 输出类似:(error) ASK 5018 192.168.1.11:6379
# 6. 客户端应发送 ASKING,再发 GET
redis-cli -h 192.168.1.11 -p 6379 asking
redis-cli -h 192.168.1.11 -p 6379 get user:1
# 输出:"v1"
# 7. 正式完成迁移后,任何节点访问 user:1 都返回 MOVED
redis-cli -h 192.168.1.10 -p 6379 cluster setslot 5018 node <B的节点ID>
redis-cli -h 192.168.1.11 -p 6379 cluster setslot 5018 node <B的节点ID>
redis-cli -h 192.168.1.10 -p 6379 get user:1
# 输出类似:(error) MOVED 5018 192.168.1.11:6379
关键点:ASKING 必须和真正的命令在同一条连接上、紧挨着发送,它只对下一条命令生效。这就是为什么手写客户端时要特别小心连接池的复用,如果 ASKING 和 GET 被分到不同连接上,ASKING 就白发了。
这个例子能帮你理解两种重定向的差别,但它不能替代真实客户端的状态机:生产客户端还要处理连接失败、超时重试、映射表过期等复杂情况。
4.4 Java 客户端怎么处理
生产环境几乎不会手写重定向逻辑,而是交给 Jedis、Lettuce、Redisson 这类客户端。以 Lettuce 为例,它默认开启了自动重定向,收到 MOVED 会刷新映射并重试,收到 ASK 会发 ASKING 再转发。这里有一个最小可复现的示例。
示例目标 :用 Lettuce 连一个三主三从集群,观察 MOVED 自动处理与拓扑刷新。
前置环境 :本机已启动一个三主三从集群,端口 7000-7005。依赖 io.lettuce:lettuce-core:6.3.2.RELEASE。
java
import io.lettuce.core.RedisURI;
import io.lettuce.core.cluster.RedisClusterClient;
import io.lettuce.core.cluster.api.StatefulRedisClusterConnection;
import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;
import java.util.Arrays;
public class LettuceClusterDemo {
public static void main(String[] args) {
RedisURI n1 = RedisURI.create("redis://127.0.0.1:7000");
RedisURI n2 = RedisURI.create("redis://127.0.0.1:7001");
RedisURI n3 = RedisURI.create("redis://127.0.0.1:7002");
RedisClusterClient client = RedisClusterClient.create(Arrays.asList(n1, n2, n3));
try (StatefulRedisClusterConnection<String, String> conn = client.connect()) {
RedisAdvancedClusterCommands<String, String> cmd = conn.sync();
for (int i = 0; i < 100; i++) {
String key = "user:" + i;
cmd.set(key, "v" + i);
}
System.out.println("写入完成,get user:1=" + cmd.get("user:1"));
} finally {
client.shutdown();
}
}
}
关键步骤与预期结果 :客户端启动时用种子节点 7000-7001-7002 拉取集群拓扑,然后本地维护槽位映射;写入 100 个 key 会分布到三个主节点;get user:1 能直接命中。此时若在另一个终端执行 redis-cli --cluster reshard,你会看到部分请求在日志里触发重定向,但结果仍然正确。
容易改错的地方 :种子节点只需给部分节点,Lettuce 会自动发现全量拓扑;但如果你把种子节点写成单点且那个节点挂了,初始连接就会失败,所以生产至少配 3 个种子。另外,Lettuce 默认 autoReconnect 和拓扑刷新是开启的,不要随意关掉。
5. 扩容:加机器、迁槽位、扩散配置
5.1 扩容要解决的核心问题
扩容的目标通常是提升容量或吞吐:内存不够了要加主节点分摊数据,QPS 高了要加主节点分摊请求。扩容不是"加节点就完事",还需要把原来集中在老节点上的槽位重新分配,否则新节点负责 0 个槽位,等于白加。
标准扩容分四步:
- 启动新节点,用
CLUSTER MEET或--cluster add-node把它加入集群。 - 新节点此时是主节点但负责 0 个槽位,需要把它的从节点也加进来。
- 用
--cluster reshard或手动MIGRATING/IMPORTING把部分槽位从老节点迁到新节点。 - 等待集群总线 gossip 把新配置扩散到所有节点,客户端刷新映射。
5.2 一个可复现的扩容案例
目标:在一个三主三从集群中新增一个主从对,并把 1000 个槽位从老主节点迁到新主节点。
前置环境:已有集群节点 7000-7005(7000/7001/7002 主,7003/7004/7005 从)。新增节点 7006(主)和 7007(从),配置文件已按集群模式启动。
bash
# 1. 把 7006 加入集群(作为新主)
redis-cli --cluster add-node 127.0.0.1:7006 127.0.0.1:7000
# 2. 把 7007 加入集群,并指定它是 7006 的从节点
redis-cli --cluster add-node 127.0.0.1:7007 127.0.0.1:7000 --cluster-slave --cluster-master-id <7006的节点ID>
# 3. 查看当前槽位分配,确认 7006 负责 0 个槽
redis-cli --cluster check 127.0.0.1:7000
# 4. 把 7000 上的一部分槽位迁到 7006
# 交互式 reshard 会询问要迁移多少槽位、从哪个节点迁、迁到哪个节点
redis-cli --cluster reshard 127.0.0.1:7000 \
--cluster-from <7000的节点ID> \
--cluster-to <7006的节点ID> \
--cluster-slots 1000 \
--cluster-yes
# 5. 再次检查,确认槽位分布和节点状态
redis-cli --cluster check 127.0.0.1:7000
预期结果 :--cluster check 输出中,7006 负责的槽位从 0 变成 1000,集群状态为 ok,所有 16384 个槽位都有主节点负责。迁移过程中客户端会经历 MOVED/ASK,但业务不中断。
关键点与边界:
--cluster-from和--cluster-to用的是节点 ID,不是 IP。- 迁移的槽位数量要结合每个槽的平均数据量估算,避免一次迁太多导致长时间重定向。
- reshard 过程中如果某个 key 特别大,会拖慢整个迁移;建议先处理大 key。
- 如果集群开启了
cluster-require-full-coverage yes,只要有一个槽位没有主节点负责,整个集群不可用;扩容时要保持所有槽位始终有归属。
5.3 扩容的流量影响
扩容不是零成本的。新节点加入后,客户端拓扑刷新会有延迟,期间一部分请求可能仍然发到老节点并收到 MOVED。假设某业务 QPS 是 5 万,重定向比例 5%,就多出 2500 次额外往返,如果老节点此时负载已经很高,重定向会进一步放大延迟。工程上建议:
- 低峰期扩容,预留足够时间。
- 每次迁移的槽位数量分批,不要一把梭。
- 观察
cluster_stats_messages_sent、cluster_stats_messages_received等指标,确认总线通信正常。
6. 缩容:下线节点前必须做的事
6.1 缩容比扩容更容易出事
缩容的直觉是"把节点删掉",但如果一个主节点上还有槽位或数据,直接删会导致这部分槽位无人负责,集群进入 fail 状态。正确顺序是:先把槽位迁空,再删除节点。
缩容分为两种情况:删除主节点和删除从节点。从节点没有槽位,直接 --cluster del-node 即可;主节点必须先 reshard 把它负责的槽位全部迁给其他节点,确认它负责 0 个槽之后才能删除。
6.2 一个完整的缩容案例
目标:把一个 4 主集群缩到 3 主,安全下线节点 7006 及其从节点 7007。
前置环境:集群有 7000/7001/7002/7006 四个主节点,7003/7004/7005/7007 四个从节点,7006 负责 1000 个槽位。
bash
# 1. 确认 7006 负责哪些槽位
redis-cli --cluster check 127.0.0.1:7000
# 2. 把 7006 的 1000 个槽位全部迁给 7000
# 注意 --cluster-from 是 7006,--cluster-to 是 7000
redis-cli --cluster reshard 127.0.0.1:7000 \
--cluster-from <7006的节点ID> \
--cluster-to <7000的节点ID> \
--cluster-slots 1000 \
--cluster-yes
# 3. 再次确认 7006 负责 0 个槽位,且集群状态 ok
redis-cli --cluster check 127.0.0.1:7000
# 4. 删除从节点 7007
redis-cli --cluster del-node 127.0.0.1:7007 <7007的节点ID>
# 5. 删除主节点 7006
redis-cli --cluster del-node 127.0.0.1:7006 <7006的节点ID>
# 6. 最后确认集群拓扑和槽位覆盖
redis-cli --cluster check 127.0.0.1:7000
预期结果 :节点列表里不再有 7006 和 7007,16384 个槽位仍全部有主节点负责,集群状态 ok。
容易出错的地方 :如果跳过第 2 步直接删 7006,--cluster check 会报 [ERR] Nodes don't agree about configuration! 或提示槽位未覆盖,此时需要把残留槽位手动迁走或用 CLUSTER SETSLOT ... STABLE 恢复。删除节点前一定要确认它已经不再被其他节点当成槽位负责人。
6.3 缩容期间的数据一致性
缩容期间,槽位在被迁走的节点上会经历 MIGRATING,在接收节点上是 IMPORTING,这和扩容完全对称。唯一不同的是,下线节点的数据在迁移完成后就不再存在,所以迁移必须完整执行完毕才能删除节点。如果迁移中途失败,源节点上还有残留 key,此时它的槽位状态可能仍是 MIGRATING,需要通过 CLUSTER SETSLOT <slot> STABLE 清理状态,再决定重新迁移还是回滚。
7. 集群总线:重定向之外的隐藏主角
7.1 总线到底传了什么
前面反复提到"通过总线扩散配置",但集群总线具体传什么,值得单独说清楚。每条总线消息有类型,常见的有 PING、PONG、MEET、FAIL、UPDATE、PUBLISH。其中 UPDATE 用来传播槽位配置变化,FAIL 用来传播节点故障判定,MEET 用来把新节点介绍给集群。
节点之间按固定周期(默认每秒若干次)互发 PING,随机携带自己知道的其他节点信息,这就是 gossip。gossip 的好处是去中心化、容错;代价是配置扩散有延迟,不是瞬间全局一致。
7.2 总线消息与业务请求的关系
可以用一个简单的类比:把集群想象成一个办公室,业务请求是同事之间直接对话,总线消息是公告栏。槽位搬家相当于某个工位换了人,公告栏需要一点时间才能更新。更新之前,去旧工位的人会被口头告知"去新工位",这就是重定向。这个类比能帮你理解扩散延迟和重定向的来源,但它不能替代真实的 epoch 机制:配置冲突时,节点是通过配置纪元大小来决定谁的信息更新的,不是靠先来后到。
7.3 总线相关的排障指标
| 指标 | 含义 | 异常时可能的原因 |
|---|---|---|
cluster_stats_messages_sent |
总线发送消息数 | 突增可能因大量 FAIL/UPDATE |
cluster_stats_messages_received |
总线接收消息数 | 与 sent 严重不对称说明网络问题 |
cluster_known_nodes |
已知节点数 | 持续增长说明有僵尸节点 |
cluster_size |
负责槽位的主节点数 | 与预期不符说明槽位未覆盖 |
cluster_state |
集群状态 | fail 通常因槽位未全覆盖 |
8. 数据一致性:迁移期间到底会不会丢数据
8.1 先区分三种"一致性"
聊 Redis Cluster 的数据一致性,先要区分三个层次,否则很容易鸡同鸭讲:
- 主从复制一致性:主节点写入后异步复制到从节点,主节点故障时可能丢失尚未复制的写入。这是 Redis 的复制模型决定的,和 Cluster 无关。
- 槽位迁移一致性 :迁移过程中同一个 key 不会同时存在于两个节点被同时读写,靠的是
MIGRATING/IMPORTING状态和重定向配合。 - 客户端路由一致性 :客户端本地映射表可能滞后,靠
MOVED/ASK纠正,最终一致而非实时一致。
8.2 迁移期间的一次写入会经历什么
假设槽 5018 正在从 A 迁到 B,此时客户端写入 user:1:
- 如果客户端直接连 A,A 发现
user:1还没迁走,正常写入 A,并异步复制给 A 的从节点。 - 如果
user:1已经迁到 B,A 返回ASK,客户端带ASKING去 B 写入,数据落在 B。 - 迁移完成后,槽 5018 归属 B,后续所有写入都去 B;A 上已经没有该槽的 key。
关键结论:迁移过程中单个 key 的读写始终被路由到"当前实际持有它的节点",不会出现两个节点同时接受同一个 key 写入的情况。但如果你在迁移过程中直接绕过客户端、用固定连接分别往 A 和 B 写同一个 key,就可能制造出两份数据,这是人为错误,不是集群机制的问题。
8.3 迁移失败会不会丢数据
MIGRATE 是"搬完再删源端"的语义:只有目标节点确认写入成功,源节点才会删除本地 key。如果 MIGRATE 因为网络或目标节点失败而超时,源节点会保留 key,不会丢。但有一个边界要注意:MIGRATE 带 COPY 选项时是复制而非搬移,源端不删;不带 COPY 时才是搬移。使用默认行为时,源端删除发生在目标端确认之后,所以正常路径下不会丢。
真正需要担心的是迁移过程中主节点宕机且从节点还没同步完,这属于主从复制的一致性窗口,不是槽位迁移特有的问题。
8.4 一个用 Java 验证迁移期间读写不丢的示例
目标:在槽位迁移进行中,持续对目标 key 做读写,验证最终值和写入次数一致。
前置环境 :三主三从集群,user:hot 所在槽位正在从 A 迁到 B。依赖 Jedis 集群客户端 redis.clients:jedis:5.1.2。
java
import redis.clients.jedis.HostAndPort;
import redis.clients.jedis.JedisCluster;
import redis.clients.jedis.DefaultJedisClientConfig;
import java.util.HashSet;
import java.util.Set;
public class MigrationConsistencyDemo {
public static void main(String[] args) throws InterruptedException {
Set<HostAndPort> nodes = new HashSet<>();
nodes.add(new HostAndPort("127.0.0.1", 7000));
nodes.add(new HostAndPort("127.0.0.1", 7001));
nodes.add(new HostAndPort("127.0.0.1", 7002));
DefaultJedisClientConfig cfg = DefaultJedisClientConfig.builder()
.connectionTimeoutMillis(2000)
.socketTimeoutMillis(2000)
.build();
try (JedisCluster cluster = new JedisCluster(nodes, cfg)) {
for (int i = 0; i < 200; i++) {
cluster.set("user:hot", "v" + i);
String got = cluster.get("user:hot");
if (!("v" + i).equals(got)) {
System.out.println("不一致:期望 v" + i + " 实际 " + got);
}
Thread.sleep(50);
}
System.out.println("最终值:" + cluster.get("user:hot"));
}
}
}
关键步骤与预期结果 :在另一个终端同时执行 redis-cli --cluster reshard 迁移 user:hot 所在槽位。示例代码持续写入 200 次并回读,正常情况下每次读到的都是刚刚写入的值,最终值是 v199。中间可能出现 MOVED/ASK 重试导致单次延迟升高,但不应该出现读到旧值。
容易改错的地方 :Jedis 的 JedisCluster 默认最大重定向次数是 5,如果迁移抖动剧烈可能会抛 JedisClusterMaxAttemptsException,生产上要结合重试策略业务侧兜底;另外连接池参数过小会导致重定向期间排队,间接放大延迟。
9. 生产实践建议:把机制变成可执行的操作规范
9.1 扩容缩容的操作清单
| 阶段 | 动作 | 检查项 |
|---|---|---|
| 准备 | 评估容量、QPS、大 key | --bigkeys、MEMORY USAGE |
| 加入 | 新节点 meet、加从节点 | --cluster check 状态 ok |
| 迁移 | reshard 分批迁槽 | 观察重定向率、延迟 |
| 扩散 | 等待总线同步 | cluster_known_nodes 稳定 |
| 收尾 | 删除下线节点、核对拓扑 | 16384 槽位全覆盖 |
9.2 客户端配置建议
- 至少配置 3 个种子节点,避免单点导致初始拓扑拉取失败。
- 开启拓扑刷新和自动重定向,不要为了"稳定"手动关闭。
- 设置合理的超时和最大重定向次数,配合业务侧降级。
- 连接池不要过小,重定向期间会额外占用连接。
9.3 监控要看什么
重点关注:重定向次数、cluster_state、cluster_slots_assigned、总线消息速率、各节点 latency 和 used_memory。重定向率突然升高通常意味着有槽位在迁移或有客户端映射长期不更新。
10. 常见误区与排障清单
10.1 常见误区
| 误区 | 实际情况 |
|---|---|
| MOVED 和 ASK 可以互换处理 | MOVED 要更新本地映射,ASK 只临时转发,处理错会反复重定向 |
| 迁移一定不丢数据 | 正常路径不丢,但迁移中主节点宕机仍可能因异步复制丢最近写入 |
| 集群状态 ok 就没问题 | 状态 ok 不代表迁移中无抖动,重定向和延迟仍需观察 |
| 直接删节点即可缩容 | 主节点必须先迁空槽位,否则集群可能进入 fail |
| 槽位迁移是原子的 | 迁移分阶段,中间状态可能残留,需要处理 MIGRATING/IMPORTING |
10.2 排障清单
按以下顺序排查集群异常:
text
1. CLUSTER INFO -> 看 cluster_state、cluster_slots_assigned
2. CLUSTER NODES -> 看节点角色、槽位、fail 标记
3. CLUSTER SLOTS -> 看当前槽位到节点的映射
4. redis-cli --cluster check -> 综合诊断集群一致性
5. 客户端日志 -> 看重定向频率、超时、最大重试异常
6. 节点 slowlog/latency -> 看是否有大 key 或阻塞命令
7. 总线指标 -> 看消息速率、已知节点数是否异常
每一步的判读标准:cluster_state 为 fail 时优先看 cluster_slots_assigned 是否为 16384;CLUSTER NODES 里出现 fail? 说明节点间对故障判定不一致,通常是网络分区;CLUSTER SLOTS 与客户端映射不一致说明拓扑扩散还没完成,稍等并触发一次刷新。
11. 面试/复盘问题
- Redis Cluster 为什么是 16384 个槽位,而不是 65536?请从心跳包大小和集群规模角度回答。
- MOVED 和 ASK 分别由哪个节点、在什么状态下返回?客户端处理方式有何不同?
- 槽位迁移过程中,源节点和目标节点分别处于什么状态?目标节点为什么需要
ASKING才能响应请求? - 如果迁移进行到一半,源节点宕机了,会发生什么?数据会不会丢?
- 集群总线的端口怎么确定?它和客户端连接、主从复制连接有什么区别?
- 缩容时为什么必须先迁空槽位再删除主节点?如果顺序反了怎么恢复?
- 一次请求从客户端发出到返回,可能经过哪些重定向路径?画出来并说明每一步。
12. 总结
回到开头那张地图:Redis Cluster 用 16384 个槽位把数据切开,每个节点保存一份槽位映射,节点之间靠集群总线 gossip 同步元数据。客户端先算槽位、再按映射路由;映射过期时靠 MOVED 纠正,槽位正在搬家时靠 ASK 临时转向。扩容就是加节点、迁槽位、扩散配置;缩容就是先迁空槽位、再删节点。迁移过程中单个 key 的读写始终落在实际持有它的节点上,正常路径不会丢数据,但主从异步复制窗口和迁移失败仍需单独处理。
如果你只记三句话:槽位是路由单位,总线是元数据通道,MOVED/ASK 是客户端纠错机制。 掌握这三点,扩容缩容、重定向处理和一致性判断都能落到具体操作上。
13. 参考资料
- Redis 官方文档:Cluster Specification(redis.io/docs/reference/cluster-spec)
- Redis 官方文档:CLUSTER SETSLOT、MIGRATE、ASKING、CLUSTER INFO、CLUSTER NODES 命令参考
- Redis 官方文档:Redis Cluster Tutorial(redis.io/docs/management/scaling)
- Lettuce 官方文档:Redis Cluster 支持与拓扑刷新
- Jedis 官方文档:JedisCluster 使用说明
- 《Redis 设计与实现》黄健宏著,关于集群与复制章节