1. 先理解namespace在Redis里到底是什么前阵子帮一个业务团队清理缓存他们的订单系统迭代了好几个版本Redis里堆了几百万个带order:temp:前缀的key占了快8个G的内存。同事的第一反应是“那我把这个前缀下的数据都删了不就行了吗一条命令的事。”然后他在命令行里翻了半天才发现根本没有这种命令。DEL只能一个key一个key地删Redis也不会因为你的key带了冒号就把它们当成“一个namespace”来统一管理。这个现象背后其实藏着一个挺核心的设计逻辑值得先掰扯清楚。1.1 冒号前缀只是约定不是层级结构很多人第一次接触Redis的时候看到user:123:profile、order:2024:89757这种key直觉会以为Redis的数据结构像文件系统一样分目录、分层级前缀就是“文件夹”。这是最大的误解。Redis的keyspace本质上就是一张巨大的哈希表key是一个扁平的字符串中间有没有冒号、有没有斜杠对Redis来说完全无所谓。冒号分隔只是社区约定俗成的命名规范目的是让人类在查看和排查时能快速识别key归属哪个业务模块Redis本身不会因为user:就把这些key放进一个独立的“空间”。明白这一点你就能理解为什么Redis官方一直没有提供“按前缀删除”这种命令。在keyspace层面user:123和order:456是平级的两个独立条目系统根本不知道“namespace”这个概念。开发者在设计一个数据存储时要在大批量匹配删除这种低频需求、和全库扫描带来的性能风险之间做取舍明显后者更危险所以Redis选择不做。1.2 为什么Redis不提供“按前缀删除”的原生命令有人可能会说那我在服务端遍历一下、匹配一下、删掉不就行了问题是这需要Redis内部持有全量key的迭代能力而且在实际执行删除前必须先做匹配。如果key数量很大这个匹配过程本身就很容易把单线程的Redis卡住。Redis的核心处理模型是单线程事件循环所有命令在同一个线程里顺序执行。你再看看KEYS user:*这个命令它的实现方式就是遍历整个keyspace逐个比对字符串前缀。在本地测试有个几百个key的实例上跑毫秒级返回体感没问题。但一旦key数量到百万级、千万级KEYS会让Redis在几秒甚至十几秒内无法处理任何其他请求线上业务直接超时报警。这就是“批量删除namespace数据”这个需求中最容易踩的第一道坑你以为输入一条命令就完事实际上是把整个服务拖入不可用状态。真正安全的做法是采用增量迭代的方式一次拿一小批key处理完再拿下一批让Redis在每一轮之间都能喘口气正常处理业务请求。2. 为什么必须绕过KEYSSCAN游标遍历的安全逻辑确定了不能用KEYS之后你会找到SCAN命令。它和KEYS的目的一样也是遍历key但实现思路完全不同——它一次只返回一小部分数据而且整个遍历过程不需要在服务端维护一个大状态。这是批量删除namespace数据的基础能力理解它的内部逻辑你才知道怎么用才安全。2.1 KEYS一条命令扫全库单线程Redis会被卡死我们先看一个具体的对比数字。假设线上Redis有500万个key执行redis-cli KEYS order:temp:*这条命令会从哈希表的第一个槽位开始把所有key全部扫描一遍过程中还会逐个做字符串匹配。Redis是单线程的这期间所有读写命令全部排队等待包括你线上正在跑的GET、SET、INCR。我见过最夸张的一次是这个命令跑了11秒期间整个缓存服务形同瘫痪依赖这个Redis的所有接口全部超时。KEYS的问题是它把“匹配”和“返回”做成了一锤子买卖必须全库扫完才能返回结果。在低峰期key数量少的时候能跑在核心生产环境就是事故发生器。2.2 SCAN的原理与COUNT的真实含义SCAN命令的工作方式和KEYS完全不同。它基于游标cursor迭代每次调用返回两个值一个是下一页的游标一个是这一轮拿到的key列表。你拿着返回的游标继续调用直到游标回到0遍历就完成了。整个过程Redis不锁库每次扫描一部分哈希槽位后就立刻返回其他命令可以穿插执行不会长时间阻塞。这里有个非常容易误解的参数COUNT。redis-cli --scan --pattern order:temp:* --count 1000COUNT不是“一次返回1000个key”的意思。它表示的是每次迭代要扫描的哈希槽位数量是一个参考值Redis不保证严格按这个数来实际返回的key数量可以低于也可以高于这个值。如果你key分布得稀疏可能扫了1000个槽位才返回几个key如果分布集中这1000个槽位里可能有几千个key。所以如果你在循环里依赖COUNT来控制单批数量逻辑上是不严谨的更靠谱的做法是在拿到返回的key列表后自己控制每次处理多少个。另外要注意游标的值并不是单调递增的返回的游标可能比上一次小也可能在某个阶段变大。你不能假设游标是“页码”它的内部实现是哈希槽位的位图扫描中间会跳过已经扫过的部分。所以唯一正确的结束条件只有一个返回值是0。2.3 MATCH模式的glob语法边界SCAN的匹配模式用的是glob风格不是正则表达式。这是很多人踩坑的地方写惯了^order、order.*这种正则语法直接搬到SCAN里发现匹配结果不对或者压根匹配不到。glob支持的三个关键符号是符号含义示例*任意长度字符order:*匹配所有以order:开头的key?任意单个字符order:?匹配order:1但不匹配order:12[abc]括号内任意字符order:[12]匹配order:1和order:2所以如果你要匹配order:temp:下面所有的key写order:temp:*就行。注意glob和正则的差异在于*在前缀之外还可以出现在中间比如order:*:temp:*也能用。但如果你想精确控制“键名以X开头且长度至少为N”glob就比较别扭得配合客户端过滤。提示在写匹配模式前先在测试环境用小批量验证确认模式正确后再放到生产执行不然很容易出现你以为删干净了实际还有残留key的情况。3. 三种可落地的批量删除方案按场景选理解了SCAN的基本逻辑接下来就是真正的核心问题怎么组合SCAN和DEL高效又安全地删除一个namespace下的所有数据。我整理了三种我实际用过的方案分别适应不同场景你可以按自己的情况选。3.1 方案Aredis-cli管道组合一次性清理最快如果你的Redis是单机版或单分片且Redis服务器性能余量充足最简单粗暴的方式是用redis-cli自身的模式redis-cli --scan --pattern order:temp:* | xargs -L 100 redis-cli DEL这个命令的含义是--scan用SCAN游标模式遍历匹配的key每拿到一批就传给xargs-L 100表示每个redis-cli进程处理100个key调DEL删除。实际跑的时候你会发现一个性能问题每100个key就要新起一个redis-cli进程如果你要删100万个key就要起1万个进程光是进程创建的开销就很大整体速度并不理想。更好的写法是用管道批量传递redis-cli --scan --pattern order:temp:* | xargs redis-cli DEL去掉-L 100之后xargs会尽量把能拼的参数一次性传给一个redis-cli进程。但这个写法有个隐藏风险参数长度受系统ARG_MAX限制如果一次性给几千个key还好再多就可能触发“参数列表过长”错误。稳妥起见可以限定每次传参数量redis-cli --scan --pattern order:temp:* | xargs -n 1000 redis-cli DEL-n 1000是“每条命令最多1000个参数”比-L 100合理的地方在于不会因为key里有换行符而切分错误-L是按行切分如果key本身包含换行会出问题虽然常规命名很少遇到。这个方案适合临时清理、一次性操作、Redis压力不大、不需要统计精确删除数量。3.2 方案BLua脚本分批DEL/UNLINK可控且可统计当你需要更精细的控制——比如限制删除速率、统计删除了多少key、或者你想在删除的同时做前置校验——用Lua脚本会舒服得多。Redis支持在服务端执行Lua脚本脚本内部可以调用SCAN和DEL而且整个脚本期间Redis不会执行其他命令注意这点所以脚本内部一定不能写死循环否则同样会阻塞。我常用的脚本长这样保存为batch_del.lualocal cursor 0 local pattern ARGV[1] local limit tonumber(ARGV[2]) local count 0 repeat local result redis.call(SCAN, cursor, MATCH, pattern, COUNT, 500) cursor result[1] local keys result[2] if #keys 0 then count count #keys redis.call(DEL, unpack(keys)) end until cursor 0 or count limit return count执行方式redis-cli --eval batch_del.lua order:temp:* 100000脚本的逻辑是每轮SCAN 500个槽位拿到key列表后统一DEL累计删除数量达到limit就提前退出。这里的limit参数很关键它保证脚本在单位时间内最多删除这么多key避免一次删太多导致延迟波动。如果你要删的数据有100万可以循环执行这个脚本多次每次删5万到10万中间间隔几秒对线上基本无感。关于unpack(keys)有一点要注意如果一批key数量过多Lua脚本传给DEL的参数个数也会太多。COUNT 500时基本安全但是如果你设了COUNT 5000而且这个哈希槽位里的key很密集可能导致单次DEL参数过多。稳妥的做法是拿到keys之后显式分批删除。改进版本local cursor 0 local pattern ARGV[1] local limit tonumber(ARGV[2]) local total 0 repeat local result redis.call(SCAN, cursor, MATCH, pattern, COUNT, 200) cursor result[1] local keys result[2] if #keys 0 then for i 1, #keys, 500 do local batch {} for j i, math.min(i 499, #keys) do table.insert(batch, keys[j]) end total total #batch redis.call(UNLINK, unpack(batch)) end end until cursor 0 or total limit return total这个版本里我顺手把DEL换成了UNLINK后面我会专门说为什么推荐这么做。脚本每轮最多删500个key遍历完或者达到上限就退出不会长时间占用事件循环。这个方案适合数据量大、需要限速、需要精确统计、希望一个命令完成一批删除。3.3 方案C集群下的正确操作姿势Redis集群版相比单机版多了一层复杂性key是按哈希槽分布在多个主节点上的。不同节点上的key各自独立你不可能在一个节点上扫描全部。如果你直接用非集群模式的redis-cli连其中一个节点跑SCAN只能扫到该节点负责的那部分key别的节点上的数据管不到。处理方式有两种。第一种用redis-cli -c的集群模式redis-cli -c -p 7000 --scan --pattern order:temp:* --count 500集群模式下--scan会向每个主节点发起SCAN然后把所有节点的结果汇总返回。实测在3主3从的集群里跑能拿到全量匹配key。配合DEL时同样要用-c模式redis-cli -c --scan --pattern order:temp:* | xargs -n 500 redis-cli -c DEL-c模式下redis-cli知道某个key属于哪个槽位会自动跳转到对应节点执行命令不需要手工指定节点。第二种如果你已经在用Lua脚本方式那就需要逐节点执行。先获取所有主节点的地址redis-cli -c -p 7000 cluster nodes | grep master然后对每个主节点分别执行一次Lua脚本。这个方式的好处是不同节点可以分开控制节奏比如先删节点A再删节点B可以错峰。缺点是命令串行起来稍微繁琐。集群下还有一个值得注意的细节如果你的namespace前缀对应到不同哈希槽而这些槽恰好又分布在不同节点上那么批量删除天然就是跨节点的不要指望在一个脚本里原子完成所有删除。Redis的事务和Lua脚本都只能在单节点范围内保证原子性跨节点操作本身就没有强一致保证。3.4 方案DRENAMEEXPIRE的“软删除”思路有时候你其实不是真的想立刻物理删除数据而是想让某个namespace的数据“快速消失但万一出问题还能恢复”。这种场景我会用软删除。思路很简单把namespace下的key改名为另一个前缀然后统一设置一个很短的过期时间。redis-cli --scan --pattern order:temp:* | while read key; do newkeyto_expire:${key} redis-cli RENAME $key $newkey redis-cli EXPIRE $newkey 60 done改名前业务读的是order:temp:*改名后这些key立刻不可见了因为业务不再读to_expire:前缀但数据还会在Redis里存活60秒由Redis的过期机制在后台逐步回收。这60秒足够你观察线上有没有异常如果发现误删应用可以用RENAME再把key改回去。这个方案有三个前提你要心里有数一是key数量较大时逐条RENAME也不快二是过期回收和主动DEL一样会消耗CPU只不过分散在后台线程三是这条命令本身也是逐条执行的没有用管道优化只适合清理几百到几千个key的场景几百万个key就老实回到方案B。4. 删除过程中的坑与边界处理批量删除namespace数据看起来是“一行命令”的活但实际操作中我踩过的坑、观察到的风险一点都不比写业务代码少。这一节是我最想分享的部分。4.1 尽量用UNLINK而不是DELRedis 4.0之后提供了UNLINK命令。从调用方来看UNLINK的用法和DEL几乎完全一样也是删除一个或多个key但两者底层的处理逻辑有本质区别。DEL是同步删除命令执行时立即释放key占用的内存。如果这个key是一个包含几百万元素的hash或者list释放内存本身也是一个耗时过程会让Redis主线程卡顿。这在大key场景下特别危险一个DEL就可能引发几百毫秒的延迟毛刺。UNLINK是异步删除命令执行时先把key从keyspace中移除然后将内存回收工作放到后台线程慢慢做主线程立即返回。对于大key其释放成本就被转移出去了不会拖累主循环。所以我在写批量删除的Lua脚本时一律用UNLINK而不是DEL。尤其是删除大量key的场景比如几万个几十万个小keyDEL和UNLINK差异还不太明显但如果这些key里有大value集合DEL的阻塞风险就是实打实的。你可以在删除前先用DEBUG OBJECT key看看serializedlength如果超过几百KB甚至几MB尽量别用DEL。4.2 “边删边写”是最大的业务风险这是我在实际项目里遇到的最隐蔽的问题。运维同学收到需求清理某个不用的缓存前缀跑完批量删除脚本内存确实降下来了但第二天业务方反馈“缓存命中率暴跌”。排查后发现某个线上接口还在持续不断地往这个前缀写入新key删除操作只是暂时缓解了内存压力新写入的数据立刻又把内存推上去了。所以批量删除namespace数据之前第一件事不是写脚本而是先确认这个namespace是否真的已经没有写入方。常见做法是去业务代码里全局搜索这个前缀的写入位置检查是否有定时任务、消息队列消费者还在往这个key写入如果短时间无法确认可以先改造写入侧代码加一个开关控制写入功能确认观察一段时间没有写入后再执行批量删除。我个人的操作顺序是先改代码关写入 → 观察10分钟确认无新key → 统计当前key数量 → 执行批量删除 → 删除后再次统计验证归零。这个流程看着麻烦但比出了事故再回滚成本低得多。4.3 删除期间观察哪些指标批量删除运行期间Redis的实时状态是必须要盯的。我用得比较多的是INFO命令的几个字段以及 redis-cli 自带的监控能力。第一看instantaneous_ops_per_sec瞬时QPS。删除前记录一个基线值删除过程中如果这个值不是升高而是骤降说明主线程正在被删除操作拖累正常的业务命令都排不上队。这时候应该降低删除速率减少每轮SCAN的COUNT值或者在每轮删除之间增加间隔。第二看used_memory_rss和used_memory。used_memory下降通常滞后于key删除因为内存释放有延迟。如果删完后used_memory_rss还是居高不下说明内存碎片率上来了需要关注后续的碎片整理。第三看慢日志。redis-cli SLOWLOG GET 20如果在删除期间慢日志里频繁出现DEL或UNLINK本身条目并且耗时明显高于平时说明这批key里有大key或者数量级偏大触发了耗时操作。结合具体key去分析原因必要时降低批次规模。4.4 清理完之后的碎片与内存回收问题批量删除几百万个key后一个常见现象是内存占用并没有像预期那样下降。假设删除了3G的数据结果used_memory只降了1G另一部分成了内存碎片。这是因为Redis的内存分配器默认jemalloc在释放小块内存时并不一定会把内存立即归还给操作系统内存可能以碎片的形式留在进程空间里。大量key被删除后原来分配的一连串小内存块释放了但它们之间穿插着其他仍在使用中的内存块无法合并成大块连续内存交还OS就形成了碎片。清理数据后观察两个指标mem_fragmentation_ratio和used_memory。如果碎片率大于1.5且内存下降幅度明显小于预期可以考虑触发碎片整理。Redis 4.0以上版本开启了activedefrag yes才会自动整理默认是关闭的。手动整理在较新版本中可以使用redis-cli memory purge不过我要提醒一句MEMORY PURGE会做的是一次完整的内存整理操作量大时同样可能造成主线程阻塞。我一般不会在业务高峰期执行而是等到低峰期或者分片节点轮流操作而且执行前先看碎片率确属偏高才动手否则纯属给自己惹麻烦。上次那个订单系统缓存清理我的最终执行方案是先在代码侧确认了order:temp:前缀已经没有任何写入方然后统计了存量key数量约320万。接着用改进版Lua脚本、每次限删5万个key、间隔3秒跑一轮全程用UNLINK而不是DEL跑了大约20轮全部清完。整个过程线上QPS很稳定慢日志里没有任何异常内存降了6.5G碎片率稍微涨到1.4但还没到需要手动整理的程度。其实批量删除namespace数据这件事最关键的从来不是“怎么删得快”而是“怎么删得不出事”。SCAN加UNLINK的组合加上限速、统计、验证三步基本能覆盖绝大多数生产清理场景。你如果手头也有类似需求建议先从确认写入方开始别急着上来就跑脚本。