Redis里用Lua写点模糊匹配的东西,实操分享和技巧探讨
- 问答
- 2026-01-14 11:50:10
- 2
前段时间我在项目里遇到了一个需求,需要批量删除Redis中符合特定模式的键,我的键名可能是 user:123:profile, user:456:cart, order:789 等等,我现在想删除所有以 user:123: 开头的键,最直接的想法就是用 KEYS 命令,就像在Redis命令行里输入 KEYS user:123:* 一样,它会返回所有匹配的键。
这里有个大问题,根据Redis官方文档(来源:Redis官方文档对KEYS命令的说明),KEYS 命令在生产环境需要非常小心地使用,因为Redis是单线程的,KEYS 命令会遍历整个数据库的所有键,如果数据库里的键非常多,这个操作就会阻塞其他所有命令,导致服务卡顿甚至短暂不可用,这肯定不行。
那怎么办呢?这时候就得请出Lua脚本了,Redis支持用Lua脚本执行一系列命令,而且最关键的是,Lua脚本在执行时是原子的,也就是说,在执行脚本的过程中,不会被其他命令打断,这为我们安全地进行批量操作提供了可能。
我的第一个思路是:在Lua脚本里调用 KEYS 命令,获取到所有匹配的键,然后循环遍历这些键,一个一个地删除,脚本写出来大概是这个样子:
local keys = redis.call('KEYS', ARGV[1])
for i, key in ipairs(keys) do
redis.call('DEL', key)
end
return #keys
这个脚本接受一个参数,就是模糊匹配的模式(user:123:*),它先通过 KEYS 命令找到所有键,然后用循环删除它们,最后返回被删除键的数量。
看起来很美,对吧?但实际上,这个方案有个和直接使用 KEYS 命令一样的致命缺陷,虽然Lua脚本本身是原子的,但 KEYS 命令在数据库键数量巨大时,其内部的遍历操作本身就是一个耗时的、会阻塞Redis的单线程,你把一个阻塞命令放在脚本里,它依然会阻塞整个服务器,所以这个方案只是“原子地阻塞”,并不是我们想要的“非阻塞”。
那有没有不阻塞的方法呢?有,那就是用 SCAN 命令。SCAN 命令的好处是它是增量式的、迭代的,它不会一次性返回所有结果,而是每次只返回一小部分和一个游标(cursor),你可以根据这个游标再次调用 SCAN,直到游标变为0,表示迭代完成,这样就把一个大的阻塞操作,拆分成很多个小的、几乎不阻塞的操作。
SCAN 命令的这种特性又和Lua脚本的原子性、不间断执行的要求产生了矛盾,我们不能在Lua脚本里写一个 while 循环,不停地调用 SCAN 直到结束,因为如果匹配的键非常多,这个Lua脚本本身执行时间会很长,同样会阻塞服务器,Redis对Lua脚本的执行时间是有严格限制的,太长了会报错。
正确的做法不是在Lua脚本内部完成整个模糊匹配和删除的过程,而是把“扫描”和“删除”分开,我们需要借助外部程序(比如你用Python、Java或Go写的应用代码)来负责循环调用 SCAN,而Lua脚本只负责“批量删除”这个原子操作。
具体流程是这样的:
- 在应用代码中:使用
SCAN命令(或者更好用的SSCAN,HSCAN,ZSCAN用于集合、哈希、有序集合)分批地、非阻塞地获取匹配模式的键,每次获取一批(比如1000个)。 - 仍然在应用代码中:当积累到一定数量的键(比如还是1000个,或者这一批扫完就处理),就调用一个写好的Lua脚本。
- 在Lua脚本中:接收一个键的列表作为参数,然后原子性地删除所有这些键。
这样,Lua脚本的角色就从“扫描+删除”变成了纯粹的“批量删除器”,因为传入的键列表是已知的、有限的,所以脚本的执行速度会非常快,不会阻塞Redis,而耗时的扫描工作由客户端的 SCAN 循环承担,这个循环是非阻塞的,对Redis服务的影响微乎其微。
下面是一个这种思路下的Lua脚本示例,它非常简单:
-- 脚本接受可变参数,都是要删除的key
for i, key in ipairs(KEYS) do
redis.call('DEL', key)
end
return #KEYS
注意,这里用的是 KEYS 数组,而不是 ARGV,在Redis Lua脚本中,KEYS 和 ARGV 是分开传递的,这主要是为了在Redis集群模式下能正确路由命令,按照规范,所有需要操作的键都应该通过 KEYS 参数传递,其他辅助参数通过 ARGV 传递。
那么在调用时(以Python伪代码为例),就是这样:
# 第一步:使用SCAN迭代获取所有匹配 ‘user:123:*’ 的键
cursor = 0
all_keys = []
while True:
cursor, keys = redis_client.scan(cursor=cursor, match='user:123:*', count=100)
all_keys.extend(keys)
if cursor == 0:
break
# 第二步:分批处理,比如每100个键调用一次Lua脚本
batch_size = 100
for i in range(0, len(all_keys), batch_size):
batch_keys = all_keys[i:i+batch_size]
# 这里调用上面定义的Lua脚本,将batch_keys作为KEYS参数传入
deleted_count = redis_client.eval(lua_script_above, len(batch_keys), *batch_keys)
print(f"Deleted {deleted_count} keys in this batch.")
一些额外的技巧探讨:
- 处理大键:如果要删除的键对应的值非常大(比如一个包含几百万元素的集合),即使只删除一个键,
DEL命令也可能耗时较长,对于这种情况,可以考虑使用UNLINK命令替代DEL。UNLINK会在后台异步删除键,不会阻塞当前线程,对服务更友好,只需把Lua脚本里的DEL改成UNLINK即可。 - 避免内存暴涨:在上面的Python例子中,我先用
SCAN把所有键都搜集到内存列表all_keys里,再分批,如果匹配的键有几百万个,这个列表会非常大,可能撑爆客户端内存,更优雅的做法是扫出一批键(比如1000个),立刻传给Lua脚本删除,然后清空列表,再扫下一批,这样客户端内存中最多只保留一批键的数据。 - 模式匹配的局限性:Redis的模糊匹配模式很简单,主要就是 (任意多个字符)、(单个字符)、
[abc](匹配括号内任一字符),它不支持更复杂的正则表达式,如果你的匹配逻辑非常复杂,可能需要在客户端扫描出前缀匹配的键后,自己在客户端代码里用正则再做一次过滤,然后再调用删除脚本。
总结一下核心思想:在Redis中做模糊匹配的批量操作,要利用 SCAN 的非阻塞特性在客户端进行迭代,将任务拆解;然后利用Lua脚本的原子性来保证每一批删除操作的安全和高效。 千万不要试图在Lua脚本内部通过循环执行 KEYS 或 SCAN 来完成整个流程。

本文由邝冷亦于2026-01-14发表在笙亿网络策划,如有疑问,请联系我们。
本文链接:https://haoid.cn/wenda/80534.html
