☰
Redis批量删除namespace:四种方案与避坑指南
2026/9/28 23:20:58 网站建设 项目流程

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. 为什么必须绕过KEYS:SCAN游标遍历的安全逻辑

确定了不能用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 1000

COUNT不是“一次返回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 方案A:redis-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 方案B:Lua脚本分批DEL/UNLINK,可控且可统计

当你需要更精细的控制——比如限制删除速率、统计删除了多少key、或者你想在删除的同时做前置校验——用Lua脚本会舒服得多。Redis支持在服务端执行Lua脚本,脚本内部可以调用SCAN和DEL,而且整个脚本期间Redis不会执行其他命令(注意这点,所以脚本内部一定不能写死循环,否则同样会阻塞)。

我常用的脚本长这样,保存为batch_del.lua:

local 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 方案D:RENAME+EXPIRE的“软删除”思路

有时候你其实不是真的想立刻物理删除数据,而是想让某个namespace的数据“快速消失,但万一出问题还能恢复”。这种场景我会用软删除。

思路很简单:把namespace下的key改名为另一个前缀,然后统一设置一个很短的过期时间。

redis-cli --scan --pattern "order:temp:*" | while read key; do newkey="to_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而不是DEL

Redis 4.0之后提供了UNLINK命令。从调用方来看,UNLINK的用法和DEL几乎完全一样,也是删除一个或多个key,但两者底层的处理逻辑有本质区别。

DEL是同步删除:命令执行时立即释放key占用的内存。如果这个key是一个包含几百万元素的hash或者list,释放内存本身也是一个耗时过程,会让Redis主线程卡顿。这在大key场景下特别危险,一个DEL就可能引发几百毫秒的延迟毛刺。

UNLINK是异步删除:命令执行时先把key从keyspace中移除,然后将内存回收工作放到后台线程慢慢做,主线程立即返回。对于大key其释放成本就被转移出去了,不会拖累主循环。

所以我在写批量删除的Lua脚本时,一律用UNLINK而不是DEL。尤其是删除大量key的场景,比如几万个几十万个小key,DEL和UNLINK差异还不太明显,但如果这些key里有大value集合,DEL的阻塞风险就是实打实的。你可以在删除前先用DEBUG OBJECT key看看serializedlength,如果超过几百KB甚至几MB,尽量别用DEL。

4.2 “边删边写”是最大的业务风险

这是我在实际项目里遇到的最隐蔽的问题。运维同学收到需求清理某个不用的缓存前缀,跑完批量删除脚本,内存确实降下来了,但第二天业务方反馈“缓存命中率暴跌”。排查后发现,某个线上接口还在持续不断地往这个前缀写入新key,删除操作只是暂时缓解了内存压力,新写入的数据立刻又把内存推上去了。

所以批量删除namespace数据之前,第一件事不是写脚本,而是先确认这个namespace是否真的已经没有写入方。常见做法是:

  1. 去业务代码里全局搜索这个前缀的写入位置;
  2. 检查是否有定时任务、消息队列消费者还在往这个key写入;
  3. 如果短时间无法确认,可以先改造写入侧代码,加一个开关控制写入功能,确认观察一段时间没有写入后,再执行批量删除。

我个人的操作顺序是:先改代码关写入 → 观察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的组合,加上限速、统计、验证三步,基本能覆盖绝大多数生产清理场景。你如果手头也有类似需求,建议先从确认写入方开始,别急着上来就跑脚本。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询