揭秘 Redis SCAN:海量数据安全高效遍历之道
引言:为什么我们需要 SCAN?
在 Redis 的日常使用中,我们经常需要获取存储在数据库中的键(key)。对于数据量不大的场景,开发者可能首先想到使用 KEYS pattern
命令。然而,KEYS
命令存在一个致命的缺陷:它会遍历数据库中的所有键,并一次性将所有匹配的键返回给客户端。当 Redis 存储的数据量非常庞大时(数百万甚至上亿个键),执行 KEYS
命令会阻塞整个 Redis 服务器,导致其他客户端的请求被挂起,造成严重的性能问题甚至服务中断。这使得 KEYS
命令在生产环境中处理海量数据时变得不可接受。
为了解决 KEYS
命令带来的阻塞问题,Redis 引入了基于游标(cursor)的迭代器命令族:SCAN
、SSCAN
、HSCAN
和 ZSCAN
。SCAN
命令用于迭代当前数据库中的键,而 SSCAN
、HSCAN
、ZSCAN
则分别用于迭代集合(Set)、哈希表(Hash)、有序集合(Sorted Set)中的元素。本文将重点详细探讨 SCAN
命令,解释其工作原理、参数、优缺点以及如何在海量数据场景下安全高效地利用它。
KEYS 命令的致命缺陷
在深入了解 SCAN
之前,再次强调 KEYS
的问题至关重要。KEYS pattern
命令的内部实现原理是遍历 Redis 当前数据库中的所有键,检查每个键是否与给定的模式匹配,并将所有匹配的键收集起来一次性返回。这个过程是一个同步且原子性的操作。
设想一下,如果 Redis 中有上千万甚至上亿个键,这个遍历过程需要耗费大量的 CPU 时间。在此期间,Redis 的事件循环会被阻塞,无法处理其他客户端的读写请求。这意味着你的所有 Redis 操作(GET, SET, PUSH, POP 等)都会因为一个 KEYS
命令而暂停,对线上服务的可用性造成严重影响。尤其是在高并发场景下,一个长时间运行的 KEYS
命令足以让整个系统崩溃。因此,Redis 的官方文档明确指出:在生产环境中,绝不应该使用 KEYS
命令。
SCAN 命令的设计哲学:迭代与分批
SCAN
命令的设计目标正是为了克服 KEYS
命令的缺点。它采用了一种迭代式的、分批返回结果的方式来遍历键空间。SCAN
命令的核心思想是:
- 基于游标 (Cursor):
SCAN
命令的每次调用都需要提供一个游标。首次调用时,游标值为0
。服务器会在处理请求后返回一个新的游标,客户端在下一次调用时传入这个新的游标。当服务器返回的游标为0
时,表示遍历结束。 - 分批返回 (Batching):
SCAN
命令不会一次性返回所有匹配的键,而是返回一个包含少量键的数组和一个用于下一次迭代的新游标。这样可以将一次性耗费大量时间的遍历操作分散到多次SCAN
调用中,每次调用只处理一小部分数据,从而避免长时间阻塞服务器。 - 非阻塞 (Non-Blocking): 由于每次调用只进行少量工作并快速返回,
SCAN
命令对 Redis 服务器的阻塞时间非常短,几乎可以忽略不计,这使得它可以在生产环境中安全使用。
这种迭代式、分批返回的设计,使得 SCAN
命令成为在不影响服务可用性的前提下遍历 Redis 键空间的标准方法。
SCAN 命令的语法和参数
SCAN
命令的基本语法如下:
SCAN cursor [MATCH pattern] [COUNT count] [TYPE type]
各参数详解:
-
cursor
(必须):- 一个无符号 64 位整数,用作迭代器的游标。
- 首次调用时,必须将
cursor
设置为0
。 - 后续调用时,必须使用上一次
SCAN
调用返回的游标作为参数。 - 当
SCAN
调用返回的游标为0
时,表示遍历已经完成。
-
MATCH pattern
(可选):- 用于过滤键。只有与给定模式匹配的键才会被返回。模式遵循 glob 风格,例如
*
匹配任意字符序列,?
匹配任意单个字符,[abc]
匹配字符集中的任意一个字符等。 - 如果没有指定
MATCH
参数,SCAN
会返回数据库中的所有键(在迭代过程中)。 - 使用
MATCH
参数可以在一定程度上减少需要处理的键数量,提高效率(但过滤是在服务器端完成的)。
- 用于过滤键。只有与给定模式匹配的键才会被返回。模式遵循 glob 风格,例如
-
COUNT count
(可选):- 一个可选的提示参数,指示每次调用应该大致返回多少个元素。
- 重要提示:
COUNT
只是一个 提示,而不是一个严格的保证。Redis 服务器在每次调用中返回的元素数量可能大于或小于COUNT
指定的值。 - 默认的
COUNT
值是 10。 COUNT
参数对每次SCAN
调用执行时间的影响很大。一个较大的COUNT
值意味着每次调用返回更多元素,但服务器处理该次调用的时间也会更长。反之,较小的COUNT
值会减少单次调用的服务器处理时间,但为了遍历整个数据集,可能需要更多的SCAN
调用(即更多的网络往返)。- 调整
COUNT
值需要权衡服务器的 CPU 负载与客户端/网络的效率。在大多数情况下,保持默认值或稍微调大一点(例如 100 或 1000)是一个不错的折衷。
-
TYPE type
(可选, Redis 6.0+):- 一个可选的参数,用于过滤返回的键类型。可以指定
string
,list
,set
,zset
,hash
,stream
等类型。 - 只有类型匹配的键才会被返回。
- 这个参数在只需要迭代特定类型的键时非常有用。
- 一个可选的参数,用于过滤返回的键类型。可以指定
SCAN 命令的返回值
SCAN
命令每次调用会返回一个包含两个元素的数组:
- 新的游标 (String 类型): 一个字符串表示的无符号 64 位整数。客户端在下一次调用
SCAN
时必须使用这个值作为cursor
参数。如果返回的游标是"0"
,表示本次调用已经返回了最后一部分结果,整个遍历过程已经完成。 - 键列表 (Array 类型): 一个包含本次迭代所获取到的键的列表。
例如,第一次调用 SCAN 0
可能返回:
1) "17"
2) 1) "key:123"
2) "another:key"
3) "some:data"
这里的 "17"
是下一次 SCAN
调用需要使用的游标。下次调用时,你将执行 SCAN 17
。
当最终遍历完成时,可能会返回:
1) "0"
2) 1) "final:key:segment"
2) "last:one"
返回的游标 "0"
表示遍历结束。
SCAN 命令的工作原理简述 (非深入算法)
理解 SCAN
如何在不阻塞的情况下遍历键空间,可以简单想象成 Redis 内部有一个键的列表(或者更准确地说,一个哈希表)。SCAN
不是从头到尾线性扫描这个列表,而是利用游标和一种特殊的迭代算法(通常是基于增量式哈希,Incremental Hashing)。
当客户端提供一个游标值 C
并指定一个 COUNT
提示时,Redis 会根据 C
和内部的哈希表结构,快速定位到某个“桶”或“分片”。然后,它会从这个位置开始扫描,收集一定数量(大致等于 COUNT
)的键,直到达到 COUNT
提示或扫描完当前桶的某个部分。扫描结束后,服务器会计算并返回下一个游标值 C'
,这个 C'
指向了下一次应该从哪里继续扫描。
这种方式的好处是:
- 分散负载: 单次
SCAN
调用只处理局部数据,不会导致全局扫描。 - 进度保存: 游标
C
实际上编码了下一次扫描应该从哪个位置开始的信息,使得迭代过程可以中断和恢复(只要记住游标)。 - 与数据库大小无关的单次操作耗时: 单次
SCAN
调用(在COUNT
提示合理的情况下)的处理时间主要取决于返回的元素数量,而不是整个数据库的大小。
SCAN 的优点:安全与高效的实现
总结 SCAN
命令相对于 KEYS
命令的优点:
-
安全性 (Safety):
- 非阻塞: 这是最重要的优点。
SCAN
不会长时间阻塞 Redis 服务器,使得在高流量的生产环境中可以安全使用。每次调用都能在纳秒或微秒级别完成,对其他操作影响极小。 - 资源可控: 通过调整
COUNT
参数,可以控制每次迭代的网络开销和服务器单次处理的 CPU 负载,实现资源的平滑使用而不是尖峰占用。
- 非阻塞: 这是最重要的优点。
-
高效性 (Efficiency):
- 迭代式处理: 将一个耗时巨大的任务分解成多个小任务,分摊到不同的时间点执行。
- 基于提示的批量返回:
COUNT
参数允许客户端根据需要调整批量大小,平衡网络往返次数和服务器单次处理效率。 - 支持模式匹配和类型过滤:
MATCH
和TYPE
参数可以在服务器端进行初步过滤,减少不必要的数据传输和客户端处理负担。
这些特性使得 SCAN
成为处理 Redis 海量数据遍历场景的标准和最优选择。
SCAN 的潜在问题与注意事项
尽管 SCAN
解决了 KEYS
的阻塞问题,但它并非完美无缺。由于其迭代和非阻塞的特性,它存在一些需要使用者注意的潜在问题:
-
不保证一次完整的快照:
SCAN
迭代过程不是原子的快照。如果在遍历过程中,数据集发生了变化(例如,添加了新的键,删除了旧的键,或者键名被修改),那么:- 可能出现重复的键: 如果一个键在某个游标位置被返回后,由于数据集的变化(特别是哈希表的 rehashing 等内部操作),它可能在后续的游标位置再次被返回。
- 可能遗漏某些键: 如果一个键在
SCAN
迭代到它所属的“桶”或“分片”之前被删除,那么它将不会被返回。类似地,如果一个键在SCAN
已经扫描过它应该出现的区域之后被添加,那么它可能不会在 本次SCAN
迭代中被返回(但在下次完整的从头开始的迭代中可能会被返回)。 SCAN
的设计保证的是,在一次完整的从游标 0 开始到游标 0 结束的遍历过程中,每个键都会被返回 至少一次,除非它在整个扫描过程中都被删除了。但它不保证键只被返回一次,也不保证能看到在扫描过程中新添加的所有键。
-
不保证顺序:
SCAN
命令返回的键是无序的。如果需要按特定顺序处理键,需要在客户端进行排序或其他后处理。 -
COUNT
是提示: 如前所述,COUNT
参数只是一个提示,不能依赖它来准确控制每次返回的元素数量。客户端代码需要能够处理返回任意数量元素的情况(包括返回 0 个元素,即使遍历没有结束)。 -
完成遍历需要多次调用: 遍历整个数据集需要执行多次
SCAN
命令,直到游标返回 0。这意味着客户端需要维护迭代状态(即当前的游标值),并在每次调用后更新游标。
这些特性意味着 SCAN
适用于那些对结果实时性要求不高、或者可以容忍少量重复/遗漏(并能在客户端进行去重或补偿处理)的场景,例如数据迁移、缓存预热、统计分析、后台清理任务等。对于需要严格一致性快照的场景,SCAN
可能不是最佳选择(尽管在许多实际应用中,SCAN
的一致性级别已经足够)。
如何安全高效地使用 SCAN 命令
利用 SCAN
命令的核心在于客户端的控制逻辑。一个典型的 SCAN
使用流程应该遵循以下步骤:
- 初始化游标: 将当前游标
cursor
初始化为0
。 - 循环调用 SCAN:
- 使用当前的
cursor
值调用SCAN cursor [MATCH pattern] [COUNT count] [TYPE type]
。 - 处理
SCAN
命令返回的结果数组:- 获取新的游标值(返回数组的第一个元素)。
- 获取本次迭代返回的键列表(返回数组的第二个元素)。
- 对返回的键列表进行业务处理(例如,获取键对应的值,执行其他操作等)。
- 更新
cursor
:将本次调用返回的新游标值赋给cursor
变量。 - 检查循环终止条件:如果新的游标值为
"0"
,则退出循环。
- 使用当前的
- 处理重复和遗漏(如果需要): 根据具体的业务需求,在客户端实现去重逻辑,或者设计补偿机制来处理可能重复或遗漏的键。例如,如果目的是删除所有匹配模式的键,可以在客户端维护一个已删除键的集合,确保每个键只尝试删除一次。如果目的是统计键数量,需要客户端去重计数。
示例伪代码 (Python 风格):
“`python
import redis
连接 Redis
r = redis.StrictRedis(host=’localhost’, port=6379, db=0)
初始游标
cursor = ‘0’
存储所有获取到的键 (如果需要去重)
all_keys = set()
print(“开始 SCAN 遍历…”)
try:
while True:
# 调用 SCAN 命令
# 可以添加 MATCH pattern=”your_prefix:” 或 COUNT=1000
# 可以添加 TYPE type=”hash” 等
response = r.scan(cursor=cursor, match=’‘, count=1000)
# 解析返回值
cursor = response[0].decode('utf-8') # 新的游标
keys = response[1] # 本次返回的键列表 (bytes 类型)
if keys:
print(f"本次获取到 {len(keys)} 个键, 新游标: {cursor}")
# 业务处理逻辑
for key_bytes in keys:
key_str = key_bytes.decode('utf-8')
# print(f"处理键: {key_str}")
# 这里可以执行 GET, DEL 或其他操作
# r.get(key_str)
# r.delete(key_str)
# 如果需要去重并统计所有键
all_keys.add(key_str)
# 检查遍历是否结束
if cursor == '0':
print("SCAN 遍历完成.")
break
except Exception as e:
print(f”SCAN 遍历过程中发生错误: {e}”)
# 根据需要处理错误,可能需要记录当前游标以便下次从中断处恢复
finally:
if all_keys:
print(f”总共获取到 (去重后) {len(all_keys)} 个键。”)
# 可以根据需要清理资源
# r.close()
“`
关于 COUNT
参数的选择:
- 较小的
COUNT
(例如默认的 10):单次调用耗时短,对服务器影响小,但需要更多的网络往返次数。适用于对延迟非常敏感、服务器资源非常紧张或网络延迟较高的场景。 - 较大的
COUNT
(例如 1000 或更大):单次调用耗时相对长,一次性处理更多数据,减少网络往返。适用于网络状况良好、服务器 CPU 有一定余量、希望尽快完成遍历的场景。 - 最佳
COUNT
值取决于你的具体环境(数据量、键分布、网络状况、服务器负载等)。通常建议从一个适中的值开始(如 100 或 1000),并通过监控 Redis 服务器的INFO
命令(特别是instantaneous_ops_per_sec
和 CPU 使用率)来观察其对性能的影响,从而进行调整。
处理重复键和遗漏键的策略:
- 如果业务允许重复处理: 例如,如果只是打印键名,重复打印无妨。如果操作是幂等的(例如,设置某个状态),重复执行也通常是安全的。
- 如果业务不允许重复处理: 客户端需要维护一个集合(Set)来记录已经处理过的键。每次获取到新批次的键时,先检查该键是否已在集合中,只处理未处理过的键,并将其添加到集合。这种方法会占用客户端一定的内存来存储已处理的键,对于海量数据可能需要分布式集合或分批存储。
- 对于遗漏键: 如果需要保证所有键都被处理到(即使是那些在扫描过程中新添加的键),唯一的办法是当一次完整的
SCAN
遍历完成后,如果业务场景允许且需要,可以再次从游标 0 开始进行新一轮的SCAN
。但需要注意,连续的SCAN
可能会导致一个键在多轮遍历中被处理。对于大多数后台任务,这种程度的最终一致性通常是可接受的。如果需要严格一致性,考虑在应用层面记录数据变更日志或使用 Redis 的 AOF 文件进行分析(但后者更复杂且非在线操作)。
总结
SCAN
命令是 Redis 提供的用于安全、高效地迭代数据库中键(以及集合、哈希、有序集合中的元素)的标准工具。它通过基于游标的迭代和分批返回机制,避免了 KEYS
命令在处理海量数据时导致的服务器阻塞问题。
理解 SCAN
的核心是掌握其迭代过程、游标机制以及 MATCH
和 COUNT
参数的作用。同时,也要清醒认识到 SCAN
在一致性上的权衡:它不能提供一个严格的、原子性的数据集快照,可能会返回重复的键或遗漏在扫描过程中发生变更的键。在使用 SCAN
进行批量处理任务时,需要根据业务需求在客户端实现相应的去重或补偿逻辑。
在绝大多数需要遍历 Redis 大量数据的场景下,SCAN
命令是唯一安全且推荐的选择。熟练掌握 SCAN
的使用,是应对 Redis 海量数据挑战的关键技能之一。通过合理设置 COUNT
参数并处理好客户端的迭代逻辑及结果一致性问题,你可以安全高效地完成各种与 Redis 数据遍历相关的任务,而无需担心影响线上服务的稳定性。