在Redis集群中,当客户端把键发给了错误的节点,服务端并不会直接帮忙转发,而是返回一个重定向错误,让客户端自己去找正确的节点。这个错误有时是MOVED,有时是ASK,分别对应了两种完全不同的集群状态。混淆二者不仅在排查日志时容易误判,还可能让客户端维护出错误的槽位映射表,导致迁移期间请求大面积失败。

要理解MOVED和ASK的差异,首先得从Redis集群的槽位模型说起。Redis Cluster将整个键空间划分为16384个哈希槽,每个主节点负责其中一段槽位。客户端写入或读取某个键之前,会先根据键名计算CRC16校验值并对16384取模,得到键所属的槽位,再查询本地槽位映射表找到负责该槽的节点。这个映射表通常通过CLUSTER SLOTS命令获取,也可以在收到重定向错误时修正。
一、Redis集群的槽位机制与重定向触发背景
Redis集群的设计目标是把数据分散到多个节点,同时避免使用中心代理节点。它没有采用传统一致性哈希的虚拟节点方案,而是把键空间固定切分为16384个槽,每个槽本质上是键集合的逻辑分区。例如执行CLUSTER KEYSLOT user:1001,会得到某个0到16383之间的整数,这个整数就代表该键所在的槽。节点A可能负责0到5460,节点B负责5461到10922,节点C负责10923到16383。
当客户端向节点A发送一个属于节点B的键请求时,节点A会先检查该槽是否处于迁移状态。如果没有迁移,节点A就知道这个槽已经明确由节点B负责,于是返回MOVED错误,并把槽号和节点B的地址一起告诉客户端。如果该槽正在从节点A迁移到节点B,节点A发现请求的键已经被迁移走了,就会返回ASK错误,并指向节点B。因此,MOVED和ASK的差别本质上是集群元数据处于稳定状态还是迁移中间状态。
这个过程看似简单,但客户端处理是否正确直接影响性能。每次重定向都意味着一次额外的网络往返,如果客户端不更新缓存,同一个槽的大量请求会重复撞在错误节点上。而如果在ASK场景下错误地更新了槽位映射,则会造成更隐蔽的数据不一致问题。
二、MOVED重定向:归属已定的永久转向
MOVED是Redis集群中最常见的重定向错误。它表示当前节点明确知道该键所在槽位已经由另一个节点负责,并且该槽位当前没有迁移任务。服务端返回的错误格式通常为:MOVED 槽号 目标节点IP:端口。比如在节点6379上执行一个键属于槽3999的命令,可能得到如下输出:
127.0.0.1:6379> GET user:1001 (error) MOVED 3999 127.0.0.1:6381
从上面的响应可以看出,MOVED错误携带了两个关键信息:槽号3999以及目标节点地址127.0.0.1:6381。客户端拿到这个错误后,应该把本地槽位映射表中槽3999的负责人更新为127.0.0.1:6381,然后重新向新节点发送原始命令。这个更新是安全的,因为MOVED意味着集群拓扑已经稳定,槽3999在后续一段时间内都会由6381节点处理,除非再次发生故障转移或手动迁移。
MOVED的语义是永久重定向。这里说的永久不是指永远不会变,而是相对于当前集群视图而言,该槽位的负责关系已经确定完成。如果客户端只针对当前请求切换到目标节点,而不更新槽位映射,那么下一次访问槽3999时仍然可能发给旧节点,再次收到MOVED,形成不必要的重定向开销。因此成熟的集群客户端在捕获MOVED后会立即刷新槽位缓存,甚至有些客户端会主动触发集群拓扑刷新,避免后续请求继续走弯路。
在代码层面,处理MOVED的典型逻辑如下:
def execute_with_moved_handling(key, command, *args):
slot = crc16(key) % 16384
node = slot_map.get_node(slot)
try:
return node.execute(command, key, *args)
except MovedError as exc:
# MOVED错误意味着槽位归属已经稳定,应更新本地映射
slot_map.update(slot, exc.host, exc.port)
new_node = cluster.get_node(exc.host, exc.port)
return new_node.execute(command, key, *args)
这种更新缓存的做法在绝大多数客户端库中都是默认行为。需要注意的是,更新槽位映射必须保证原子性,否则在并发请求下可能出现一个线程读到新映射,另一个线程仍然使用旧映射的情况。通常的做法是使用读写锁或直接替换整个映射表对象。
三、ASK重定向:迁移期间的临时转向
与MOVED不同,ASK错误只在槽位迁移过程中出现。Redis集群支持在线扩容和缩容,迁移槽位时并不会一次性把整个槽切走,而是把槽中的键逐个从源节点搬到目标节点。迁移过程中,集群的槽位归属记录仍然指向源节点,也就是说,尚未完成迁移的槽在CLUSTER SLOTS命令中仍由源节点负责。但某些键可能已经被提前迁移到了目标节点。
假设源节点6379正在把槽3999迁移到目标节点6381。此时如果客户端向6379发送GET user:1001,而user:1001这个键已经被迁移到6381,那么6379无法直接返回数据,也不能假装键不存在。它会返回一个ASK错误,格式同样包含槽号和目标节点地址,例如:
127.0.0.1:6379> GET user:1001 (error) ASK 3999 127.0.0.1:6381
ASK的含义是:这个键现在临时在6381节点上,但槽3999的正式归属还没切换,所以你不能更新槽位映射。客户端收到ASK后,需要先向目标节点6381发送一个ASKING命令,然后再重发原来的GET命令。ASKING命令的作用是告诉目标节点:这个请求是经过ASK重定向过来的,虽然我还不是槽3999的正式负责人,但请允许我处理该键。目标节点收到ASKING后,会在当前连接上打开一个一次性开关,允许下一次命令访问尚未完成迁入的槽。这个开关只在下一个命令生效,之后自动关闭。
下面是一个处理ASK错误的简化示例:
def execute_with_ask_handling(key, command, *args):
slot = crc16(key) % 16384
node = slot_map.get_node(slot)
try:
return node.execute(command, key, *args)
except AskError as exc:
# ASK是临时重定向,不能更新slot_map
target = cluster.get_node(exc.host, exc.port)
target.send_raw("ASKING")
return target.execute(command, key, *args)
这个例子中,ASK处理逻辑不会修改槽位映射表,因为迁移还没有结束。如果客户端误把ASK当成MOVED,将槽3999的负责人改成目标节点6381,那么同一槽内尚未迁移的键仍然存放在6379上,后续针对这些键的请求都会被发到6381,而6381没有ASKING开关,会认为槽3999不属于自己,从而返回MOVED错误,形成请求失败或者来回重定向的混乱。
四、MOVED与ASK的核心区别对比
为了更直观地理解两个错误,可以从触发条件、客户端处理、槽位归属状态几个维度进行对比。MOVED发生在集群拓扑稳定之后,槽位已经完成转移,当前节点知道新的负责人是谁,错误中给出的是永久有效的目标地址。ASK则发生在线性迁移过程中,源节点仍然在元数据上持有槽位,只是部分键提前搬到了目标节点,错误中给出的目标节点只对特定键临时有效。
| 对比维度 | MOVED | ASK |
|---|---|---|
| 触发阶段 | 槽位迁移完成或拓扑稳定 | 槽位迁移进行中 |
| 目标地址有效性 | 当前槽位正式负责人 | 临时存放部分键的节点 |
| 客户端是否更新映射 | 应当更新槽位映射 | 不应更新槽位映射 |
| 重发前是否发送ASKING | 不需要 | 必须先发送ASKING |
| 后续请求影响 | 直接访问新节点 | 继续按原映射发送,等迁移完成后再更新 |
从协议设计角度看,MOVED和ASK都复用了Redis的错误返回通道,但错误码不同,客户端可以据此区分。需要注意的是,某些旧版本客户端可能只处理MOVED而不处理ASK,导致迁移期间命令报错。现代客户端库一般都会同时捕获这两种错误,但开发者在自行封装客户端时,仍然要明确两者的差异。
在实际迁移场景中,一个槽可能同时存在MOVED和ASK错误。例如,迁移未结束前,槽内有的键在源节点,有的在目标节点。客户端如果访问源节点上的键会正常返回,访问已迁走的键会得到ASK。迁移完成后,集群会更新槽位负责人,此时如果再访问旧节点上的槽,就会得到MOVED。因此,看到ASK通常意味着集群正在变更,看到MOVED则意味着变更已经收敛。
五、客户端实现建议与常见误区
如果使用成熟的Redis集群客户端,如Jedis、Lettuce、redis-py-cluster、go-redis等,MOVED和ASK的处理通常已经被封装。但这些库内部同样遵循上面的处理逻辑:捕获MOVED后刷新槽位映射,捕获ASK后发送ASKING并只做单次转向。自己实现Redis Cluster客户端时,至少需要完成槽位计算、槽位映射缓存、MOVED处理、ASK处理四部分。
最常见的误区是把ASK和MOVED混为一谈。有些开发者为了省事,在捕获ASK时也调用更新槽位映射的函数。表面上看,目标节点地址确实是命令执行的正确节点,但在迁移过程中槽位归属仍然是动态的,更新映射会让一部分还没迁移的键找不到。另一个误区是只处理重定向一次,不考虑连续重定向。例如在槽位迁移期间,客户端可能先收到源节点的ASK,发送ASKING后到目标节点执行命令,此时如果迁移恰好完成,或者目标节点又把槽移交给了第三个节点,目标节点可能返回新的MOVED。客户端需要有循环重试上限,避免死循环。
为了减少迁移期间的重定向次数,运维上可以尽量在低峰期进行槽位迁移,并使用redis-cli的--cluster reshard命令批量移动槽。客户端也可以通过监听集群拓扑变化来提前刷新槽位映射,但刷新的时机不宜过于频繁,否则会给集群带来额外负载。对于性能敏感的业务,可以把槽位映射缓存到本地,同时设置合理的过期时间,在捕获MOVED时强制刷新。
总结来说,MOVED代表已经确定的槽位归属变化,客户端应该更新路由表;ASK代表迁移过程中的临时键位置,客户端只需要携带ASKING进行一次临时访问。理解了这一层差异,再遇到Redis集群重定向错误时,就能快速判断集群状态并避免错误的路由表更新。