You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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时,所有键至少被返回一次。

三、当前实现的问题与推荐方案

当前实现的问题

  1. 内存风险:脚本会把所有匹配键收集到keysVar后一次性删除,若匹配键数量极大,会占用大量Redis内存,甚至触发Lua脚本的内存限制导致执行失败。
  2. 过度阻塞:整个遍历+删除过程是原子的,若匹配键很多,会长时间阻塞Redis,影响其他业务请求。
  3. 效率低下:每个模式单独执行一次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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.12 23:35:52