去年秋天我接到一单线上求助:某个 Redis 集群的内存其实还在可控范围,但是INFO keyspace里单节点 key 数量已经超过 1.2 亿。业务方一开始想扩容,我去看了一眼接入方式,发现问题的核心根本不在于内存,而在于 key 数量失控。后来我用一套 Shell 脚本完成了治理,把僵死 key 数量压到原来的三分之一以下,整个过程没有停服,也没有改一行业务代码。这套方案就是后来沉淀下来的 DeepSeek:一个用 Shell 脚本 + redis-cli 的组合拳,专门用来降低 Redis 中 key 数量的完整方案。如果你也在维护一个 key 数量疯狂增长的 Redis 实例,这篇文章应该能帮你省下不少脑细胞。
1. key数量失控的根源和成本,动手前先看清楚
1.1 三个最常见的失控来源
先说失控的原因。我见过的大量 key 增长,基本逃不出下面这三种情况。
不带过期时间的缓存。
这是最常见的。很多业务同学写缓存的时候只记得SET,不记得EXPIRE,甚至有的框架默认就不设置 TTL。这些 key 一旦写入就永远存在,时间一长就成了僵尸数据。比如你把用户的购物车信息写进 Redis,只做了SET cart:12345 ...,一个月后这批数据还在内存里,可用户早就不看了。
动态维度 key 无限膨胀。
第二种是 key 设计里带了动态维度,比如按订单号、按 requestId、按日期拼 key。订单号本身是无限增长的,那么order:{id}:detail这种 key 也会无限增长。哪怕每个 key 只有几百字节,一天一千万订单,一年就是几十亿个 key。
缓存穿透兜底造成的空值缓存。
还有一种是做缓存穿透保护时,把不存在的数据也缓存起来了。比如数据库里查不到某个用户,就写一个empty空值进 Redis,TTL 设置很短,看起来没什么问题。但如果攻击流量很大、或者业务场景比较极端,这些空值 key 也会在短时间内堆积成一个天文数字。
1.2 成本维度不只是内存,这才是最要命的
很多人以为 Redis 里 key 多了只是费内存,其实远不止。
- 持久化文件膨胀:RDB 和 AOF 里要记录大量 key,备份文件变大,恢复时间变长。
- fork 阻塞风险:Redis 做 RDB 持久化时,fork 子进程要复制父进程内存页表,key 数量越多,内存越大,fork 阶段阻塞主线程的风险越高。
- 主从全量同步变慢:从库重新全量同步时,要把 RDB 传到从库,key 越多,传输时间越长,同步窗口越大。
- 过期键扫描开销:Redis 的过期 key 清理依赖于定时任务和惰性删除,key 总量过大时,每次扫描的耗时也会上升。
- 内存碎片:大量小 key 频繁写入删除,会让内存碎片率升高,最终实际占用比业务数据大得多。
我把这些影响整理成了一张表,方便你跟业务方解释为什么要治理 key 数量:
| 成本维度 | 具体影响 |
|---|---|
| 持久化与备份 | RDB/AOF 文件膨胀,备份和恢复时间成倍增加 |
| fork 阻塞 | 内存占用过高时,fork 可能导致主线程卡顿 |
| 主从同步 | 全量同步 RDB 文件变大,从库恢复时间拉长 |
| 过期扫描 | 每次周期扫描的对象变多,CPU 消耗上升 |
| 运维排障 | bigkeys、scan、内存分析等操作耗时变长 |
1.3 先算一笔账,再决定要不要动手
我之前处理过一个支付相关的系统,每天新增订单 key 大概 80 万,平均每个 key 加 value 约 400 字节。听起来不大,但如果不设置过期时间,一个月就是 2400 万 key、大约 10GB 数据,一年就是接近 3 亿 key、120GB 内存。关键是这些 key 里大部分在写入第二天就没有任何访问了。
所以判断要不要治理,不要只看内存水位,还要看 key 增长率、命中率、活跃 key 占比这些指标。如果keyspace_hits远小于keyspace_misses,或者扫描后发现大量前缀相同的 key 已经长时间没有访问,那就该动手了。
2. 降低key数量的主流路径,我为什么最终选择Shell脚本组合
2.1 业界常见的四条路
面对 key 数量膨胀,大致有四条路可以走。
代码层治理。改业务代码,统一缓存封装,默认设置 TTL,规范 key 命名,从源头杜绝问题。这是根治方案,但缺点是周期长,而且存量数据还是得有人清理。适合作为长期方案推进。
内存淘汰策略。给 Redis 配置maxmemory-policy allkeys-lru或者volatile-ttl之类的淘汰策略,让 Redis 自己想办法清理。说实话这是最后一道保险,风险在于可能误删还在使用的 key,因为 LRU 只是近似,而且如果全是无 TTL 的 key,volatile-ttl根本不起作用。我一般只建议作为兜底,不作为主动治理手段。
冷热数据分离。把一部分冷数据从 Redis 迁移到其他存储,比如磁盘型 KV 存储或者对象存储,只留热数据在 Redis。这条路对架构改动较大,适合长期演进,不适合应急止血。
脚本批处理。写脚本定期扫描、统计、删除或者设置过期时间。见效快,不用改业务代码,风险可控。缺点是需要有人对数据语义足够清楚,防止误删。这是短期止血、中期辅助的首选。
2.2 方案对比,说实话脚本方案最灵活
我习惯用一张表来做决策对比:
| 方案 | 见效速度 | 风险 | 适用场景 |
|---|---|---|---|
| 代码层治理+TTL 规范 | 慢 | 低 | 长期根治 |
| 内存淘汰策略 | 快 | 中高 | 紧急兜底 |
| 冷热数据分离 | 慢 | 中 | 数据量极大且长期增长 |
| Shell 脚本批处理 | 快 | 中(可控) | 存量治理、日常巡检 |
实际项目里,我一般会把代码层治理和脚本批处理一起上。脚本先处理掉存量脏数据,代码层规范后续增量数据,两边同时推进。
2.3 为什么是Shell,而不是Python或Go
可能有人会说,清理 key 这么复杂的事情,用 Python 或者 Go 写不是更优雅一点吗?我在做 DeepSeek 方案时倒没有纠结太久,原因很现实。
redis-cli 本身已经具备核心能力。扫描用--scan,批量执行用--pipe,统计用INFO,很多时候根本不需要额外的 SDK。部署环境里一般都有 Shell。运维服务器、堡垒机上不一定有 Python 环境和 Redis SDK,但基本都有 Bash 和 redis-cli。配合 crontab 非常自然。Shell 脚本天然适合挂在定时任务里,生成日志、错误退出、邮件告警都容易处理。
另外 Shell 脚本方便审计和解释。你要给业务方看"你到底跑了什么命令",直接贴脚本比贴 Python 类更直观。对临时治理任务来说,简单粗暴绝对要比花哨重要。
3. 脚本核心设计与实现:从SCAN到分批删除的完整链路
3.1 红线:不要用 KEYS,请用 SCAN
不管你是统计还是清理,第一条铁律就是不要在生产环境用KEYS命令。KEYS会一次性遍历整个 keyspace,而 Redis 是单线程模型,KEYS在 key 数量大的实例上执行几秒甚至几十秒,期间所有读写都会被阻塞。生产事故就是这么来的。
正确做法是用SCAN命令配合游标迭代。SCAN每次返回少量 key,不会阻塞主线程。redis-cli 给我们封装好了:
redis-cli --scan --pattern "temp:*"这条命令会持续迭代扫描,把匹配temp:*的 key 输出到标准输出,每行一个 key。它是流式输出的,不会一次性加载到内存,所以即使有上千万 key 也能处理。
3.2 第一步先摸清分布:统计各前缀的 key 数量
动手清理之前,必须先搞清楚哪些前缀的 key 占了大头。我通常会写一个简单的统计脚本:
#!/bin/bash # 统计指定 pattern 下 key 数量 for pattern in "user:*" "order:*" "cart:*" "temp:*" "session:*"; do count=$(redis-cli --scan --pattern "$pattern" | wc -l) echo -e "$pattern\t$count" done不过这里有个坑:如果某个前缀下的 key 数量特别大,这个脚本会跑很久,因为它等于全量扫描了一遍。所以更稳妥的做法是先抽样评估,或者用--count参数控制每次 SCAN 的步长。
3.3 核心删除逻辑:分批管道,避免一把梭
确认了要清理的前缀之后,就到了核心删除环节。最直接的方式是:
redis-cli --scan --pattern "temp:*" | xargs -n 1000 redis-cli DEL这个命令会把匹配到的 key 每 1000 个作为一组,调用一次redis-cli DEL。优点是简单,缺点是大量 key 时仍然比较粗暴,而且不能很好的限速。
我在 DeepSeek 方案里用的是更加可控的分批管道模式:先把 key 列表落盘,然后按批次执行DEL,每批之间 sleep 一下,给 Redis 喘息的时间。
#!/bin/bash # 批量删除 Redis key,支持分批与限速 # 用法: ./redis_clean.sh "temp:*" 1000 2 pattern="${1:-temp:*}" batch_size="${2:-1000}" sleep_seconds="${3:-2}" key_file="/tmp/redis_keys_$(date +%s).txt" pipe_file="/tmp/redis_del_pipe.txt" log_file="/tmp/redis_clean.log" # 1. 导出 key 列表到本地文件 redis-cli --scan --pattern "$pattern" > "$key_file" total=$(wc -l < "$key_file") echo "$(date '+%F %T') 待处理 key 数量: $total" | tee -a "$log_file" # 2. 分批写入管道文件并执行 count=0 : > "$pipe_file" while IFS= read -r key; do printf "DEL %s\r\n" "$key" >> "$pipe_file" count=$((count + 1)) if [ $((count % batch_size)) -eq 0 ]; then echo "$(date '+%F %T') 已累计 $count 条,开始批量删除..." | tee -a "$log_file" redis-cli --pipe < "$pipe_file" >> "$log_file" 2>&1 : > "$pipe_file" sleep "$sleep_seconds" fi done < "$key_file" # 3. 处理剩余不足一批的数据 if [ -s "$pipe_file" ]; then redis-cli --pipe < "$pipe_file" >> "$log_file" 2>&1 fi echo "$(date '+%F %T') 处理完成,共处理 $count 个 key。" | tee -a "$log_file"这个脚本解决了三个问题:第一,key 列表先落盘,后续批量处理如果断了,不用重新扫描;第二,使用redis-cli --pipe走 RESP 协议管道,批量发送DEL命令,效率比逐条执行高很多;第三,每批处理完固定 sleep 几秒,避免瞬时压力过大。
注意:
redis-cli --pipe需要 Redis 2.6.14 以上版本才支持,现在的版本基本都不用担心。
3.4 不是所有 key 都该立刻删除,设置过期时间反而更稳
实际治理过程中,你会发现有些 key 你拿不准是否还在使用。这时候直接删除有风险,更好的做法是给它设置一个较短的过期时间,让 Redis 在 TTL 到来后自动清理。这样做的好处是:如果业务还在用,最多就是一次缓存穿透,应用会重新回源填充;如果已经没人用了,到期后自然消失,不会产生误删事故。
给 key 设置随机 TTL 也可以防止雪崩:
#!/bin/bash # 给匹配到的 key 设置随机过期时间,范围 3600~7200 秒 pattern="temp:*" while IFS= read -r key; do ttl=$((RANDOM * 3600 / 32767 + 3600)) printf "EXPIRE %s %d\r\n" "$key" "$ttl" done < <(redis-cli --scan --pattern "$pattern") | redis-cli --pipe > /dev/null这里我用RANDOM生成了一个 3600 到 7200 秒之间的随机 TTL。为什么要随机?如果所有 key 都在同一个时间点过期,很可能给下游数据库造成一波瞬时回源压力,也就是常说的缓存雪崩。随机 TTL 能把压力摊开。
3.5 特殊字符与空格 key 的隐藏陷阱
这个坑我踩过一次。key 名称里如果带了空格,比如cart:2024:01 01,直接用xargs会被拆成两个参数,删除的时候不仅删不掉,还可能误删别的 key。安全做法是用--null参数,让 redis-cli 输出以空字符结尾的 key 列表,然后用xargs -0来处理:
redis-cli --scan --pattern "cart:*" --null | xargs -0 -n 1000 redis-cli DEL同样的,在while read循环里,我建议显式指定IFS= read -r key,避免默认的 IFS 把 key 头尾的空格和制表符吞掉。
4. 上线前必须做好的三件事:预演、限速与止损预案
4.1 先在预发或只读副本上跑通统计流程
我强烈建议不要一上来就在生产主实例上执行大面积删除。第一步,先在预发环境执行统计脚本,确认你要清理的 pattern 完全匹配预期,不会误伤核心业务数据。如果没有预发环境,可以在生产环境先拿一个流量很低的只读从库做验证,观察SCAN的输出结果。
如果是从库,redis-cli 可以连接从库并执行READONLY模式扫描,这样不会影响主库性能。需要注意的是,从库可能有一些延迟,统计的 key 列表跟主库不是完全一致,但作为预演足够了。
4.2 用并发和批大小控制施压速度
清理脚本不要全速跑。Redis 虽然处理DEL很快,但大批量删除时瞬时 QPS 还是会冲上去,可能影响业务。我一般用下面这套参数来控制压力:
| 场景 | 批大小 | 批次间隔 | 并发数 |
|---|---|---|---|
| 低峰期清理 | 5000 | 1s | 1 |
| 高峰期小批清理 | 1000 | 2~3s | 1 |
| 紧急大规模清理 | 10000 | 1s | 2~4 |
DESI模式下我一般不推荐开并发,因为 Redis 单线程模型下,并发redis-cli反而可能增加瞬时命令排队,性能提升有限。如果要加速,优先加大批大小,而不是提高并发。另外,清理过程中要持续观察INFO stats里的instantaneous_ops_per_sec和slowlog,如果波动明显,需要及时调低批大小。
4.3 删除前的备份、白名单与止损预案
任何删除操作都要有后悔药。我做的第一件事是把 key 列表导出并 gzip 备份:
redis-cli --scan --pattern "temp:*" | gzip > /tmp/keys_backup_$(date +%s).txt.gz这样即使误删了还能根据列表恢复。不过要说明,恢复 key 的 value 需要额外的DUMP/RESTORE机制,我这里备份的是 key 名单,作用主要是分析、排查,而不是完整的快照。真正的多级保护还是靠白名单。
在清理脚本里我会增加一个白名单文件,凡是匹配白名单的 key 直接跳过:
while IFS= read -r key; do case "$key" in temp:important:*|temp:keep:*) echo "跳过白名单 key: $key" continue ;; esac printf "DEL %s\r\n" "$key" done < "$key_file" | redis-cli --pipe止损预案方面,我的经验是:清理期间安排一个熟悉业务的人盯群,一旦业务方反馈缓存丢失,立即停掉脚本。如果使用 TTL 方式而不是直接删除,即使误操作也只导致缓存回源,不影响数据正确性,这也是为什么我前面强调"不确定就先设置过期时间"。
5. 实测中的两个关键坑:SCAN返回大Key和连接抖动
5.1 大 key 才是真正的定时炸弹
SCAN 本身不阻塞,但如果你用SCAN扫到了一个大 key,比如一个包含几十万个字段的 hash,然后对它执行DEL,Redis 会一次性释放这个 key 占用的内存,这个过程在单线程模型下同样会造成卡顿。我见过一个实例,因为删了一个 200MB 的 hash key,主线程卡了好几秒。
遇到大 key,正确的做法是渐进式清理,而不是直接删除。比如对 hash 类型,用HSCAN配合HDEL分批删除字段,等 hash 里的字段清空了,key 自然就没了。同样的逻辑适用于 set 用SSCAN配合SREM、zset 用ZSCAN配合ZREM。
# 渐进式清理大 hash 示例:每次删除 100 个 field redis-cli --scan --pattern "bighash:*" --null | xargs -0 -n 1 redis-cli --raw HSCAN "$key" 0 COUNT 100 | # 这里仅示意,实际需要循环处理游标这个脚本我简写了,核心思路就是不要用DEL直接处理大 key,而是把内部元素分批删掉。
5.2 redis-cli 连接超时和网络抖动
大批量执行redis-cli命令时,可能会遇到连接超时。尤其是通过堡垒机、跳板机连接 Redis 的场景,长时间运行后连接被中途掐断非常常见。我在脚本里一般会加上连接超时和 TCP keepalive 参数:
redis-cli --connect-timeout 5 --tcp-keepalive 60 --scan --pattern "temp:*"--connect-timeout控制 TCP 连接建立的超时时间,--tcp-keepalive会让 TCP 连接定期发送保活包,避免长时间空闲被中间设备断开。
另一个容易忽视的问题:如果脚本在批量删除时中断了,再次运行时不要重新扫描,直接使用之前落盘的 key 文件,避免重复扫描线上的高负载。我已经习惯把 key 列表导出来作为"任务队列",每处理完一个批次,就在进度文件里记录处理的最后一条 key。下次中断后,从进度文件对应的位置继续往下读就行。
5.3 删除后内存没降下来,别慌
清理完大量 key 之后,INFO memory里的used_memory可能不会立刻降下来。这有两个原因:一是 Redis 释放的内存并不会马上归还给操作系统,而是留在自己的内存分配器里复用;二是内存碎片率高的时候,即使逻辑数据少了,物理内存占用依然偏高。
如果确认删除了大量 key,可以用MEMORY PURGE主动整理,但这个命令在某些版本里会比较耗时,建议低峰期谨慎执行。同时可以打开activedefrag让 Redis 在后台自动整理碎片。
6. 治理之后别让key数量反弹
6.1 建立 key 数量巡检脚本
清理完成不代表结束,要防止它反弹。我习惯写一个巡检脚本放到 crontab 里,每天定时统计 key 总量和关键前缀的数量。比如每 10 分钟执行一次,记录到日志文件:
#!/bin/bash # /usr/local/bin/redis_key_count.sh echo "$(date '+%F %T') $(redis-cli dbsize)" >> /var/log/redis_key_count.log如果想统计不同前缀的分布,可以基于redis-cli --scan --count 1000做采样统计,不用全量扫描。这样既能监控总量,又能了解增长来源。
6.2 代码层的长效措施
脚本治理是治标,代码层规范才能治本。从长期看,建议业务侧做几件事:所有缓存 key 默认设置 TTL,即使是空值缓存也不能例外;key 命名按业务模块分层,方便识别;接入统一的缓存 SDK,在 SDK 层强制设置过期时间,避免业务同学遗漏。
我自己在推进的时候,会先整理一份"Redis Key 规范"文档,把命名规则、TTL 要求、风险评估表格发给大家。短期靠脚本清理,长期靠规范约束。
6.3 用可视化工具观察治理效果
虽然我们这套方案是命令行为主,但治理过程中确实还需要一个可视化工具来观察 key 的变化趋势。我在用的终端工具比你想象中简单:Redis Desktop Manager 或 Another Redis Desktop Manager 都可以,连接实例后直接看 key 数量、内存、TTL 分布,配合脚本日志能更直观地判断清理效果。不过可视化工具我不建议直接在这种大批量清理场景里操作,因为图形界面的批量操作能力远不如命令行灵活,它更适合事后观察和验证。
我个人的习惯是:清理前截一张图,清理后再截一张图,对比给业务方看最直观。内存曲线的变化、key 总数的下降,用数据说话。
最后再分享一个我在实际治理中反复用到的小技巧:清理操作前,把目标 key 的列表导出来,不管脚本多完美,都要给自己留一条退路。尤其是那种业务迭代过快、key 语义已经模糊的实例,宁可多花十分钟做备份,也不要赌一把直接删。我在 DeepSeek 方案里始终坚持这个原则,这也是它能平稳处理几亿 key 都没有出现事故的关键一条。