1. 集群模式下“批量”操作为什么总是出问题
1.1 16384个哈希槽:一切批量操作的底层约束
先把最容易被忽略的前提摆出来:Redis Cluster不是一个“把多台Redis拼在一起”的简单加和,它把整个键空间切成了16384个哈希槽(Hash Slot),每个key通过CRC16算法计算后落到某个槽位上,再由槽位和节点的映射关系决定数据存在哪台物理节点上。
这就带来一个很本质的变化:单机版里,一个命令天然访问本机全部数据;集群版里,你执行一条命令,客户端要先帮你把key定位到对应节点,如果命令涉及多个key且它们不在同一个节点,问题就来了。
我刚接手那个集群项目时,第一件做的事就是确认节点分布、槽位分配和客户端SDK的集群模式配置。很多批量操作异常,根子上都能追溯到这一步——客户端没有以集群模式连接,或者连接的是单节点,导致MOVED重定向乱飞、数据看不到、批量命令报CROSSSLOT错误。
提示:无论用哪个客户端,先确认
cluster info返回的cluster_state:ok,再确认客户端配置的是集群模式而非直连单节点。这一步做错,后面全是白忙。
1.2 为什么MGET、DEL这类命令在集群里“变难用”了
单机上,MGET key1 key2 key3一条命令搞定,DEL k1 k2 k3也很爽快。但到了集群里,MGET要求所有key必须哈希到同一个槽位,否则直接返回错误:
(error) CROSSSLOT Keys in request don't hash to the same slot同样,多key的DEL、RENAME、MGET、MSET这类操作全部受限。原因不复杂:Redis Cluster的分布式协议要求命令涉及的所有key必须在一个slot内,才能在单个节点上原子执行。分散在不同节点的key,服务端没法在一个命令里完成查询或写入。
很多人第一次踩这个坑,是在做“模糊匹配删除”的时候——写了个循环,把匹配到的key收集起来,凑一批DEL发过去,结果满屏CROSSSLOT。还有人写MGET,把用户ID列表传进去,直接报错。
通用解法只有两条路:
- 需要多key操作的业务,在key设计时就加上
{hashtag},让相关key落到同一槽位。比如order:{20250101}:user001和order:{20250101}:detail001,花括号里的内容参与CRC16计算,确保了同槽。 - 无法加hashtag的场景,就得在客户端手动拆KeyGroup,按slot分组后逐组执行,或者干脆用Pipeline发单key命令。
1.3 从单机迁集群后,最容易炸的三种清理姿势
我见过不少团队从单机迁集群,迁移完成后第一个出问题的往往不是读写业务,而是那些平时跑得好好的“定时清理脚本”。总结下来,最容易炸的是这三种姿势:
第一,直接用KEYS匹配。单机上数据量小,KEYS一把梭还能忍;集群里一次KEYS *,假设命中几百万个key,返回的数据量直接把那个节点和客户端内存一起干爆。KEYS命令在生产环境是被明令禁止的,定时清理脚本里就更不该出现。
第二,循环调用DEL单key删除。一次删一个key,集群里还涉及节点间的连接切换,几百万个key删下来,光是网络往返和命令解析就能把节点CPU打到饱和。删除不是不能逐步做,而是不能毫无章法地逐条发。
第三,用redis-cli -c配合管道“一把删除”。网上很多教程教redis-cli -c --scan --pattern 'prefix:*' | xargs redis-cli -c DEL,这段命令看起来对,实际跑起来问题不小——-c模式遇到跨槽会重连,xargs一次传入的key数量不可控,而且每个key都走一次网络往返,删除量一大,节点连接数直接飙升。
这三类问题统称“集群批量操作的低效与异常”,它们的核心根源都指向同一个事实:批量操作不能简单地把单机命令套过来,必须理解数据分布和网络模型,重新设计执行方式。
2. 大批量查询的提速核心:Pipeline与并行分片
2.1 Pipeline并非万能:跨Slot请求的误区
讲到“大批量查询优化”,Pipeline几乎是第一反应:把大量GET命令一次性发给Redis,减少往返延迟。单机上这个方法无比顺手,集群里就要小心了。
首先,Pipeline在集群客户端里,如果一批命令涉及多个key且它们不在同一个节点,很多SDK的做法是把这批命令拆开,按节点分组后再并发发送。也就是说,Pipeline在集群里并不会因为跨节点而报错,但也不代表它就自动高效。如果客户端没有做好分片路由,几百条命令被串行地发往不同节点,整体延迟可能比单机更差。
我遇到过的一个案例是:一个“批量查询用户状态”的接口,初期直接在集群上构造了一个大Pipeline,里面塞了两千多个GET命令,key分布在一百多个节点上。结果呢?客户端SDK虽然自动把命令按节点分组了,但每组都是串行发送,最慢的那组直接拖到几百毫秒;再加上连接池被大量占用,其他正常请求反而超时。
这里想强调一个容易被忽略的点:Pipeline只是减少了“网络往返”的等待,但批量命令在服务端的处理仍然是单线程串行的。如果你发了一万个GET,节点CPU处理每个命令的耗时加起来,才是真正的瓶颈。所以,大批量查询的关键不只是“减少往返”,还要“控制总命令量”和“并发度”。
2.2 按哈希槽分组后再合并结果,正确的查询姿势
既然集群里无法保证一次MGET跨节点,那就自己按slot分组。思路很简单:
- 拿到所有待查询的key列表;
- 用CRC16算出每个key的slot;
- 按slot分组;
- 每个slot内的key走一次
MGET; - 并发地执行多个slot分组的请求。
比如用Python的redis-py-cluster(或新版redis-py内置的RedisCluster),可以这样实现:
from collections import defaultdict from redis.cluster import RedisCluster cluster = RedisCluster(host="cluster-host", port=6379, startup_nodes=startup_nodes) keys = ["user:1001", "user:1002", "user:2001", "user:2001", "order:888"] slot_groups = defaultdict(list) for key in keys: slot = cluster.get_slot(key) # 或者使用命令 consul.helpers.get_slot slot_groups[slot].append(key) results = {} # 每个slot单独一批mget for slot, group in slot_groups.items(): vals = cluster.mget_nonatomic(group) # 这个方法内部按slot分组处理 for k, v in zip(group, vals): results[k] = vmget_nonatomic是redis-py-cluster提供的一个实用方法,内部实现了按slot分组的逻辑。如果没有这个API,需要自己按照上面的分组逻辑,每个分组执行cluster.mget(group),并配合ThreadPool或asyncio做并发。
这里有一个工程经验:分组的粒度不宜过细。如果key分布均匀,一个槽位一批,几千个key可能产生几十个分组,各自并发执行。并发度过高时,连接池会紧张、节点CPU飙升,反而变慢。我通常在分组后,用一个固定大小的线程池(比如8~16线程)去执行,而不是为每个分组开一个线程。
2.3 SCAN游标查询:全量扫描的集群注意事项
查询场景里还有一类高频需求是“按前缀扫出所有key”,比如清理临时缓存、统计某个业务线的key数量。集群下不能再用KEYS,正确的替代方案是SCAN配合MATCH模式。
SCAN命令本身是“游标式遍历”,每次返回一批key和一个新游标,直到游标回到0。它在集群环境里有个额外的坑:如果你连接的是一个负载均衡器或单节点,SCAN只能扫到那个节点上的key;集群里必须遍历每个节点。也就是说,对着每个主节点各执行一次SCAN,然后把结果汇总。
一个可参考的实现逻辑是:
def scan_all_keys(cluster, pattern="*", count=1000): keys = [] for node in cluster.get_nodes().values(): if not node.is_primary: # 只扫描主节点 continue cursor = 0 while True: cursor, found = cluster.scan(cursor=cursor, match=pattern, count=count, node_identifier=node.name) for key in found: keys.append(key) if cursor == 0: break return keys需要注意两个点:第一,MATCH模式是在服务端对返回的key做的模式匹配,不是“先全量扫再过滤”的偷懒实现,但它仍然需要遍历哈希表,所以大key数量下的耗时不可忽略;第二,SCAN的COUNT参数只是返回数量的“提示”,不是精确值,扫描期间数据发生变化时可能出现重复或漏掉,这点对“批量删除”场景尤为重要——重复扫描到同一个key没关系,批量删除时幂等就行。
实际跑大批量扫描任务时,我建议不要一次性把所有key收进内存,而是边扫边处理:扫一批、删一批、记一个游标位置。真要中断了,也好从上一次的游标继续。好过把几十万key全塞进内存再统一处理,那样内存和CPU都容易顶不住。
3. 大批量删除的工程落地:UNLINK优先与分批节奏
3.1 DEL和UNLINK的性能分水岭
删除操作看似简单,实际在集群大批量场景下有非常明确的性能分水岭。DEL命令在删除一个对象时,是同步地把这块内存释放掉。如果这个key是一个大集合、大列表,内存释放过程可能阻塞整个Redis主线程,导致所有请求瞬间卡顿。这在单机上都让人头大,在集群里会直接拖垮整个分片节点。
UNLINK命令(Redis 4.0引入)的思路完全不同:它先把这个key从键空间中“摘除”,然后在内部分出一个异步线程去逐步释放内存。对调用方来说,UNLINK的返回很快,主线程不会因为释放大对象而长时间阻塞。
所以常规结论是:4.0以上的集群,批量删除一律用UNLINK,不要用DEL。
下面这组命令可以直观对比两者的差异:
# 同步删除(可能阻塞) redis-cli -c -h cluster-host -p 6379 DEL 'log:20250101:bucket1' # 异步删除(不阻塞) redis-cli -c -h cluster-host -p 6379 UNLINK 'log:20250101:bucket1'不过有一点要提醒:UNLINK在删除“极小对象”(比如字符串、短列表)时,内部实现跟DEL没什么区别,属于正常现象。所以它唯一的性能隐患出现在大key上,小key删除感觉不到差异。
如果集群版本还在4.0以下,没有UNLINK可用,只能DEL。那就要靠“分批 + 限速”来降低风险:比如一次只删一个key,删一个暂停几毫秒,或者在删除前对key的类型和长度做判断,太大的key先做渐进式裁剪(比如用LRANGE TRIM逐步删列表内容,用SSCAN + SREM逐步删集合成员),再删除本体。
3.2 按前缀删除:三种实现方案与选型对比
“按前缀批量删除”是清理任务的典型需求,实现方案主要分三类。我以一个具体场景来说:有大量tmp:session:*的临时会话key需要清掉,总量大概80万个。
方案一:redis-cli + SCAN + xargs 串行删除
redis-cli -c -h cluster-host -p 6379 --scan --pattern 'tmp:session:*' \ | xargs -L 100 -P 8 redis-cli -c -h cluster-host -p 6379 UNLINK优点是小范围清理很方便,不用写代码。缺点是每个xargs批次都会创建一批新的客户端连接,连接反复建立/销毁对节点资源不友好;且-P 8的并发虽然能提速,但并发没限制时也容易把连接瞬间打满。
方案二:应用内SDK扫描 + 批量UNLINK
import redis from redis.cluster import RedisCluster cluster = RedisCluster(startup_nodes=[...], skip_full_coverage_check=True) def batch_unlink_by_pattern(pattern, batch_size=500, limit=1000000): deleted = 0 node_ids = [node.name for node in cluster.get_nodes().values() if node.is_primary] for node_id in node_ids: cursor = 0 while True: cursor, keys = cluster.scan(cursor=cursor, match=pattern, count=batch_size, node_identifier=node_id) if keys: # UNLINK是单key命令,天然支持任意分布 pipeline = cluster.pipeline(transaction=False) for key in keys: pipeline.unlink(key) pipeline.execute() deleted += len(keys) if cursor == 0: break return deleted这是我最推荐的方式:连接复用、命令合并、可控并发。因为UNLINK是单key命令,不存在跨slot问题,可以在一个Pipeline里塞几百个UNLINK直接执行。
注意:Pipeline里的命令如果落在不同节点,客户端SDK会自行拆分路由并发送到对应节点,但事务性之类的高级特性就不能依赖了。好在我们这里是单向删除任务,不需要事务保证。
方案三:BigPipe或Lua脚本
有些人会想用Lua脚本一步完成匹配和删除。但要注意:集群模式下Lua脚本要求脚本涉及的所有key都在同一个slot,否则会报CROSSSLOT。如果某个特定槽位内的key需要清理,可以用Lua的SCAN+UNLINK简化逻辑;但全集群按前缀删除,Lua脚本就不合适了。
三种方案选型上,我总结成一张表,方便各位直接对照:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| redis-cli + xargs | 无需编码,上手快 | 连接反复建立、并发难控 | 临时单机或小规模清理 |
| SDK扫描+批量UNLINK | 可控性强、效率高、连接复用 | 需要写代码 | 集群定期任务、大批量清理 |
| Lua脚本 | 原子执行 | 受跨slot限制 | 固定slot内的特殊清理 |
3.3 删除节奏控制:限流、观察与回退
大批量删除有一个悖论:删得越急,风险越大;删得太慢,又影响业务。我经历过的处理方式是“削峰填谷”——把删除任务压成均匀的流,而不是一波脉冲。
关键参数有这几个:
- 每批大小:单批UNLINK命令数量控制在200~500之间。太小了吞吐不足,太大了单条Pipeline里的响应耗时变长,容易出现客户端超时。
- 批间隔:每执行一个批次,sleep 20~50毫秒。这个不是玄学,是为了给主线程留出喘息时间,避免SCAN本身把CPU占满。
- 并发上限:如果是多线程执行,并发线程数不要超过节点数的两倍。比如6个主节点的集群,用8~12个线程足够。
- 失败回退:任务执行中如果出现
CLUSTERDOWN或大面积的超时,要自动缩小批大小并延长批间隔。实现上可以做一个“正常模式/降级模式”的切换开关。
还有一个很容易忽略的点:执行删除前,先对匹配数量做一次抽样估算。用SCAN count=1000扫出来的返回结果,估算总的匹配比例,判断删除规模是在“万级”还是“百万级”,再决定要不要走审批流程、通知下游依赖方。直接闷头删几十万key,如果这些key是某个业务的正常缓存,那等于一次小规模故障。
4. 批量操作中的典型异常与排查链路
4.1 CROSSSLOT错误的复现与规避
现象:
127.0.0.1:7000> mget user:1 user:2 (error) CROSSSLOT Keys in request don't hash to the same slot这是我见过的集群批量操作中最常见、也最容易让人懵的错误。它背后的机制上文已经讲过——跨key命令要求所有key落在同一个slot。但实际项目中,很多人遇到的不是直接MGET报错,而是某些客户端SDK内部使用了跨slot命令却不自知,返回的错误被层层包装,误导排查方向。
复现路径:我有一个真实排查经历。某天监控提示某个查询接口错误率上升,日志里全是command timed out和CROSSSLOT。翻代码时发现,上游一次性传入了几千个ID,下游用mget把所有ID对应的key一把查了。这个写法在单机Redis上跑了两年没事,换到集群上就直接全线飘红。
解决思路:按1.2节提到的方式分成两条路:
- 短期:把
mget拆成pipeline下的逐个get,单key命令不受跨slot限制,立即恢复业务; - 长期:评估这些key是否可以通过用户维度加
{userId}哈希标签,把相关key聚到同一槽位,实现真正的mget聚合。
顺带提醒,DEL多key、RENAME、BLPOP等命令也受同样约束,排查时别只盯着MGET。
4.2 连接耗尽与超时:排查从哪一步开始
现象:大批量清理脚本运行几分钟后,客户端开始大面积报连接超时,节点上CLIENT LIST显示的连接数到了几万,INFO里connected_clients相当高。
完整的排查链路,从现象倒推原因:
第一步,看CLIENT LIST,确认连接是不是被脚本占满。如果连接数持续上升且没有回落,基本可以判断是连接创建速度大于释放速度。
第二步,看INFO cpu和INFO commandstats。如果cmdstat_unlink或cmdstat_del耗时占比很大,说明不是连接本身的问题,而是节点CPU已经被删除命令打满,积压的连接开始排队超时。
第三步,检查脚本写法。如果是用redis-cli的xargs -L 200 redis-cli ...这种形式,每200个key就重新建立十几个连接,几十万key跑下来创建连接数极为可观。更隐蔽的问题是:redis-cli默认不会复用TCP连接,每执行一条命令就断开重连,这种开销在大批量运行时是致命的。
修复方案一般有两步:
- 把脚本改成应用内SDK执行,复用长连接;
- 用有界线程池限制并发数,避免人为放大并发。
我在一次清理任务里,把原来xargs + 50并发改成SDK + 8线程的Pipeline方案后,节点连接数从8000多降到300左右,清理80万key的时间反而从40分钟缩短到9分钟。并发不是越高越快的例子,实在太典型了。
4.3 大Key删除引发阻塞:一次故障的全过程复盘
有一次凌晨定期任务清理历史日志key,跑到一半,某个分片节点的延迟从2ms飙到5000ms。当时第一反应是检查慢查询,结果SLOWLOG GET里出现了多条“DEL一个超大的集合类型key”的记录,单条耗时都在3秒以上。
根因链条是这样的:清理脚本统计了key的数量,但没统计key的类型和长度。一批日志key里,绝大多数是几百条的小列表,但有几条是长年累月没清过的超级集合,每个包含上百万个元素。DEL删除这种“大象”,底层要逐个释放元素占用的内存,过程极快也会阻塞线程,于是整片分片被冻住几秒,客户端超时、主从心跳检测差点触发切换。
这类问题的标准处理姿势:
- 删除前先抽查key类型和长度(
TYPE+LLEN、SCARD、ZCARD、HLEN等命令); - 对超过阈值的“大key”单独走渐进式清理流程,比如某个集合用
SSCAN分批 +SREM裁剪; - Redis 4.0以上的环境直接用
UNLINK,让大key的内存释放交给后台线程。
这个案例让我养成一个习惯:任何批量清理任务上线前,一定先加一段“大key检测”逻辑,把超过阈值的key捞出来,单独处理。执行完再比对一下清理前后的内存变化,看有没有key因为类型特殊没被清干净。
4.4 主从切换与集群Fail期间,批量任务如何自保
最后一个容易被忽视的异常场景,是在主从切换或节点故障期间跑批量任务。集群在某个节点进入fail状态、槽位重新分配时,批量操作会报出五花八门的错误:连接失败、CLUSTERDOWN、MOVED重定向异常等。
我记得有一次做全集群清理时,正赶上一次机房级别的网络抖动,两个节点同时被判定故障。脚本层面没有做任何保护,发现一堆偶发错误后直接中断,结果就是“清了一半的key”这种尴尬状态——业务方那边已删除的数据还在缓存层存在残留,但源头已经没了,下次查询就会重建。
给批量任务加的三道保险:
- 任务幂等:删除操作天然幂等,重复执行不产生副作用,但前提是每次执行都用相同的匹配模式。所以脚本参数必须固定,不能因为中断后改了pattern就重跑。
- 状态记录:每处理完一批key,把当前游标位置或已删key的集合名持久化下来。中断后从断点恢复,而不是从头再来。批处理任务用Redis自己存状态也很方便。
- 异常降级:捕获到
CLUSTERDOWN或连接异常时,不要立刻重试,先sleep 5秒,查看cluster info里的cluster_state;等状态恢复ok后再继续。一遇到错误就疯狂重试,只会加重故障。
关于具体实现,给一段伪代码结构:
retry_count = 0 while batch_to_process: try: # 正常批量处理 pipeline.execute() retry_count = 0 except RedisClusterException as e: if "CLUSTERDOWN" in str(e): time.sleep(5) retry_count += 1 if retry_count > 10: raise RuntimeError("cluster not recovered within 50s") else: raise顺着这个话题多说一句,我最常被问的一个问题是:“扫描加删除,会不会把线上所有key都误删了?”这里有个工程习惯可以做在代码里——第一次执行时先只打印不删除,或者用测试pattern(比如tmp:test:*)跑通全流程,确认匹配范围精确后,再放开真正的删除。大批量任务最怕的不是慢,而是“删错”。
5. 一些我个人的清理任务习惯
按惯例,最后分享几个踩过坑之后沉淀下来、每次都会照做的小习惯,算不上完整方法论,但对批量查询删除这类场景很有帮助:
- 任务执行窗口选在业务低峰,全程盯
INFO commandstats和SLOWLOG,不要跑完再来看结果。 - 删除前对匹配前缀的key做抽样,看看类型分布和平均大小。类型里只要有
list、set、zset且长度可能长的,就必须上UNLINK或渐进删除。 - 如果条件允许,给清理任务的入口加一个“干跑模式”,默认只统计不删除,确认无误后手动切换执行模式。
- 用
qps和deleted_count写一个简单的日志输出,任务跑完能明确回答“删了多少、花了多久、有没有熔断重试”这三个问题。 - 最后清理临时数据时,不要忘记同时检查下游依赖——缓存清了,依赖方手里可能还有一份旧数据的引用,提前打个招呼能少陪一次故障演练。
大批量查询删除本身不算复杂,难的是在“快”和“稳”之间找到平衡。理解slot约束、选对命令、控制节奏、做好异常保护,这四件事做扎实了,集群上的批量任务基本不会出大状况。