Redis 批量删除 Namespace 数据避坑指南:从 KEYS 阻塞到 SCAN 安全实践
大概在去年年中我接到一个看起来特别简单的需求把 Redis 里某个业务模块下的数据全部清掉。这个模块的所有 key 都带user:session:前缀相当于一个独立的 namespace模块下线之后这些会话数据就成了垃圾。我当时的第一反应是进 redis-cli敲一条KEYS user:session:*拿到所有 key再拼一个DEL批量删掉。幸好旁边的运维同事按住了我的手——这条命令在几百万 key 的实例上跑完线上基本就卡死了。redis 批量删除 namespace 下的数据听起来是一行命令的事真做好了却要踩过一轮坑、想清楚原理、选对工具才能稳稳落地。这篇内容适合正在做缓存治理、模块下线、测试环境清理或者在 k8s 里维护 Redis 实例的同学参考。我会从 namespace 到底是什么讲起然后解释为什么 KEYS 是生产环境的坑再给出 SCAN 的正确打开方式和三套可以抄的脚本最后聊聊集群环境和大批量清理的注意事项。1. namespace 到底是什么Redis 的伪目录和 k8s 里的 namespace 别搞混1.1 用前缀约定模拟 namespace最常用的工程做法Redis 本身是扁平的 keyspaceKV 就存在一个巨大的哈希表里。它没有目录、没有 schema、也没有真正意义上的 namespace。工程上大家普遍用 key 的命名约定来模拟最常见的就是用冒号分段user:session:20240815:abc123、order:detail:10086。冒号前面的部分在团队约定里就是一道 namespace 边界。所以当你看到删除 namespace 下的数据绝大多数情况下是在说删除所有以某个前缀开头的 key。实操中还会遇到一些人把 Redis 的不同 dbselect 0~15当作隔离命名空间来用。不过 db 模式在 cluster 下不生效在单机上用 db 做模块隔离的团队也越来越少因为容易误执行 FLUSHDB 把旁边模块的数据一起干掉。我更建议团队统一使用前缀命名配合下面要讲的 SCAN 方案能精准批量删除。1.2 哪些场景会逼着你去批量删除 namespace我总结下来主要就四种情况模块下线或服务拆分某个业务不再用了但缓存 key 还在内存白白占着。这种通常是大批量几十万到上千万。测试环境数据污染联调后代码里写死了某个前缀用例反复跑把 Redis 撑满了。脏数据修复序列化版本升级、key 过期时间设置错误需要定向清理某个 namespace 下的异常数据。数据迁移后回滚从旧缓存迁到新缓存或者临时开关切流量切错了要清掉错误前缀。每种场景对多快删完的容忍度不一样。模块下线可以慢慢删甚至删几小时都能接受修脏数据则往往希望在秒级完成。所以提前想清楚自己要哪种策略比直接抄命令更重要。1.3 动手前先回答四个问题目标实例和目标 db单机还是集群哪一个 db连接到哪台机器别在测试环境执行了生产脚本。namespace 边界到底多长是user:session:*还是user:session:2024:*边界越长扫描匹配越快越准。数据量级大概多少dbsize 有多大匹配 key 大约多少。能接受多大影响低峰期、高峰期允许几毫秒抖动还是完全不能影响业务。这几个问题对之后选方案redis-cli 管道、Lua、Python直接有影响。后面我会反复提到它们。2. 别用 KEYS DEL 清数据线上翻车案例和原理拆解2.1 KEYS 是单线程模型里的头号阻塞源Redis 是单线程处理命令KEYS 命令需要遍历整个 keyspace 来匹配 pattern时间复杂度 O(N)。当实例有几百万 keyKEYS 执行期间所有读写请求都在排队。线上如果看到某个客户端报 lettuce 的RedisCommandTimeoutException很多情况下不是网络问题而是服务端正在执行 KEYS 这类阻塞命令后面所有命令都卡住了。我用图书馆管理员做类比管理员把几千本书从架子上拿下来一本本登记这个过程中任何读者借书都只能等着。KEYS 就是在干这件事而且 Redis 的所有命令都得等它干完。业务高峰期来这么一下慢查询日志、客户端超时、告警电话会同时出现。2.2 DEL 成百上千个 key 也没你想的便宜很多人以为 KEYS 危险DEL 总安全了吧。单独的 DEL 复杂度是 O(1)但你要删的是几百万个 key。如果一个个删网络往返和命令处理时间都会累积。更关键的是DEL 一个 value 很大的 key释放内存的过程会让主线程停顿在 4.0 之前大 key 删除造成的阻塞可以说是一删一个准。虽然是逐个 O(1)但大量 IO 同样会拉高延迟影响其它请求。所以用 KEYS 拿回列表再循环 DEL看起来一行搞定实际是双重伤害先全局阻塞一次再被大量 DEL 拖累。我见过一个小团队在灰度环境用这个方法清缓存结果主从同步延迟从几毫秒飙到十几秒整个链路直接雪崩。2.3 什么前提下可以小范围用 KEYS我不能说 KEYS 完全不可以用。如果确定满足非生产环境当前 key 总量很小几百个以内明确能接受全实例短暂阻塞。那redis-cli --scan其实更好。我见过一些同学在本地开发库用 KEYS 清理这问题不大但要养成条件反射在不确定数据量和环境的情况下一律不碰 KEYS。判断信号很简单——先执行redis-cli dbsize如果匹配的 key 数量还不到几千再来谈简单方案。3. SCAN 游标扫描精确匹配前缀的批量删除核心思路3.1 SCAN 的游标机制一次拿一点遍历完为止SCAN 是生产环境里遍历 key 的正确姿势。每次调用返回两个东西新的游标和一个 key 列表。游标不是偏移量而是一个不透明的状态标记。下一次用这个游标继续调用直到返回的游标是0代表遍历结束。这样 Redis 每次只处理一小部分数据不会长时间阻塞。遍历过程中如果有 key 被新增或删除SCAN 不保证一定能全部看到但工程上完全够用。最简单的体验方式redis-cli --scan --pattern user:session:*--scan是 redis-cli 对 SCAN 的封装自动处理游标一行命令就能把匹配的 key 一个一行输出。注意这个命令本身在生产环境也有开销但相比 KEYS 已经温和太多。3.2 MATCH pattern 与 COUNT 的真实语义SCAN 的匹配是服务端做的每次返回的 key 数量由 COUNT 参数影响但 COUNT 不是返回 N 条匹配结果而是本次遍历大致做这么多槽位的工作量。假如 pattern 匹配率很低一次 COUNT100 可能一条结果都返回不出来但这不代表数据不存在。另外某些版本的 SCAN 还可能在结果里返回重复 key客户端要能容忍重复或不重复的语义。我们用 redis-cli --scan 时它会做一定处理不做去重重复概率很低实际可忽略。个人建议线上 COUNT 不要设太高100~1000 就可以这样单次 SCAN 耗时可控避免在高负载下又变成隐藏的阻塞源。这个值和后面的删除批次要分开看SCAN 的 COUNT 控制的是遍历力度删除批次控制的是 DEL 的力度。3.3 扫到 key 之后的删除策略DEL、Pipeline、Lua 怎么选逐条 DEL实现最简单每条命令一次 RTT适合几千条的清理。Pipeline把一批 DEL 打包发给 Redis减少网络往返适合几万到几十万但要注意不要一次打包太多建议 100~200 个避免客户端缓冲和服务端压力太大。Lua 脚本在服务端执行扫描删除既能减少网络往返又能保证一次性操作在单体 Redis 上是原子的适合需要严格一致性和速率控制的场景。三种方式不是互斥的。实际项目里我经常是SCAN 用游标遍历 删除用 Pipeline或者SCAN 删除都塞进一个 Lua 脚本区别主要看你要不要实时进度和容错。4. 三个能直接上线的脚本方案redis-cli、Lua、Python4.1 最省事redis-cli --scan 管道 xargs 批量删如果只是低峰期一次性清理redis-cli 管道是最快的方式redis-cli --scan --pattern user:session:* | xargs -d \n -L 100 redis-cli DEL解释一下这条命令的组成左边拿到所有匹配 key每 100 个作为一批执行一次DEL key1 key2 ... key100。末尾的redis-cli DEL会默认连接同一个实例如果想指定 host/port把两边的 redis-cli 都写成redis-cli -h host -p port。一个必须注意的点是-d \n。我见过很多教程直接写xargs redis-cli DEL当 key 里包含空格或者特殊字符时xargs 会把它们当成多个参数导致删错或删漏。加上-d \n之后每一行输入整体作为一个参数。这个细节后面我再讲坑的时候还会提。4.2 需要原子性Lua 脚本限定批次执行如果你希望扫描删除在服务端原子完成或者想在一次调用里控制删除总量Lua 脚本是我常用的方式。脚本这样写-- delete_namespace.lua local cursor 0 local pattern ARGV[1] local max_delete tonumber(ARGV[2] or 500) local count_hint tonumber(ARGV[3] or 100) local deleted 0 repeat local rep redis.call(SCAN, cursor, MATCH, pattern, COUNT, count_hint) cursor rep[1] local keys rep[2] if #keys 0 then deleted deleted #keys redis.call(DEL, unpack(keys)) end until cursor 0 or deleted max_delete return deleted调用方式redis-cli --eval delete_namespace.lua user:session:* 500 100脚本里 max_delete 是一个安全阀单次调用最多删 500 个 key删完一个批次就返回由客户端拿到的返回值判断是否再调用。这样 Redis 不会一次性循环几十万次导致主线程卡死。如果你删的 key 里有些是大 value还可以把redis.call(DEL, ...)换成redis.call(UNLINK, ...)让内存回收异步化。4.3 精细控制Python 脚本带进度和限速在数据量大、需要可观测性的场景我更喜欢用 Python 脚本。redis-py 的 scan_iter 封装了游标配合 pipeline 可以做到可控速度。import time import redis r redis.Redis(host127.0.0.1, port6379, db0, decode_responsesTrue) pattern user:session:* batch_size 200 sleep_seconds 0.05 deleted_total 0 pending [] for key in r.scan_iter(matchpattern, count100): pending.append(key) if len(pending) batch_size: r.delete(*pending) deleted_total len(pending) print(fdeleted: {deleted_total}) pending [] time.sleep(sleep_seconds) if pending: r.delete(*pending) deleted_total len(pending) print(fdeleted: {deleted_total})这个脚本用 count100 控制 scan 单次工作量每删除 200 个就 sleep 50ms实现对 Redis 的软限速线上高峰期也能把抖动控制在很小范围。需要断点续删时可以把这个脚本改成记录最后一个遇到的 key下次从它继续 scan_iterscan_iter 支持 cursor 参数不过大多数场景不需要这么复杂。4.4 方案对比与适用场景方案适合规模原子性代码量主要风险redis-cli xargs千~百万无一行key 含空格、xargs 批次过大Lua 脚本万~百万单实例原子5 行循环控制不好会阻塞Python 脚本任意规模无20 行需要额外运行时scan_iter 需注意集群支持实际建议几万以下用 redis-cli几万以上又要稳定用 Python如果有严格业务一致性或者只能在客户端简单调用用 Lua。如果你的场景明确是批量删除 namespace 下的数据而且这个 namespace 前缀很规范那 Lua 和 Python 的差距其实不大选你最熟悉的、敢在凌晨三点拿出来跑的方案。5. Cluster 与 k8s 部署下的批量清理两个最容易翻车的场景5.1 Redis ClusterSCAN 只能扫到当前节点master 要逐个跑在 Redis Cluster 里key 按照 CRC16 分到 16384 个 slot不同的 slot 分散在不同 master 节点上。SCAN 命令只会扫描你当前连接的那个节点不会自动跨节点。所以如果直接对着某个节点执行redis-cli --scan你只能拿到落在那一台机器上的 key其他节点的 namespace 数据还在。正确做法是遍历 cluster 里所有 master 节点分别调用 SCAN再汇总删除。命令行简单做法redis-cli -h node1 -p 7000 --scan --pattern user:session:* | xargs -d \n -L 100 redis-cli -h node1 -p 7000 DEL redis-cli -h node2 -p 7001 --scan --pattern user:session:* | xargs -d \n -L 100 redis-cli -h node2 -p 7001 DEL如果要自动化可以先拿到 cluster nodes 输出过滤带有 master 的节点然后循环执行。Python 用 RedisCluster 时scan_iter 在 redis-py 的 cluster 模式下会逐个节点迭代所以相对省心但要确认你使用的库版本确实支持不放心就自己遍历 startup_nodes。这个逐个 master 扫描的细节是很多人从单机切到集群后最容易踩的坑。5.2 k8s 里的定位先找 pod再进 redis-cli现在很多 Redis 以 Deployment/StatefulSet 跑在 k8s 里项目里说的 namespace 也可能是 k8s 的命名空间。这两层概念要分开Redis key 的前缀是业务层 namespacek8s namespace 是资源隔离范围。你要清理的数据具体在哪个 pod 上通常取决于 Redis 是单机部署还是 cluster 多 pod。定位实例的方法很简单kubectl get pods -n k8s-namespace | grep redis kubectl exec -it pod -n k8s-namespace -- redis-cli --scan --pattern user:session:* | head -n 10如果 Redis 以 service 对外提供访问也可以从集群内直接用 service 域名连过去。需要提醒的是kubectl exec 进去之后执行的 redis-cli 默认连接本机 6379如果 pod 里配置了多副本或 Sentinel先确认当前 pod 的角色避免删了从节点又同步回来。5.3 主从架构下删除的影响批量删除的 DEL/UNLINK 命令会通过主从复制同步到从节点。如果从节点还有读流量删除风暴会同时压在主从两端。可以采取两个优化一是在从节点上执行 SCAN 来发现 key在主节点上执行删除这样扫描产生的开销不掺到主节点二是删除高峰和业务读高峰错峰。这些细节在大型缓存集群里尤其重要毕竟清理 namespace 的目的是让 Redis 更健康而不是又制造一个新故障。6. 线上清理实战分批统计、灰度验证与踩坑记录6.1 清理前先体检数数、量大小、评估耗时不要上来就删。先用只读操作摸底redis-cli dbsize redis-cli --scan --pattern user:session:* | wc -l如果想看 key 大小用 memory usage 抽样redis-cli memory usage user:session:20240815:abc123依据总 key 数和平均大小估算出需要删除的数据量。然后再用下游脚本本地测一次删除速率比如先删 1000 个记录耗时估算全量删除需要多少秒。如果估算结果超过可接受的抖动窗口就必须用 sleep/分批策略拉长时间线。这一步花不了几分钟但对后续操作的安全性几乎是决定性的。6.2 灰度验证先从一小片开始删我习惯先挑一个时间分片或者一个用户分片做验证比如只清理user:session:202407:*。执行之后检查目标 key 是否消失业务有没有报错主从延迟有没有升高客户端超时有没有出现。如果灰度通过再扩大范围。这一步对于线上操作来说非常必要它能把脚本写错了的风险控制在最小范围。很多线上事故都是因为我以为命令没问题直接全量跑结果 prefix 多打了一个字母删了别的模块数据。6.3 正式执行时的监控指标和配置建议执行中盯四个指标Redis 的瞬时 CPU、connected_clients、主从复制 lag以及业务侧报错率。如果 CPU 出现尖峰或者客户端开始超时立即暂停脚本。小技巧是给删除脚本加一个暂停开关比如将 sleep 时间放到配置里出事时把 sleep 调大即可。还要考虑开启 lazyfree 相关配置lazyfree-lazy-expire yes lazyfree-lazy-eviction yes lazyfree-lazy-server-del yes这样删除大 key 时内存回收放到后台线程减少主线程卡顿。注意这个配置需要在 redis.conf 里设置并重启实例或者在运行时用 CONFIG SET 动态调整改了之后 DEL 的语义不变只是内存回收变异步对清理 namespace 这种大批量场景很有用。6.4 我踩过的四个坑坑一xargs 把含空格的 key 拆成两个参数一度把不相关的 key 也删了。教训始终用xargs -d \n。坑二SCAN COUNT 设置成 10000一条 SCAN 依然可能耗时几十毫秒在高峰期照样造成延迟尖刺。教训COUNT 设小一点100~1000。坑三Lua 脚本写成了无限循环删起来停不下来。教训脚本里必须有 max_delete 安全阀客户端也要有超时中断。坑四以为 MATCH 能让 SCAN 变快。其实 MATCH 只是过滤返回结果SCAN 仍然要遍历整个 keyspace所以不要指望靠 pattern 减少扫描工作量。真要快只能让 key 的前缀尽量短或者按 slot 拆分扫描。6.5 已经出问题时怎么兜底如果删除脚本把实例打挂了第一个动作是停掉脚本和客户端流量让 Redis 恢复。如果只是删得慢但又必须快速腾出内存有两个兜底手段。一是把要删除的 key 改名成一个临时 key并设置很短 TTLRENAME user:session:oldkey tmp:toBeExpired:1 EXPIRE tmp:toBeExpired:1 60这样 Redis 会在后台逐步过期释放不会一下子产生删除风暴。不过这需要逐 key 操作适合少量 key 的紧急处理。二是如果整个实例的 namespace 数据就是全部数据而且可以接受全量清空可以直接FLUSHDB单机或在每个 master 上FLUSHDB集群。但 FLUSHDB 是删除当前 db 所有 key如果 namespace 只是部分数据千万不要用。这个命令同样要注意在集群下逐个节点执行。我个人的习惯是把批量删除 namespace拆成三个问题数量有多大、能承受多久的延迟抖动、是否能接受分批慢慢删。这三个问题想清楚了方案自然就出来了。清理这件事慢就是快——多花 10 分钟评估比半夜被电话叫起来处理事故划算得多。希望这篇能帮你在下次面对 Redis 批量删除时少踩几个坑。