当Redis里的键越来越多,像缓存预热、批量清理、键空间统计这类需求会频繁出现。很多人第一反应是执行 KEYS *,但这个命令会一次性收集所有匹配键,键数量一旦达到几十万,Redis主线程就会被拖住,其他正常读写全部排队。SCAN命令提供了另一种思路:通过游标把全量遍历拆成多次小批量操作,每次只消耗极短时间,让Redis可以在迭代间隙继续服务其他请求。接下来会从阻塞差异、游标机制、实战代码和常见误区几个方向展开。

一、KEYS与SCAN的阻塞差异到底有多大
KEYS命令的实现并不复杂:它会遍历整个键空间,把所有匹配 pattern 的键收集到一个列表里再返回。由于Redis执行命令是单线程模型,这个遍历返回过程会一直占用主线程,处理时间与键总数成正比。如果一个实例里存了500万个键,即使每个键很短,KEYS也可能让Redis停顿数百毫秒甚至更久。对于网关层来说,几百毫秒的Redis响应延迟会直接放大到大量客户端超时。
SCAN则把工作拆散。每次调用SCAN只扫描哈希表的一小部分桶,然后立即返回一个游标和当前批次键名,下一轮调用用游标继续。每次调用的复杂度通常可以近似看作O(1),因为它不会因为全库有1000万个键就单次返回1000万个元素。当然,完整遍历所需总时间不会比KEYS更短,而且中间存在重复和遗漏的可能性,但Redis不会长时间无法响应。
可以用一个简单对比理解:KEYS像把所有货物一次性搬出来清点,仓库门口被堵死;SCAN像每次只搬一箱到门口,登记完再回去继续搬,虽然整个过程可能更久,但通道基本畅通。这个特性让SCAN特别适合在线环境。
二、SCAN的游标机制:为什么游标不是偏移量
第一次使用SCAN最容易误解的是返回值里的游标。SCAN的返回是一个包含两个元素的数组:新的游标和本次返回的键列表。当游标回到0时表示迭代结束。但游标并不是已遍历键数量,也不是页码,更不能像MySQL的LIMIT offset那样跳着取。假如第一次返回游标28672,不代表已经遍历了28672个键。
Redis内部使用哈希表存储键空间。SCAN的游标可以理解成对哈希表桶位置的一种压缩编码。Redis使用反向二进制位递增来遍历桶,目的是在哈希表发生扩容或缩容时,尽量保证已经扫描过的桶不会因为rehash而完全错位,也尽量减少重复扫描。这样即使遍历过程中键空间发生变化,SCAN仍然能在一个可接受的范围内工作。
COUNT参数经常被人误解为每批返回数量。它只是一个建议值,告诉Redis每轮扫描多少哈希表桶,不是精确的返回键数。实际返回可能多于COUNT,也可能少于COUNT。如果键分布稀疏,扫了很多桶也凑不出几个键;如果桶里单链表很长,可能一批就返回很多。MATCH条件则是在返回前过滤,因此即使设置COUNT 1000,也可能只得到几个匹配键。
三、实战:怎么用SCAN安全地批量删除匹配键
一个典型场景是清理过期活动留下的临时键,例如temp:order:*。直接用KEYS加DEL虽然简单,但会阻塞线上流量。用SCAN逐批删除会安全很多。下面是一段Python Redis客户端的简单实现。
import redis
r = redis.Redis(host='127.0.0.1', port=6379, db=0, decode_responses=True)
cursor = 0
deleted = 0
while True:
cursor, keys = r.scan(cursor=cursor, match='temp:order:*', count=500)
if keys:
# Redis 4.0+ 推荐使用 UNLINK 异步删除大键
deleted += r.unlink(*keys)
if cursor == 0:
break
print(f"deleted {deleted} keys")
这段代码每次取最多一批匹配键,然后调用UNLINK而不是DEL。DEL在删除大键时也会阻塞主线程,UNLINK会把键从键空间移除,真正的内存释放交给后台线程完成。对于包含大量元素的大哈希、大集合,UNLINK能进一步降低延迟毛刺。如果Redis版本低于4.0,只能使用DEL,但建议控制批次大小。
Python客户端还提供了一个更省事的scan_iter生成器,内部自动处理游标。它适合在遍历过程中做增量操作,例如给所有会话键续期:
for key in r.scan_iter(match='session:*', count=800):
r.expire(key, 1800)
不过scan_iter隐藏了游标细节,如果你需要在循环中删除键并且希望保持游标稳定,仍然建议自己管理游标。因为删除操作会影响底层哈希表,自动迭代器通常也能正常工作,但显式控制游标会让行为更可预期。
四、生产环境中的常见误区与调优建议
第一个误区是把COUNT调得很大来加速遍历。COUNT越大,每轮扫描的桶数越多,单次延迟也可能升高。如果单次SCAN从几百微秒涨到几十毫秒,就失去了渐进式遍历的意义。一般建议COUNT设置为500到1000,然后观察P99延迟。键本身比较大时,可以适当调低,比如200到300;键很小且网络往返成本高时,可以适当调高。
第二个误区是以为SCAN一定能拿到所有键。SCAN遍历期间如果键空间发生变化,新增键可能不会被本轮遍历看到,已删除键也可能不会返回。Redis官方文档明确说明:完整遍历期间,只有一直存在的键才能保证至少返回一次;被修改或删除的键是否返回是不确定的。所以SCAN适合不需要严格快照的场景,比如清理、统计趋势,不适合做精确对账。
第三个误区是只关注Redis服务端,忽略客户端处理逻辑。即使SCAN本身很快,如果客户端对每批键做复杂的业务校验、跨网络调用或大JSON解析,整体耗时会很长,游标也会在客户端停留很久。更好的方式是把SCAN取出的键放入本地队列,由独立消费者慢慢处理,并设置失败重试。这样既能控制Redis压力,也能平滑处理下游瓶颈。
还有一点需要特别提醒:如果只是遍历某个集合、哈希或有序集合内部的大量元素,不要用SCAN全库再过滤,而应该使用SSCAN、HSCAN和ZSCAN。它们的机制类似,但只在目标数据结构内部迭代,效率要高很多。比如清理某个超大集合中的过期成员,直接用SSCAN结合SREM会非常高效。
最后可以用一个表格快速对比几个命令的适用场景。
| 命令 | 作用范围 | 是否阻塞 | 适用场景 |
|---|---|---|---|
| KEYS | 全库键空间 | 阻塞 | 离线分析、小数据量 |
| SCAN | 全库键空间 | 渐进 | 在线清理、批量操作 |
| SSCAN | 单个集合 | 渐进 | 大集合成员迭代 |
| HSCAN | 单个哈希 | 渐进 | 大哈希字段迭代 |
| ZSCAN | 单个有序集合 | 渐进 | 大有序集合成员迭代 |
总结一下,SCAN的核心价值不是更快完成全量遍历,而是把长时间阻塞拆成可接受的短时间片。理解游标不是偏移量、COUNT只是建议、遍历期间数据可能变化这三点后,再把客户端处理逻辑做轻,就能在绝大多数在线场景中安全替代KEYS。

