
上周我把一台Redis实例的key数量从680万压到了180万。这个数字放在大厂可能不值一提但在我们这种核心业务全挤在一台8G内存实例上的场景里680万个key已经快到临界点了——每次执行一条慢命令CPU就往上蹿业务方在群里问“缓存服务是不是又抽风了”。更尴尬的是很多key回头看根本不知道该不该删因为光看名字你永远判断不出它背后是哪个业务在写、什么时候写的。后来我换了一个思路让DeepSeek来当我的结对程序员帮我写一组Shell脚本把“找出无用key、批量删除、给剩余key补设TTL”这套流程自动化。折腾了两个晚上方案终于完整跑通。今天把这套完整方案、脚本和踩过的坑都摊开讲给正在被key数量压得喘不过气的朋友一个参考。这篇文章适合谁后端工程师、运维、SRE以及所有在用Redis但没系统性做过缓存治理的团队。内容不聊高深理论全是实操key数量为什么会爆炸、为什么我选Shell而不是Python/Go、DeepSeek怎么辅助写脚本、完整的SCANUNLINK清理流程、批量设置TTL的办法以及我实实在在踩过的坑。1. key数量暴增到底影响了Redis的什么1.1 内存开销你删掉的不是“几个key”是“一份份元数据”很多人对Redis内存的认知停留在“value有多大就占多大空间”实际上每个key本身还带一整套元数据。Redis底层是一个dict字典结构每个key对应一个dictEntry里面存了key的指针、value的指针、哈希表next指针再加上redisObject对象头、SDS字符串头等等。一个空的String类型key哪怕value只有一个字节真实占用的内存也可能在70字节以上。我粗略算过一笔账如果5百万个无意义的key平均key名30字节、value是“1”这种短字符串每个key整体占用按90字节算光key这一层就是450MB。这就是为什么有些Redis实例存的数据不多RSS却高得吓人。这里也顺带解释一个大家常问的点为什么不用Hash结构优化如果你有大量同前缀的key比如user:1001:name、user:1001:age把同一批uid下面的字段塞进一个Hash里用hset user:1001 name xxx、hset user:1001 age 20元数据开销就能被多个字段摊薄。Redis在内部对小Hash还有ziplist/listpack压缩编码内存收益非常明显。但改造数据结构的成本不是一篇文章能讲完的今天的主角还是“清理”。1.2 操作延迟与阻塞风险慢命令才是致命伤key数量一多最明显的就是KEYS命令不能碰。KEYS *要对整个字典做全量遍历几百万key时执行一次直接阻塞Redis好几秒期间所有读写请求全部排队业务直接雪崩。就算不用KEYSkey数量过大也会遇到其他隐性成本RDB持久化要遍历全部keyAOF重写也一样key越多后台保存耗时越长fork出来的子进程如果内存没释放干净写时复制带来的内存增长更吓人。主从复制时全量同步要传输RDB文件key数量直接决定RDB文件大小同步时间会翻倍。集群模式下key数量影响slot迁移速度不过单机场景暂时不涉及。redis-cli --bigkeys这类分析工具要遍历整个keyspacekey越多跑完一轮越久。我那次遇到的实际现象是实例的CPU经常莫名其妙飙到80%以上排查发现并没有慢查询日志后来才发现是后台RDB保存周期变长加上内存碎片率一直维持在1.5左右整个实例的状态就很“黏”。1.3 业务与运维成本你无法回答“这个key该不该删”key数量爆炸最隐蔽的问题是它让所有人失去了判断力。INFO keyspace一查keys几百万expires却只有一小部分你再问开发“这些key是不是都能删”没人敢打包票。数据保留时间不可控安全审计过不了故障复盘说不清最后只能继续堆内存。所以Redis缓存治理的第一个动作永远是盘点你到底存了哪些前缀、哪些有TTL、哪些没有。这一步搞清楚了后面删key才有底气。2. 方案选型为什么我用Shell脚本而不是其他工具2.1 redis-cli已经够强只是很多人没用对坦白说清理Redis key这件事Redis官方自带的redis-cli已经覆盖了90%的需求。关键是你会不会组合它redis-cli --scan --pattern temp:* --count 500按模式增量遍历不阻塞实例。redis-cli --scan | while read key; do ...; done把扫描结果接到Shell循环里做流式处理。redis-cli --pipe file把一批Redis协议命令一次性写入性能比一条条执行高一个量级。redis-cli --bigkeys粗略分析大key分布。redis-cli --rdb把RDB备份拉回本地。Shell脚本的定位就是把这些命令串起来补齐日志、灰度开关、参数校验这些事。需要依赖吗不需要。redis-cli装好bash自带任何一台服务器都能跑。这比Python脚本需要装redis-py、Go脚本需要交叉编译要轻太多。2.2 为什么我不推荐一上来就写Python/Go不是说Python不行而是“清理key”这种一次性或低频任务用Shell脚本有天然优势零依赖交付就是单个.sh文件扔到服务器上直接执行。可以扔进crontab配合flock做防重入运维同学看一眼就懂。出了问题直接看日志和退出码不需要另起一套运行环境。当然如果你有几十亿key、需要做复杂的容量预估和动态调整那确实该上Python或者Go。但在大多数中小团队里Shell脚本足够而且最小可行。2.3 DeepSeek在里面的角色不是替你写而是帮你把手册翻明白这次我用DeepSeek的方式比较特殊我不要求它一次性生成最终脚本而是把它当“Redis活手册 代码审查员”。我把自己要做的事说清楚让它先给一个能跑的版本然后我指着代码里的问题逐条问它再改。比如我问它“SCAN返回的cursor怎么在Shell里处理”它会告诉我redis-cli scan 0 MATCH xxx COUNT 200的输出格式还会提醒我cursor在循环里更新的写法。老实说这些知识网上都能查到但DeepSeek能顺着我的上下文连续给出方案省掉了我翻文档的时间。3. 用DeepSeek辅助生成清理脚本的全过程3.1 第一轮Prompt把约束条件说清楚我给的Prompt大概是这样的我有一台Redis实例key数量约600万有问题的key主要是这几个前缀 - session:xxx会话缓存可以删 - temp:xxx临时数据超过24小时就没用了 - login_code:xxx登录验证码超过10分钟就没用 要求 1. 写一个bash脚本扫描指定前缀的key 2. 必须用SCAN不能用KEYS避免阻塞 3. 删除时用UNLINK避免删除大key时阻塞 4. 支持--dry-run参数只打印不执行 5. 输出日志记录扫描数量和删除情况 6. 一次扫描一坨key不要一条条删除尽量高效这个Prompt的信息量已经够了。DeepSeek第一版生成的东西其实是一个比较常见的“扫描删除”脚本核心逻辑是用redis-cli --scan收集key然后用xargs分批调redis-cli unlink。能用但有很多隐患。3.2 DeepSeek生成的第一版脚本长什么样简化后大概是这样的#!/bin/bash redis-cli -h 127.0.0.1 -p 6379 --scan --pattern session:* | xargs -n 200 redis-cli -h 127.0.0.1 -p 6379 unlink看着没问题问题刚好就藏在细节里--scan会把所有匹配key都读到内存里如果匹配到500万个key这串输出会非常长虽说不像KEYS那样阻塞Redis但客户端Pipeline堆积也可能造成瞬时压力。xargs -n 200 redis-cli unlink每200个key起一个redis-cli进程5百万key就是2.5万个进程光进程调度开销就够喝一壶。没有日志没有dry-run出问题很难回滚。如果key名里有空格或特殊字符xargs的默认空白分割会把一个key拆成两截严重的话会删错东西。我没有直接跑这个版本而是让它按“协议文件 --pipe”的方式重写一次连接把批量UNLINK发完。3.3 迭代后的最终脚本SCAN RESP协议 --pipe最终版本我用的是“扫描结果写入临时文件 构造RESP协议 管道发送”的方式兼顾了效率和可观测性#!/bin/bash # # redis_clean_keys.sh # 基于 SCAN UNLINK 批量清理 Redis key # # 用法: # ./redis_clean_keys.sh -h 127.0.0.1 -p 6379 -n 0 -P session:* --dry-run # ./redis_clean_keys.sh -h 127.0.0.1 -p 6379 -n 0 -P session:* # # 依赖: redis-cli, date, sed, awk # set -uo pipefail HOST127.0.0.1 PORT6379 DB0 PASSWORD PATTERN BATCH200 DRY_RUN0 LOG_FILEredis_clean_$(date %Y%m%d_%H%M%S).log log() { echo [$(date %Y-%m-%d %H:%M:%S)] $* | tee -a $LOG_FILE } usage() { cat EOF Usage: $0 [options] Options: -h, --host host Redis 地址默认 127.0.0.1 -p, --port port Redis 端口默认 6379 -n, --db number Redis 库号默认 0 -a, --password pwd Redis 密码 -P, --pattern pattern 需要匹配的 key 前缀/模式必填 -b, --batch number SCAN 每次遍历的数量默认 200 --dry-run 只输出将要删除的 key不真正执行 EOF exit 1 } while [[ $# -gt 0 ]]; do case $1 in -h|--host) HOST$2; shift 2;; -p|--port) PORT$2; shift 2;; -n|--db) DB$2; shift 2;; -a|--password) PASSWORD$2; shift 2;; -P|--pattern) PATTERN$2; shift 2;; -b|--batch) BATCH$2; shift 2;; --dry-run) DRY_RUN1; shift;; *) echo 未知参数: $1; usage;; esac done if [[ -z $PATTERN ]]; then echo 错误: 必须通过 -P 指定 key 匹配模式 usage fi AUTH_ARGS() if [[ -n $PASSWORD ]]; then AUTH_ARGS(-a $PASSWORD) fi SCAN_BASE(redis-cli -h $HOST -p $PORT -n $DB ${AUTH_ARGS[]}) SCAN_FILE$(mktemp) PROTO_FILE$(mktemp) trap rm -f $SCAN_FILE $PROTO_FILE EXIT log 开始扫描 pattern$PATTERN batch$BATCH ... ${SCAN_BASE[]} --scan --pattern $PATTERN --count $BATCH $SCAN_FILE 2$LOG_FILE SCANNED0 while IFS read -r key; do SCANNED$((SCANNED 1)) if [[ $DRY_RUN -eq 1 ]]; then log [DRY RUN] 将删除: $key else printf *2\r\n\$6\r\nUNLINK\r\n\$%d\r\n%s\r\n ${#key} $key $PROTO_FILE fi done $SCAN_FILE log 本次扫描到 $SCANNED 个匹配 key if [[ $DRY_RUN -eq 0 -s $PROTO_FILE ]]; then log 开始通过 --pipe 批量 UNLINK ... ${SCAN_BASE[]} --pipe $PROTO_FILE $LOG_FILE 21 fi log 清理任务执行完毕这段脚本的几个关键点mktemp生成临时文件trap确保脚本异常退出时也能清理临时文件。用--scan --count $BATCH控制每轮遍历的抓取量给服务端一个“降低单次耗时”的提示。删除命令不直接用字符串拼接而是按RESP协议生成UNLINK key这样即使key名里有空格、引号也不会出问题。--dry-run只在日志里打印不碰线上数据。跑一个dry-run你会看到类似输出[2025-03-02 02:11:32] 开始扫描 patternsession:* batch200 ... [2025-03-02 02:11:41] 本次扫描到 312405 个匹配 key [2025-03-02 02:11:41] [DRY RUN] 将删除: session:1:a [2025-03-02 02:11:41] [DRY RUN] 将删除: session:1:b ...这个日志本身就是很珍贵的资产它告诉你这个pattern下到底有多少key、都是谁。3.4 我让DeepSeek继续审查它确实发现了我容易漏掉的问题脚本能跑之后我又问了DeepSeek几个问题“如果Redis密码里有特殊字符怎么办”“如果SCAN_FILE里的key数量特别大临时文件会不会撑爆磁盘”“--pipe返回ERR Protocol error: invalid multibulk length通常是啥原因”它一一给了答案。尤其有一条提醒比较有价值redis-cli --pipe不会自动判断每条命令是否执行成功它只统计errors数量所以脚本结束后至少要看一眼日志里的errors: 0别想当然以为全删了。这一点我以前确实踩过坑之前的清理脚本跑完显示“5万个key已删除”结果Redis里还剩几万个因为中途有某个key因为类型原因报错了而我没看错误计数。4. 完整实操从盘点、清理到设置TTL的落地方式4.1 第一步先盘点别急着删所有清理动作开始之前先回答两个问题现在有哪些前缀每个前缀有多少key最直接的方式是先用INFO keyspace看整体redis-cli -h 127.0.0.1 -p 6379 -n 0 info keyspace输出里能看到keys和expires如果keys很多、expires很少说明大量key没有过期时间这是最大的风险点。然后按前缀统计分布。我当时的做法是扫描全部key用冒号切出第一段统计频次redis-cli -h 127.0.0.1 -p 6379 -n 0 --scan --count 1000 | awk -F: {print $1} | sort | uniq -c | sort -rn | head -20输出大概长这样245833 session 152100 temp 89102 login_code 55100 user 12600 cart_ 322 unknown_prefix这一步花不了几分钟但对整个清理方案至关重要。你会清楚看到哪个前缀是“大头”哪个可能是历史遗留。另外强烈建议跑一次redis-cli --bigkeys虽然它不会直接告诉你该删哪些key但能帮你找出那些“删除代价很高”的大keyredis-cli -h 127.0.0.1 -p 6379 -n 0 --bigkeys如果发现某些key是几MB甚至几十MB的Hash或List你就得额外小心后面我们会说到大key的删除策略。4.2 第二步按前缀批量清理盘点完就到了主角登场的时候。假设我们发现temp:前缀下有一大批24小时前的临时数据可以这么操作先dry-run看看匹配到的key数量是否符合预期./redis_clean_keys.sh -h 127.0.0.1 -p 6379 -n 0 -P temp:* --dry-run确认数量和pattern没写错之后去掉--dry-run正式清理./redis_clean_keys.sh -h 127.0.0.1 -p 6379 -n 0 -P temp:*执行完了立刻用DBSIZE看当前库的key总数redis-cli -h 127.0.0.1 -p 6379 -n 0 dbsize如果清理成功数字应该明显下降。这里我再强调一次清理动作一定要放在业务低峰期并且最好提前备份RDB。我习惯在清理前先执行redis-cli --rdb /tmp/redis_backup_$(date %s).rdb把当前数据拉到本地防止删错后无法恢复。4.3 第三步给剩余key补设合理的TTL删完一批之后最怕的就是“清了又满”。如果你的业务代码里大量写key时没设过期时间清理得再勤也白搭。所以第三步是给那些一直没TTL的key补上过期时间。我当时写了一个简化版脚本逐个判断key的TTL#!/bin/bash # add_ttl_without_expire.sh # 给 Redis 中没有 TTL 的 key 设置随机过期时间12-24小时之间 redis-cli -h 127.0.0.1 -p 6379 -n 0 --scan --count 500 | while IFS read -r key; do ttl$(redis-cli -h 127.0.0.1 -p 6379 -n 0 ttl $key) if [[ $ttl -1 ]]; then expire_sec$((RANDOM % 43200 43200)) redis-cli -h 127.0.0.1 -p 6379 -n 0 expire $key $expire_sec /dev/null echo [$(date %Y-%m-%d %H:%M:%S)] set expire $key - $expire_sec /tmp/ttl_fix.log fi done注意这个脚本是“一条key一次TTL查询”性能不算好如果你有几十万key跑完可能要几十分钟。我的建议是先扫出没有TTL的key数量量不大再跑这个脚本。量大的话优先修代码让业务侧在写入时带上EX或PX参数从源头解决问题。设置过期时间时加一个随机偏移量我这里用的是12-24小时避免大量key在同一秒过期引发缓存雪崩。这是生产环境的常识但特别容易忘。4.4 第四步把清理和TTL巡检挂到计划任务人工跑一次只能解决一时的问题要持续控制key数量最好把巡检脚本挂到crontab里。我用的方式是在原脚本外面包一层shell加上flock锁避免任务重叠#!/bin/bash # cleanup_cron.sh LOCK_FILE/tmp/redis_cleanup.lock # -n 表示拿不到锁就直接退出避免上一轮任务还没跑完又启动一轮 flock -n $LOCK_FILE -c /opt/scripts/redis_clean_keys.sh -h 127.0.0.1 -p 6379 -n 0 -P temp:*然后在crontab里# 每天凌晨3点清理临时key 0 3 * * * /opt/scripts/cleanup_cron.sh /tmp/redis_cleanup_cron.log 21这样至少能把“临时前缀”这类明确可删的key控制住。至于那些没有TTL的key我建议每周跑一次4.3里的巡检把告警接上一旦发现某类前缀的key数量异常增长就说明业务代码有新的地方漏了设置过期时间。5. 避坑指南我在清理过程中踩过的坑5.1 千万别用KEYS命令哪怕只是“看一眼”我知道你肯定听过这句话但犯这个错的人依然很多。我身边就有人为了图省事直接redis-cli keys * | wc -l统计key数量结果几百万key的实例瞬间卡住线上请求超时。统计key数量请用DBSIZE扫描特定pattern请用--scan。KEYS唯一适用场景是本地开发环境或者key数量极少的测试实例。5.2 大key别直接DEL要用UNLINKRedis 4.0之后UNLINK就是用来替代DEL处理大key的。DEL是同步释放内存如果value是一个几十MB的List删除操作会阻塞实例UNLINK会把释放内存的动作丢给后台线程主线程很快返回。所以我在清理脚本里特意使用了UNLINK而不是DEL。如果你匹配到的key里有Hash、List、Set、ZSet这种容器而且体积很大这点尤其关键。如果你发现某个超大Hash特别碍事又还想保留一部分数据那可以用HSCAN加HDEL配合删而不是一把梭redis-cli -h 127.0.0.1 -p 6379 -n 0 hscan big_hash_key 0 COUNT 200 | \ while read -r field; do redis-cli -h 127.0.0.1 -p 6379 -n 0 hdel big_hash_key $field done5.3 Redis的MATCH pattern不是正则是glob这个坑我用DeepSeek写脚本时也被提醒过。Redis的MATCH支持三种通配符*任意长度字符?单个字符[...]括号内的任意一个字符不支持、^、[a-z]范围之外的复杂正则规则。更坑的是*会匹配任意字符所以user:*也能匹配到user:1:name:extra。如果你只想匹配user:后面只带一层ID的key靠Redis的MATCH做不到得在Shell里二次过滤。我当时的处理方式是对照日志逐条看确保没有误杀。如果你有类似的强过滤需求可以先用grep -E或awk再筛一层redis-cli --scan --pattern user:* | grep -E ^user:[0-9]$5.4 删除后Redis内存没有立刻下降别慌我在清理完第一批key后发现INFO memory里的used_memory确实降了但used_memory_rss几乎没动心里一沉。后来才明白这是内存碎片和后台释放机制在捣乱。UNLINK虽然是异步释放但内存碎片率mem_fragmentation_ratio不会因为你删了key就马上恢复正常尤其在高并发写入过的实例里内存页散布得非常碎。RSS可能在高位维持一段时间。遇到这种情况我的建议是不要马上重启先观察一段时间后台线程会慢慢回收。如果碎片率长期高于1.5可以在维护窗口用MEMORY PURGE尝试整理但效果因内存分配器而异。内存依然不够用再考虑主从切换、重启或扩容。5.5 删除后key又出现了问题不在Redis而在上游用脚本清完以后如果过了一天DBSIZE又涨回原来的量级基本可以断定有业务代码在持续写入这些key。这时候最忌讳的是“再加一条更狠的定时清理任务”因为这是拿运维手段掩盖代码缺陷。正确做法是在Redis客户端或者业务侧给这些key统一加上TTL。查一下哪些服务在写这些前缀把缓存写入逻辑改成“写入时设置过期时间”。如果实在没法改代码至少把清理频率调高但一定要评估是否会造成误删。6. 常见问题与排查技巧实录我整理了一份问题速查表这些都是实践里被问过最多的问题问题现象可能原因解决方案redis-cli --scan --pattern a:*扫到的key比实际少SCAN的count是一个“提示值”服务端不一定精确返回这么多加上遍历过程中可能有key被删除或新写入多跑几轮对比或直接在业务低峰期全量扫执行清理脚本后Redis仍有大量同名前缀key业务代码在持续写入清理速度赶不上写入速度优先修业务侧TTL再考虑提高清理频率--pipe返回ERR Protocol error: invalid multibulk length协议文件格式被破坏通常是key名内包含\r\n或二进制字符清理前先对key名做校验或改用--scan --quoted配合解析清理后内存碎片率很高大量key删除后内存页碎片化观察自动回收必要时维护窗口重启脚本在crontab里重复执行上一个任务没跑完新任务又启动了用flock -n做互斥锁删除时发现部分key是其他业务正在用的线上数据清理前没做充分盘点或pattern写得太宽严格遵守“先dry-run、再小批量灰度、最后全量”的流程清理后线上出现缓存穿透删得太猛或者TTL设置太短大量请求同时打到数据库TTL设置随机偏移避免集中过期最后一个关于“缓存穿透”的问题我觉得值得多说两句。很多人清理Redis的时候眼睛里只盯着“key减了没有”却忽略了这些key是不是还有流量在访问。如果某个key刚被删下一秒用户又访问了并且业务层没有做空值缓存那它就是一次DB硬查询。所以在清理前应该先看这个pattern在Redis的INFO stats里有没有对应的keyspace_hits数据或者用客户端埋点观察一段时间的访问频次。高频访问的key哪怕暂时没用也别一刀切。最后说一点个人体会这套方案跑完之后我最大的感触是DeepSeek确实能帮我们省掉很多“查手册”的时间它把Shell脚本的骨架、SCAN的用法、--pipe的注意事项都给你排好了但你自己的判断力没法外包。比如哪些key能删、哪些不能删脚本再聪明也不知道你业务里cart_和user_info_的区别。我现在的习惯是让DeepSeek生成第一版然后人工review每一行命令再在测试环境架一套一模一样的Redis演练一遍最后才敢动生产。Redis数据删了就真的没了备份、dry-run、灰度这三个动作一个都不能省。