Redis Lua脚本中KEYS与SCAN的差异及缓存批量删除最佳实践
我用Redis 3.2.12作为Spring Boot应用的缓存,需要匹配多个键模式并删除对应缓存,于是用Lua脚本实现了基于SCAN命令的逻辑,脚本如下:
local cursor='0'; local keysVar = {}; repeat local scanResult = redis.call('SCAN', cursor, 'MATCH', ARGV[1], 'COUNT', 100); local keys = scanResult[2]; for i = 1, #keys do keysVar[i] = keys[i]; end; cursor = scanResult[1]; until cursor == '0'; redis.replicate_commands() redis.call('DEL', unpack(keysVar)); return keysVar ;
我知道SCAN是为了解决KEYS的阻塞问题,但Redis保证Lua脚本原子执行,执行期间会阻塞所有服务器活动。既然两者时间复杂度都是O(N),那在Lua脚本里用KEYS和SCAN到底有啥差异?对比上面的脚本和下面这个基于KEYS的脚本:
return redis.call('DEL', 'defaultKey', unpack(redis.call('KEYS', @keypattern)))
另外还有几个疑问:
- 为什么说KEYS是确定性的?从库执行KEYS时,返回的键数量难道不会变化吗?
- SCAN被认为非确定性是因为主从返回结果可能不同,那KEYS难道不会出现这种情况吗?
- SCAN用游标,为啥会重复返回同一个键?
最后,我现在通过RedisTemplate遍历多个模式执行Lua脚本的代码如下:
private void clearCache(List<String> patterns) { Resource scriptSource = new ClassPathResource("cleanup.lua"); RedisScript<String> redisScript = RedisScript.of(scriptSource, String.class); patterns.forEach(pattern -> { redisTemplate.execute(redisScript, Collections.emptyList(), pattern); }); }
请问这种方式是否正确?有没有推荐的实现方案?
一、Lua脚本中KEYS与SCAN的核心差异
虽然两者时间复杂度都是O(N),但核心差异体现在内存占用和执行风险上:
- 内存压力:KEYS会一次性把所有匹配键加载到Redis内存中,若匹配键数量极大(比如十万级),会瞬间占用大量内存,可能触发Redis内存淘汰甚至OOM;SCAN是分批读取键,内存占用更平缓,不会出现瞬间峰值。
- 执行失败风险:Redis对单个命令返回的结果大小有限制,KEYS如果匹配太多键,可能超过限制导致命令直接失败;SCAN分批处理的方式能规避这个问题。
- 阻塞感知:虽然Lua脚本是原子执行的,整个过程都会阻塞Redis,但KEYS的单次遍历耗时更长,容易引发明显的服务卡顿;SCAN每次迭代耗时短,即使总耗时和KEYS相近,内存压力的降低也能减少潜在风险。
二、关于确定性与重复返回的疑问
1. 为什么KEYS是确定性的?
KEYS是一次性遍历命令执行瞬间的整个键空间,由于Redis是单线程模型,命令执行期间不会有其他客户端修改键空间,所以返回的结果是完全确定的——它精准反映了命令启动时的键状态。
至于从库执行KEYS时结果变化,是因为主从复制是异步的,从库数据存在延迟,不同时间点执行KEYS结果自然不同,但单次KEYS命令的执行结果是确定的。
2. KEYS会不会出现主从结果不同的情况?
会,但这和SCAN的非确定性不是一回事。SCAN的非确定性是指同一游标遍历过程中,即使键空间没有变化,多次迭代也可能返回重复键或不同顺序的结果;而KEYS的结果是绝对确定的——只要键空间在命令执行期间不变,每次执行KEYS返回的内容完全一致。主从结果不同是数据同步延迟导致的,所有Redis命令都可能遇到这个问题,不是SCAN独有的。
3. SCAN为什么会重复返回同一键?
SCAN的遍历基于Redis内部的哈希表结构:当哈希表扩容/缩容时,键的哈希槽会重新分配,如果遍历还没完成,同一个键可能被分配到不同槽位,导致后续迭代再次返回该键。另外,即使哈希表结构不变,SCAN的遍历算法也可能因COUNT设置过小等边界情况出现重复返回,但Redis保证游标回到0时,所有键至少被返回一次。
三、当前实现的问题与推荐方案
当前实现的问题
- 内存风险:脚本会把所有匹配键收集到
keysVar后一次性删除,若匹配键数量极大,会占用大量Redis内存,甚至触发Lua脚本的内存限制导致执行失败。 - 过度阻塞:整个遍历+删除过程是原子的,若匹配键很多,会长时间阻塞Redis,影响其他业务请求。
- 效率低下:每个模式单独执行一次Lua脚本,相当于多次遍历键空间,整体效率不高。
推荐方案
方案1:优化Lua脚本,分批删除
修改脚本,在每次SCAN迭代时就删除当前批次的键,避免一次性收集所有键,降低内存压力:
local cursor = '0' local deleted_count = 0 repeat local scan_result = redis.call('SCAN', cursor, 'MATCH', ARGV[1], 'COUNT', 1000) local keys = scan_result[2] if #keys > 0 then deleted_count = deleted_count + redis.call('DEL', unpack(keys)) end cursor = scan_result[1] until cursor == '0' return deleted_count
方案2:客户端分批处理,放弃强原子性
如果业务可以接受删除过程中少量的键不一致(比如删除期间新增的匹配键不被删除),可以在客户端用SCAN遍历,分批执行DEL,避免长时间阻塞Redis:
private void clearCache(List<String> patterns) { for (String pattern : patterns) { ScanOptions scanOptions = ScanOptions.scanOptions().match(pattern).count(1000).build(); // 用粘性连接减少连接开销 Cursor<String> cursor = redisTemplate.executeWithStickyConnection(connection -> connection.scan(scanOptions)); List<String> batch = new ArrayList<>(1000); while (cursor.hasNext()) { batch.add(cursor.next()); if (batch.size() >= 1000) { redisTemplate.delete(batch); batch.clear(); } } // 处理剩余的键 if (!batch.isEmpty()) { redisTemplate.delete(batch); } cursor.close(); } }
这种方式的优势是不会长时间阻塞Redis,内存占用小;缺点是无法保证删除过程的原子性,若有新键在删除期间生成,不会被清理。
方案3:借助缓存设计减少手动删除
如果业务允许,给缓存键设置合理的过期时间,减少手动删除的需求;或者用哈希表统一存储同一类缓存,直接删除整个哈希表(DEL hashKey),比遍历键空间高效得多。
内容的提问来源于stack exchange,提问作者Mohendra Amatya

