Redis提供了KEYS和SCAN两个命令来遍历键空间,但两者的适用场景截然不同。KEYS命令简单直接,一条命令返回所有匹配的键,然而它的代价是遍历整个键空间期间Redis主线程被完全占用,其他请求只能排队等待。SCAN则是Redis 2.8引入的增量式迭代命令,通过游标机制把一次遍历拆解成多次小步操作,每次只占用极短的处理时间。本文将从原理、用法、参数调优和实际集成几个方面,讲清楚为什么生产环境必须用SCAN替代KEYS。

KEYS命令为什么会阻塞Redis
Redis的核心工作模型是单线程处理命令(网络IO在6.0后虽有多线程,但命令执行仍是串行的)。当执行KEYS pattern时,Redis必须从头到尾遍历整个字典结构,把所有匹配的键收集到一个列表里,最后一次性返回。这个过程中,任何其他客户端的请求都不会被处理。
假设Redis中存了500万个键,KEYS的执行时间可能达到数百毫秒甚至数秒。在这段时间内,业务请求的超时、连接池的耗尽、上游服务的雪崩都可能接连发生。更危险的是,如果客户端没设置合理的超时时间,大量的KEYS调用堆积在一起,Redis会彻底失去响应。这正是许多线上事故的直接诱因:一次运维脚本里的KEYS *,让整个依赖Redis的服务集群集体抖动。
有人会问,用KEYS prefix*缩小匹配范围是否可行?答案是不行。KEYS的时间复杂度永远是O(N),这里的N是整个键空间的总键数,而不是匹配结果的数量。因为Redis必须逐个检查每一个键是否匹配模式,哪怕最终只返回10个键,遍历成本依然是全量的。
SCAN的增量式迭代原理
SCAN命令的语法是SCAN cursor [MATCH pattern] [COUNT count] [TYPE type]。它的核心思想是分步遍历:每次调用SCAN时传入一个游标,Redis根据游标定位到哈希槽的某个桶,扫描一定数量后返回新游标和本次匹配到的键。当返回的游标为0时,表示遍历完成。
redis-cli 127.0.0.1:6379> SCAN 0 MATCH user:* COUNT 100 1) "35" # 下一次扫描要使用的游标 2) 1) "user:1001" # 本次匹配到的键 2) "user:2048" 127.0.0.1:6379> SCAN 35 MATCH user:* COUNT 100 1) "0" # 游标为0,遍历结束 2) 1) "user:5501"
这里需要澄清一个常见误解:COUNT并不是指定返回多少个键。它只是每次调用需要检查的桶数量的提示值(hint),默认为10。Redis实际扫描的桶数可能与COUNT不完全一致,返回的键数量也可能多于或少于COUNT。COUNT设置得越大,单次扫描的键越多,遍历总轮数越少,但单次耗时也越长;反之则更平滑。生产环境一般建议设置在几百到几千之间,通过压测找到业务可接受的平衡点。
SCAN的遍历顺序并不是按键的字典序,而是按Redis内部哈希表的桶反向二进制迭代顺序进行的。这个设计很巧妙:当Redis对哈希表进行扩容或缩容时,反向二进制迭代保证已遍历的桶在扩容后不会被重复遍历(保证遍历完整性的同时尽量减少重复)。因此SCAN提供的是一套弱保证(guarantees):遍历开始到结束期间一直存在的键一定会被返回;遍历期间被删除的键可能返回也可能不返回;遍历期间新增或修改的键可能返回也可能不返回;同一个键可能被返回多次。
这意味着SCAN适合做“统计”和“清理”这类允许少量误差的任务,比如统计某个前缀的键数量、批量删除过期数据。如果业务要求严格不重不漏,就不能只依赖SCAN本身,需要在业务层对返回结果做去重,或者在遍历期间暂停写入。
典型场景的代码实践
最常见的需求是批量删除某个前缀的键。Redis本身不提供按前缀批量删除命令,正确的做法是SCAN加UNLINK(异步删除,避免大键删除阻塞)。以下是一个Python示例:
import redis
client = redis.Redis(host='127.0.0.1', port=6379, decode_responses=True)
def scan_and_delete(prefix, count=500):
cursor = 0
deleted = 0
while True:
cursor, keys = client.scan(cursor, match=prefix + '*', count=count)
if keys:
# UNLINK异步删除,避免DEL阻塞主线程
deleted += client.unlink(*keys)
if cursor == 0:
break
return deleted
print('已删除键数量:', scan_and_delete('temp:session:*'))在Java的Spring Boot项目中,可以借助RedisTemplate实现同样的逻辑。需要注意scan方法的返回结果中同时包含游标和键列表:
@Service
public class RedisScanService {
@Autowired
private StringRedisTemplate redisTemplate;
public List<String> scanKeys(String pattern) {
List<String> keys = new ArrayList<>();
redisTemplate.execute((RedisConnection connection) -> {
Cursor<byte[]> cursor = connection.scan(
ScanOptions.scanOptions().match(pattern).count(500).build());
while (cursor.hasNext()) {
keys.add(new String(cursor.next(), StandardCharsets.UTF_8));
}
return null;
});
return keys;
}
}集群环境与常见注意事项
在Redis Cluster环境中,SCAN只能遍历单个节点上的键。由于键被分散到16384个哈希槽、分布在不同主节点上,客户端必须对每个主节点分别执行SCAN才能覆盖完整的键空间。使用Jedis或Lettuce的集群客户端时,可以遍历getClusterNodes()返回的所有主节点,逐个执行扫描。
还有几个容易踩坑的点需要注意。第一,MATCH模式只支持glob风格通配符(如h?llo、h[ae]llo),不支持正则表达式。第二,如果键数量极大且网络往返成为瓶颈,可以适当调大COUNT减少交互次数,或者在客户端使用pipeline批量提交。第三,SCAN系列的SSCAN、HSCAN、ZSCAN用于遍历集合、哈希、有序集合内部的元素,游标语义相同,但MATCH匹配的是元素而不是键名。第四,遍历过程中如果遇到正在做rehash的哈希表,出现少量重复键是正常现象,业务侧做幂等处理即可。
总结一下,KEYS只适合在本地开发或键数量极少的场景下使用,生产环境的任何键空间遍历都应该用SCAN完成。只要理解了游标机制和弱保证语义,合理设置COUNT参数,并在业务层处理好重复和误差,SCAN完全可以做到既高效又安全。
Redis SCANRedis KEYS键空间扫描修改时间:2026-09-09 03:06:38