在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:1
, key: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
),利用{}
强制哈希到同一个槽位,配合SCAN
或HSCAN
(哈希类型的SCAN)高效查询; - Redis Module:对于超大规模数据(如10亿级key),可使用Redis Search(RediSearch)等模块,支持更高效的索引查询;
- 定时任务分片:将全量扫描拆分为多个子任务(如按小时分片),避免一次性加载所有key。
六、总结:一字之差,天壤之别
KEYS *
和SCAN
的核心区别,本质上是阻塞式遍历与增量式迭代的设计理念之争。在Redis这种单线程架构中,任何长时间占用主线程的操作都可能引发级联故障——而SCAN
通过牺牲一定的延迟(分批次处理),换来了系统的整体稳定性。
作为开发者,我们需要牢记:Redis的性能优势源于单线程的高效执行,任何可能阻塞主线程的操作都应尽量避免。下次需要遍历key时,记得用Java的SCAN
实现替代KEYS *
——这可能是你保护Redis稳定性的关键一步。
(全文完)