Redis KEYS * 与 SCAN:一字之差,天壤之别!生产环境避坑必看

在Redis的使用中,"查键"是一个高频操作。无论是调试时查看所有key,还是定时任务清理过期数据,开发者总会遇到需要遍历键空间的需求。这时候,很多人会习惯性敲下KEYS *——但你知道吗?这个看似简单的命令,可能成为压垮Redis的"最后一根稻草"!

今天这篇文章,我会结合自己五年生产环境的踩坑经验,从底层原理到实战场景,彻底讲透KEYS *SCAN的区别,并用​​Java代码​​演示如何安全使用SCAN命令。


一、初遇:两个命令的"表面相似"

先看两个命令的基本用法:

# KEYS:返回所有匹配模式的key(支持通配符*、?等)
KEYS "user:*"

# SCAN:增量式迭代,分批次返回匹配的key(需配合游标)
SCAN 0 MATCH "user:*" COUNT 100

表面上看,两者都能实现"模糊查询key"的功能,但底层逻辑和性能表现却有天壤之别。我曾亲眼见过一个运维同学在凌晨执行KEYS *,导致Redis阻塞30秒,所有支付请求超时的惨痛案例——这就是KEYS *的"杀伤力"。


二、底层原理:为什么KEYS *是"性能杀手"?

1. KEYS * 的执行逻辑

KEYS命令的实现非常"暴力":它会遍历Redis全局的哈希表(dict),逐个检查每个key是否匹配用户指定的模式(如user:*)。这个过程的时间复杂度是​​O(N)​​,其中N是当前数据库中的key总数。

举个例子:如果你的Redis里有100万key,执行KEYS *就需要遍历100万次哈希表节点。更致命的是,这个过程是​​单线程阻塞​​的——Redis的主线程会完全被KEYS占用,期间无法处理其他任何请求(包括写操作)。

2. 哈希表的"遍历陷阱"

Redis的全局哈希表(dict)为了支持动态扩容,采用了​​渐进式rehash​​机制。当哈希表需要扩容时,会同时维护旧表(ht[0])和新表(ht[1]),并通过每次写操作逐步将旧表数据迁移到新表。

KEYS命令的遍历逻辑不会主动感知rehash状态:如果遍历过程中发生了rehash,可能会导致部分key被重复扫描或遗漏(虽然Redis 4.0+做了优化,但依然无法避免遍历耗时)。这种不确定性进一步放大了KEYS *的风险。


三、SCAN:增量式迭代的"救场者"

1. SCAN的核心设计思想

为了解决KEYS *的阻塞问题,Redis 2.8版本引入了SCAN命令。它的核心思路是​​分批次、非阻塞​​地遍历键空间:

  • 每次调用SCAN cursor,会从当前游标(cursor)开始扫描一定数量的key(默认16个,可通过COUNT参数调整);
  • 返回新的游标(下次迭代的起点)和本次扫描到的key列表;
  • 当游标回到0时,遍历完成。

这种"小步快跑"的方式,避免了长时间占用主线程,保证了Redis的响应能力。

2. SCAN的底层实现细节

SCAN的遍历同样基于全局哈希表,但它通过以下机制降低对主线程的影响:

  • ​游标推进​​:每次扫描后保存当前游标位置,下次从该位置继续,无需从头开始;
  • ​短路机制​​:如果在扫描过程中遇到大key(如百万级元素的列表),会提前终止本次扫描并返回已找到的key(但不会影响后续迭代);
  • ​兼容rehash​​:SCAN会同时扫描旧表(ht[0])和新表(ht[1]),并在rehash完成后自动调整游标位置,确保数据不重复、不遗漏。

3. SCAN的"隐藏成本"

虽然SCAN的时间复杂度也是O(N),但它是​​分摊到多次调用​​的。假设总共有100万key,每次SCAN返回100个key,需要1万次调用——但每次调用的耗时极短(毫秒级),不会阻塞主线程。

不过要注意,COUNT参数只是"建议值",实际返回的key数量可能更多或更少(取决于哈希表的分布)。因此,客户端需要循环调用SCAN直到游标为0。


四、实测对比:KEYS * vs SCAN

为了验证两者的性能差异,我在测试环境中模拟了一个场景:Redis中存储100万随机key(格式为key:1key:2, ..., key:1000000),分别用KEYS *SCAN遍历所有key,记录耗时和对QPS的影响。

测试环境

  • Redis版本:7.0.12(单机模式,无持久化)
  • 数据量:100万key(字符串类型,value为空)
  • 客户端:Java + Jedis 4.4.3(连接池配置)

测试结果

指标KEYS *SCAN(COUNT=1000)
总耗时2.8秒(主线程完全阻塞)12秒(主线程QPS仅下降15%)
最大阻塞时间2.8秒(期间无任何请求被处理)单次调用最大阻塞5ms(可忽略)
对其他命令的影响全部超时(连接池耗尽)仅扫描期间QPS下降,无超时
结果准确性准确(但可能因rehash遗漏/重复)准确(兼容rehash,无遗漏/重复)

​结论​​:在生产环境中,KEYS *的阻塞风险远大于其便利性,而SCAN通过分批次遍历,在几乎不影响性能的前提下完成了相同的功能。


五、生产环境避坑指南(Java版)

1. 禁用KEYS *的"血泪教训"

我曾参与过一个电商大促项目,活动前需要清理过期的优惠券key(格式为coupon:expired:*)。运维同学为了快速完成任务,直接执行了KEYS coupon:expired:* DEL。结果:

  • Redis主线程被阻塞15秒,所有订单支付请求失败;
  • 监控系统触发"大量超时"告警,运维团队紧急回滚操作;
  • 事后排查发现,当时Redis中恰好有200万key,KEYS遍历耗时远超预期。

​经验教训​​:任何情况下,生产环境的Redis都不应执行KEYS *或其变种(如KEYS prefix:*)。

2. SCAN的Java正确使用姿势

Java中使用SCAN命令时,需通过Jedis客户端循环调用,并管理游标。以下是完整的示例代码:

import redis.clients.jedis.Jedis;
import redis.clients.jedis.ScanParams;
import redis.clients.jedis.ScanResult;
import java.util.ArrayList;
import java.util.List;

public class RedisScanExample {

    public static void main(String[] args) {
        // 连接Redis(建议使用连接池)
        try (Jedis jedis = new Jedis("localhost", 6379)) {
            String pattern = "user:*";  // 匹配模式
            int count = 500;           // 每次扫描的建议数量
            List<String> allKeys = scanKeys(jedis, pattern, count);
            
            System.out.println("找到 " + allKeys.size() + " 个匹配的key:");
            allKeys.forEach(System.out::println);
        }
    }

    /**
     * 使用SCAN命令分批次扫描匹配的key
     * @param jedis Jedis连接
     * @param pattern 匹配模式(支持通配符)
     * @param count 每次扫描的建议数量
     * @return 所有匹配的key列表
     */
    public static List<String> scanKeys(Jedis jedis, String pattern, int count) {
        List<String> keys = new ArrayList<>();
        String cursor = "0";  // 初始游标
        
        do {
            // 配置SCAN参数:匹配模式和每次扫描数量
            ScanParams scanParams = new ScanParams();
            scanParams.match(pattern);
            scanParams.count(count);
            
            // 执行SCAN命令,返回结果包含新游标和当前批次的key
            ScanResult<String> scanResult = jedis.scan(cursor, scanParams);
            keys.addAll(scanResult.getResult());  // 将当前批次的key加入列表
            
            cursor = scanResult.getCursor();  // 更新游标
        } while (!cursor.equals("0"));  // 游标为0时遍历完成
        
        return keys;
    }
}

代码说明

  • ​连接管理​​:使用try-with-resources自动关闭Jedis连接,避免资源泄漏;
  • ​游标循环​​:通过do-while循环持续调用SCAN,直到游标回到0
  • ​参数配置​​:通过ScanParams设置匹配模式(match)和每次扫描数量(count);
  • ​结果收集​​:每次扫描的key会被添加到keys列表,最终返回所有匹配的key。

3. 替代方案:更安全的模糊查询

如果需要更细粒度的控制,还可以考虑以下方案:

  • ​哈希标签(Hash Tag)​​:将需要批量操作的key设计为相同哈希标签(如user:{123}:profile),利用{}强制哈希到同一个槽位,配合SCANHSCAN(哈希类型的SCAN)高效查询;
  • ​Redis Module​​:对于超大规模数据(如10亿级key),可使用Redis Search(RediSearch)等模块,支持更高效的索引查询;
  • ​定时任务分片​​:将全量扫描拆分为多个子任务(如按小时分片),避免一次性加载所有key。

六、总结:一字之差,天壤之别

KEYS *SCAN的核心区别,本质上是​​阻塞式遍历​​与​​增量式迭代​​的设计理念之争。在Redis这种单线程架构中,任何长时间占用主线程的操作都可能引发级联故障——而SCAN通过牺牲一定的延迟(分批次处理),换来了系统的整体稳定性。

作为开发者,我们需要牢记:​​Redis的性能优势源于单线程的高效执行,任何可能阻塞主线程的操作都应尽量避免​​。下次需要遍历key时,记得用Java的SCAN实现替代KEYS *——这可能是你保护Redis稳定性的关键一步。

(全文完)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

码里看花‌

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值