Redis 里删一个 key 通常就是DEL一下的事,难的是"按某个规则删掉几百上千个甚至百万个 key"。很多人第一反应就是KEYS去匹配,然后DEL,结果就是 Redis 卡顿、内存飙升、主从延迟,甚至误删数据。这篇文章我把这些年处理 redis 批量删除的完整思路、原理拆解、能直接复制的脚本,以及踩过的坑全部整理出来,给同样被这个问题困扰的读者一份可以照着做的方案。
1. 先别急着敲命令:批量删除前必须想清楚的几件事
很多人拿到"批量删除"的需求就直接搜命令,这是最危险的做法。Redis 没有原生的"按 pattern 批量删除"命令,凡是批量删除都是组合方案。组合方案各有适用边界,选错了轻则效率极低,重则阻塞线上服务。所以动手之前,先搞清楚你到底是哪种需求。
1.1 四种看起来一样但解法完全不同的需求
- 按前缀模糊删除:比如清掉所有
cache:user:*开头的缓存 key。这是最常见的情况,后面讲的所有方案都围绕它展开。 - 按数据类型删除:比如清掉库里所有
hash类型但前缀不同的 key。这种不能只匹配 key 名,还要判断类型,得用SCAN拿到 key 之后再用TYPE过滤。 - 按空转时间 / 过期状态删除:比如清理很久没有被访问的 key。要用
OBJECT IDLETIME判断,方案跟普通前缀删除完全不同。 - 按 value 内容删除:比如删除所有 value 里包含特定字符串的 key。这种最麻烦,必须扫描后反序列化再判断,代价极高,一般不推荐在线上直接跑。
这四种需求看着都叫"批量删除",实际编码路径差异巨大。绝大多数人遇到的是第一种,我这里也聚焦在第一种场景。
1.2 动手前先回答这四个问题
我把每次做批量删除前固定要确认的问题列成了清单,少了任何一项都有可能出事:
- 匹配 pattern 是否精确:比如
order:*会不会误伤order_api:*?多一个字符、少一个字符,删除范围可能差出一个数量级。 - 预计影响的 key 数量级:几百个和几百万个的删法天差地别。几百万级就必须分批、限速、考虑对主从复制的影响。
- 部署形态是单机还是集群:集群下 key 分散在多个节点的 slot 上,直接复用单机命令极大概率报
CROSSSLOT错误。 - 删除是否可逆:业务 key 有没有备份?能不能接受误删?不能接受就得先导出清单、甚至先把 key 改名保留一段时间再删。
多数线上事故都不是"命令不会写",而是"需求没问清就动手"。我见过同事为了清一批测试 key,用了KEYS tmp*去匹配,结果把正式环境里所有tmp开头的业务 key 全部干掉,还好当时有备份才没酿成大祸。这就是典型的没想清楚就动手。
2. 为什么"KEYS 加 DEL"是野生方案,SCAN 才是能上生产的路子
很多初学者喜欢写KEYS "cache:*"拿到所有 key 再循环 DEL。这个做法在小数据量、自己本地环境没问题,一旦上了生产,相当于给自己埋雷。要理解为什么,得先看 Redis 的运行模型。
2.1 KEYS 阻塞:单线程模型下最昂贵的操作之一
Redis 的命令执行是单线程的,所有命令在同一个事件循环里排队执行。KEYS命令要遍历整张哈希表来收集所有匹配的 key,这个操作的时间复杂度是 O(N)。当实例里有几百万甚至几千万个 key 时,执行一次KEYS可能需要秒级的时间。在这几秒里,所有读写请求都被堵在后面排队,连接会堆积,超时故障会像雪崩一样传开。
打个不严谨但很好懂的比方:餐厅只有一个服务员,他正在给所有客人点菜,此时如果有人让他去仓库把所有库存清点一遍,那这期间所有客人只能等着。KEYS就是那个"清点全仓库库存"的命令。
另外一个隐患是内存。如果匹配到的 key 有几十万个,KEYS会一次性把这些 key 全部拼成一个庞大的响应返回,内存立刻被撑高,网络传输也扛不住。而SCAN每次只返回一小批 key,不会有这种问题。
2.2 DEL 也不是完全没有代价
很多人觉得删 key 是瞬间的事,但 DEL 对"大 key"来说并不快。DEL 是同步命令,主线程在删除一个包含几千万元素的 hash 或 list 时,需要同步释放所有内存。这个过程中主线程是被占住的,一样会造成卡顿。Redis 4.0 之后提供了UNLINK,它先把 key 从命名空间里摘除,让业务立即可见"这个 key 没了",真正的内存回收放到后台线程慢慢做。批量删除的场景里,能用 UNLINK 就尽量用 UNLINK。
2.3 SCAN 的原理:每次只翻一页,而不是一次翻完整本书
SCAN也是 O(N) 的复杂度,但它高明在"分批遍历"。每次调用只处理一批哈希桶,返回一个游标,客户端拿着游标继续迭代,直到游标回到0才算遍历完成。这中间的每一次操作都很轻量,Redis 主线程可以在每次 scan 之间继续处理其他正常请求,不会出现长时间阻塞。
需要重点说明的是COUNT参数。它不是"每次返回多少个 key",而是"每次迭代大概扫描多少个桶的工作量提示"。具体返回多少个 key 取决于这些桶里实际有多少个 key,以及 pattern 的匹配率。如果匹配率很低,可能出现扫了一大批桶才返回几个 key 的情况,这是正常的,不要误以为程序卡死了。
还有一个容易忽略的点:SCAN的MATCH是"先取后筛",也就是先把桶里的 key 取出来,再做模式匹配。所以 pattern 越严苛、匹配率越低,完成一次全量遍历所需的迭代次数就越多。
下表把KEYS和SCAN的差异列出来,方便对比:
| 对比项目 | KEYS + DEL | SCAN + DEL / UNLINK |
|---|---|---|
| 对主线程的阻塞 | 全量扫描,几十毫秒到秒级阻塞 | 每次只扫描少量桶,影响极小 |
| 内存开销 | 一次性返回所有匹配 key,可能撑爆内存 | 每次返回少量 key,内存占用可控 |
| 遍历期间新写入的 key | 不存在此问题(一次快照式) | 不保证扫描到遍历期间新增的 key |
| 生产环境可用性 | 不推荐,风险极高 | 推荐,配合游标和限速可用 |
生产环境我从来不敢在实例上跑KEYS,哪怕数据量只有几万。因为线上随时可能增长,谁也没法保证某次匹配会扩大到什么量级。SCAN系方案慢是慢一点,但它不会"堵车",这是核心差异。
3. 三种能真正落地执行的批量删除代码
方案选型我按"操作者习惯"分了三类:命令行一行流适合临时清理;Lua 脚本适合在服务端快速执行小批量删除;Python 等客户端脚本适合大批量、需要记录进度和限速的场景。下面逐个讲用法和适用场景。
3.1 命令行一行流:redis-cli 加 xargs 的经典组合
最经典的写法是这样的:
redis-cli --scan --pattern "cache:user:*" | xargs -r -n 100 redis-cli DEL拆开看这句命令做了三件事:redis-cli --scan --pattern负责遍历出所有匹配的 key,每行一个输出;管道交给xargs;xargs -n 100把 key 每 100 个凑成一堆,执行一次redis-cli DEL。因为 DEL 本身支持一次删除多个 key,这样能大幅减少命令轮次。
这个用法有两个必须注意的细节。第一个是xargs必须有-r参数,否则匹配结果为空时它会执行一次不带参数的redis-cli DEL,直接进入交互模式挂在那里。第二个是如果 Redis 设置了密码,管道后面的redis-cli也要带上-a:
redis-cli -a yourpass --scan --pattern "cache:user:*" | xargs -r -n 100 redis-cli -a yourpass DEL但我不太推荐在生产环境直接复制这条命令,因为它把"扫描"和"删除"变成了两个独立的进程流,中间没有重试机制,万一删除过程中网络闪断,要么重复删(DEL 幂等问题不大),要么中间漏掉几个 key,事后检查很麻烦。它最适合的场景是:临时清理开发环境、本地测试数据,或者 key 总量很小(几百上千个)的时候。
3.2 Lua 脚本方案:一次 EVAL 在服务端完成批量删除
Lua 脚本的好处是减少网络往返,让 Redis 在服务端完成"扫描 + 删除"整个流程。典型脚本长这样:
-- 参数:KEYS[1]=匹配模式,ARGV[1]=单次最大删除数量 local cursor = "0" local pattern = KEYS[1] local limit = tonumber(ARGV[1] or "1000") local deleted = 0 repeat local result = redis.call("SCAN", cursor, "MATCH", pattern, "COUNT", 200) cursor = result[1] for _, key in ipairs(result[2]) do if deleted >= limit then break end redis.call("UNLINK", key) deleted = deleted + 1 end until cursor == "0" or deleted >= limit return deleted执行方式:
redis-cli --eval batch_del.lua "cache:user:*" , 1000这里有一个非常重要的认知:Lua 脚本在 Redis 中是原子执行的,脚本运行期间其他命令都得等着。所以这个脚本里的循环如果一次性迭代完整个 keyspace 并删除所有匹配 key,它造成的阻塞跟KEYS没有本质区别,只是把网络的消耗省掉了。
因此上面的脚本里我加了limit参数,把单次执行限制在最多删 1000 个 key。外部程序反复调用这个脚本,每次删一小批,才能真正避免阻塞。换句话说,Lua 方案适合"单次删除规模可控"的场景,比如定期清理一批最多几百个 key 的过期任务,而不是一次性清几百万 key。
3.3 Python 客户端脚本:适合大批量、要限速、要记录进度的场景
真正面对百万级 key 的清理,我会用 Python 写脚本,用scan_iter配合 pipeline 和限速。流程清晰、可观测、出现问题时能快速定位到删除进度。
import time import redis # 替换为实际连接信息 pool = redis.ConnectionPool( host="127.0.0.1", port=6379, db=0, decode_responses=True, ) r = redis.Redis(connection_pool=pool) # pipeline 关闭事务,避免大批量 execute 时事务内存积压 pipe = r.pipeline(transaction=False) pattern = "cache:user:*" batch_size = 200 # 每个 pipeline 批次删除的数量 delete_total = 0 # 累计删除统计 batch_count = 0 # 当前批次计数器 for key in r.scan_iter(match=pattern, count=500): # 用 UNLINK 而不是 DEL,避免大 key 同步释放内存导致卡顿 pipe.unlink(key) batch_count += 1 delete_total += 1 if batch_count >= batch_size: pipe.execute() print(f"deleted {delete_total} keys...", flush=True) # 控制删除速率:每删 200 个 sleep 一下 # 线上建议每秒删除量控制在 2000-5000 以内,根据实例规格调整 time.sleep(0.1) batch_count = 0 if batch_count > 0: pipe.execute() print(f"done, total deleted: {delete_total}")几个设计点解释一下:
scan_iter封装了游标循环,但它不影响对 SCAN 原理的理解。它每次迭代返回一个 key,内部按 count 参数控制扫描力度。- pipeline 必须开
transaction=False。默认的 pipeline 会 MULTI 包裹事务,如果在一个事务里塞几百个删除命令,事务提交时一次性执行,反而造成瞬时压力。关掉事务后,pipeline 只是把多个命令批量发送,是一条一条按顺序执行的。 - UNLINK 而不是 DEL,理由前文说过,大 key 的异步释放能显著降低主线程抖动。
- 限速的关键是 sleep。删除操作会同步到从库和 AOF,删得太快会让从库复制积压,后面单独讲。
这个脚本我再强调一次:它没有做断点续跑。如果执行到一半中断,下次从头开始 scan_iter 时已经删掉的 key 不会再出现,所以不会误删第二批,但可能少删(如果 key 在第一次扫描后被新写入)。大批量删除后,建议重新扫描确认剩余情况,而不是只看脚本输出。
4. 集群、空格、前缀陷阱:这三个坑我替你们踩过了
这三种写法都算不上银弹,真实环境一变化就到处是坑。下面这三个问题我在实际项目中都撞到过,每一个都值得单独拿出来讲。
4.1 集群模式下批量删除:CROSSSLOT 和节点路由问题
Redis Cluster 把 key 按照 hash slot 分散在多个主节点上。单机版redis-cli --scan | xargs redis-cli DEL在集群环境通常有三种表现:
- 不带
-c的 redis-cli 只连一个节点,删除其他 slot 的 key 时直接报MOVED错误提示,实际没删掉。 - 带
-c的 redis-cli 会自动跟随 MOVED 重定向,但每次重定向都多一次网络往返,删除几百万 key 时效率惨不忍睹。 redis-cli -c --scan倒是可以遍历整个集群的所有节点,但前提是后续的每个 DEL 也要能正确路由,如果是管道里的独立 redis-cli 进程,它们不知道集群拓扑,依然会撞上 MOVED。
我处理集群批量删除时用的方式是按节点手动扫描。每个 master 节点只保存一部分 slot,在单节点上扫描出的 key 必然属于该节点,直接在本节点删除既不需要跨节点路由,也不会触发 CROSSSLOT:
for host_port in 10.0.0.1:7000 10.0.0.2:7000 10.0.0.3:7000; do host=${host_port%%:*} port=${host_port##*:} redis-cli -h "$host" -p "$port" --scan --pattern "cache:user:*" \ | xargs -r -n 200 redis-cli -h "$host" -p "$port" DEL done如果你用的是 Python 客户端,RedisCluster 的scan_iter已经做掉了跨节点遍历,delete调用也会自动路由到对应节点,体验会舒服很多。但底层原理还是"各节点扫各节点、各节点删各节点",理解了这一点,遇到任何客户端报错都能快速定位到问题方向。
4.2 key 里带空格和引号:xargs 差点删错数据
这是我最想提醒你的一条。xargs默认按空白字符(空格、tab、换行)来分割输入。如果 Redis 的 key 里含有空格,比如业务方把用户名和城市拼进 key:
cache:user:张三:北京用默认的 xargs 处理时,这个 key 会被拆成两个参数:
cache:user:张三: 北京管道后面的redis-cli DEL cache:user:张三: 北京会尝试删除两个完全不存在的 key。更麻烦的是,如果库里恰好有一个 key 叫北京,它就被误删了。我在模拟项目 X 里就碰到过一次类似误删,排查半天发现删除脚本把带空格的 key 拆了词。
解决方案很直接:让 xargs 只按换行符分割:
redis-cli --scan --pattern "cache:user:*" | xargs -d '\n' -r -n 100 redis-cli DEL-d '\n'在 GNU xargs 下可用。如果你的环境是 macOS 自带的 BSD xargs,它不支持-d,可以用tr '\n' '\0'配合xargs -0:
redis-cli --scan --pattern "cache:user:*" | tr '\n' '\0' | xargs -0 -r -n 100 redis-cli DEL这条命令语义等价,而且更稳。我给线上清理脚本统一用了tr + xargs -0的组合,目的就是彻底避开 key 内部特殊字符的问题。
4.3 前缀匹配的隐形陷阱:* 贪婪匹配和大小写敏感
Redis 的MATCH用的是 glob 风格匹配,三个点必须记住:
第一,*是贪婪的,可以匹配任意长度的任意字符,包括冒号。user:1:*:info这个 pattern 会匹配user:1:name:info,也会匹配user:1:extra:deep:info,因为它不在乎*跨越了几层冒号。如果你只想匹配固定两层结构,需要更精确地设计 pattern,比如user:1:?:info(?只匹配一个字符),或者直接按业务拆 key 层级。
第二,匹配区分大小写。Cache:*和cache:*是完全不同的两套 key。清理前最好先用 SCAN 单独验证一次实际存在哪些大小写变体,不要凭记忆写 pattern。
第三,SCAN的MATCH是"先扫后筛"。由于 scan 过程中可能遇到哈希表 rehash,同一批桶可能被重复扫描,理论上返回的 key 也有小概率重复。好在这对我们无害,因为删除操作天然幂等,删两次也就删一次的效果。
还要补一个容易忽略的细节:如果删除的同时业务还在不断写入新 key,那么一次完整的 SCAN 迭代并不保证覆盖所有新增 key。所以"删完"之后不要只看一次扫描结果,最好连续跑两三次空扫描,确认没有新增匹配 key 再收工。
5. 删大 key 和千万级 key:如何不把主从拖垮
数量级到了百万以上,问题就从"怎么删"变成了"怎么删得动且不引起事故"。这里有两个容易被忽视的维度:单 key 的大小和总删除量对主从复制的冲击。
5.1 大 key 必须用 UNLINK,而不是 DEL
如果你的匹配结果里混着几个几十 MB 甚至几百 MB 的 hash 或 zset,DEL 会同步遍历并释放所有内存,Redis 主线程可能因此卡住几百毫秒甚至更久。UNLINK 做的事情是:先把 key 从 keyspace 里摘除,这一步非常快,真正的内存回收放到后台线程慢慢处理。从调用方视角看,UNLINK 之后这个 key 就再也查不到了,和 DEL 的效果一致,只是资源释放时机被延后。
所以前文所有脚本里,我都用了unlink而不是delete。Redis 4.0 以后都可以放心用,当前主流版本早就支持了。
5.2 删除速率控制:从库复制延迟和 AOF 写入压力
大批量删除会被复制到从库,也会写入 AOF。如果删得太快,删除命令在复制流里大量堆积,从库处理不过来,主从延迟会瞬间飙升。主从延迟一旦过大,从库提供读服务的场景下业务会读到明显滞后,极端情况下主从切换还有数据不一致风险。
我的一般做法是:把删除速率控制在每秒 2000 到 5000 个 key 左右,具体数值要看实例规格、从库数量和网络带宽。上面的 Python 脚本里time.sleep(0.1)加每批 200 个,折算下来大约每秒 2000 个,是比较稳妥的起步值。如果监控显示主从延迟在可控范围内,再逐步调大。
删的过程要盯着几个指标看:
INFO replication里主库的master_repl_offset与从库的slave_repl_offset差值;INFO memory里used_memory的变化趋势,确认内存确实在释放,而不是因为删除过快导致异步释放队列积压;- 实例的
latency监控,确认没有出现明显毛刺。
如果发现从库追不上,就把 sleep 加大、批次调小。这个节奏控制没有固定公式,本质是"给从库留出追赶数据的时间"。
5.3 删前留后手:清单导出和改名隔离
大批量删除是不可逆操作,我强烈建议在任何"删了会影响业务"的清理前,先把要删的 key 清单导出到文件:
redis-cli --scan --pattern "cache:user:*" > keys_to_delete.txt这个文件至少能告诉你"我删了什么",配合备份还能做到可恢复。更进一步的做法是对高危操作做"隔离式删除":不直接删,而是先用RENAME把 key 改名到某个隔离前缀下,比如cache:user:123改成to_delete:cache:user:123,观察一段时间确认业务无感后,再统一清理to_delete:*。RENAME 的时间复杂度是 O(1),改名本身几乎不耗时,但它能给你留一条完整的后悔路。
我之前在某个大促前清理一批用户维度的缓存 key,就是这么干的。先改名观察了 30 分钟,确认所有下游服务都没有依赖这些 key,之后才真正执行 UNLINK。期间如果有任何异常,把名字改回来就能恢复,不用重新造缓存。这个"先改名、后删除"的套路,对重要业务场景非常管用。
6. 我把这六步固化成了标准操作流程
批量删除这个事,真不是"会敲命令"就够了。我吃了不少亏之后把流程固化成了一个标准操作手册,每次清理都照着走,基本没有再出过问题。
- 确认需求:pattern 精确吗?范围多大?要不要备份?
- 导出清单:用 SCAN 把匹配的 key 全部落盘到文件,既用于数量评估,也作为删除依据。
- 试删验证:先只删 10 个,用 EXISTS 验证 key 消失,同时确认业务无报错。
- 小批次执行:以每批 200 个、间隔 0.1 秒左右的速率执行脚本,边删边盯主从延迟和内存变化。
- 复查残留:连续扫描两三轮,确认匹配集合趋于零,如果有源源不断的新 key,说明业务还在写这类 key,需要评估是否继续删。
- 善后:保留清单文件,便于回溯;如果脚本异常中断,对比清单和实际删除进度。
最后再分享一个小小的习惯:清理类的脚本我已经不敢用KEYS了,哪怕只是临时清理测试环境。因为一旦在正式集群上不小心执行了带KEYS的命令,谁也没法保证下一秒线上不抖动。把所有清理脚本全部改写成SCAN系实现之后,我再也没有因为"删缓存"这件事被叫起来处理过线上故障。这个改动很小,但给线上安全带来的收益非常大。