概述
SCAN 命令在 Redis 早期版本中就支持,主要是为了解决 Redis 去批量获取 key 时造成的阻塞情况。如 KEYS 命令是获取所有命令,这是一种阻塞操作,这里就可以使用 SCAN 命令来尽可能避免 Redis 的阻塞。
SCAN 命令的复杂度虽然是 O(N),但它是分次进行的,不会阻塞线程。
SCAN 命令集
SCAN 命令及其相关的 SSCAN 命令、 HSCAN 命令和 ZSCAN 命令都用于增量地迭代集合类元素。
- SCAN 命令用于迭代当前数据库中的数据库键;
- SSCAN 命令用于迭代集合键中的元素;
- HSCAN 命令用于迭代哈希键中的键值对;
- ZSCAN 命令用于迭代有序集合中的元素(包括元素成员和元素分值);
以上列出的四个命令都支持增量式迭代, 它们每次执行都只会返回少量元素, 所以这些命令可以用于生产环境, 而不会出现像 KEYS 命令、 SMEMBERS 命令带来的问题。当 KEYS 命令被用于处理一个大的数据库时,又或者 SMEMBERS 命令被用于处理一个大的集合键时,它们可能会阻塞服务器达数秒之久。
不过,增量式迭代命令也有很大的缺点,比如在对键进行增量式迭代的过程中,键可能会被修改;字典在进行 rehash 时,缩容的情况会造成重复读取数据;在增量迭代过程,如果增加或删除元素在一定程度上也会导致问题(如果把增量迭代看作完整的过程)。所以增量式迭代命令只能对被返回的元素提供有限的保证。
SCAN 命令的基本用法
SCAN 、 SSCAN 、 HSCAN 和 ZSCAN 四个命令的工作方式都非常相似,它们的区别如下:
- SSCAN 命令、 HSCAN 命令和 ZSCAN 命令的第一个参数总是一个数据库键;
- SCAN 命令则不需要在第一个参数提供任何数据库键 —— 因为它迭代的是当前数据库中的所有数据库键。
1 | SCAN 命令 |
SCAN 命令是一个基于游标的迭代器,也就是 SCAN 命令每次被调用之后, 都会向用户返回一个新的游标, 用户在下次迭代时需要使用这个新游标作为 SCAN 命令的游标参数, 以此来延续之前的迭代过程。
当 SCAN 命令的游标参数被设置为 0 时, 服务器将开始一次新的迭代, 而当服务器向用户返回值为 0 的游标时, 表示迭代已结束。
使用
以下是一个 SCAN 命令的迭代过程:
1 | redis 127.0.0.1:6379> scan 0 |
从上面的示例可以看到, SCAN 命令的回复是一个包含两个元素的数组, 第一个数组元素是用于进行下一次迭代的新游标, 而第二个数组元素则是一个数组, 这个数组中包含了所有被迭代的元素。
在第二次调用 SCAN 命令时, 命令返回了游标 0 , 这表示迭代已经结束, 整个数据集已经被完整遍历过了。以 0 作为游标开始一次新的迭代, 一直调用 SCAN 命令, 直到命令返回游标 0 , 我们称这个过程为一次完整遍历。
返回值
SCAN 命令、 SSCAN 命令、 HSCAN 命令和 ZSCAN 命令都返回一个包含两个元素的 multi-bulk 回复:
- 回复的第一个元素是字符串表示的无符号 64 位整数(游标)
- 回复的第二个元素是另一个 multi-bulk 回复, 这个 multi-bulk 回复包含了本次被迭代的元素。
SCAN 命令返回的每个元素都是一个数据库键。
SSCAN 命令返回的每个元素都是一个集合成员。
HSCAN 命令返回的每个元素都是一个键值对,一个键值对由一个键和一个值组成。
ZSCAN 命令返回的每个元素都是一个有序集合元素,一个有序集合元素由一个成员(member)和一个分值(score)组成。
SCAN 选项
COUNT 选项
虽然增量式迭代命令不保证每次迭代所返回的元素数量, 但我们可以使用 COUNT
选项, 对命令的行为进行一定程度上的调整。 COUNT
选项的作用就是让用户告知迭代命令, 在每次迭代中应该从数据集里返回多少元素。注意, COUNT
选项只是对增量式迭代命令的一种提示,它只是一个参考值,因为 Redis 扫描数据是以哈希桶为单位的。
COUNT
参数的默认值为10
;- 在迭代一个足够大的、由哈希表实现的数据库、集合键、哈希键或者有序集合键时, 如果用户没有使用
MATCH
选项, 那么命令返回的元素数量通常和COUNT
选项指定的一样, 或者比COUNT
选项指定的数量稍多一些。 - 在迭代一个编码为整数集合(intset,一个只由整数值构成的小集合)、 或者编码为压缩列表(ziplist,由不同值构成的一个小哈希或者一个小有序集合)时, 增量式迭代命令通常会无视
COUNT
选项指定的值, 在第一次迭代就将数据集包含的所有元素都返回给用户。
MATCH 选项
和keys
命令一样, 增量式迭代命令也可以通过提供一个 glob 风格的模式参数, 让命令只返回和给定模式相匹配的元素, 这一点可以通过在执行增量式迭代命令时, 通过给定 MATCH <pattern>
参数来实现。
需要注意的是, 对元素的模式匹配工作是在命令从数据集中取出元素之后, 向客户端返回元素之前的这段时间内进行的, 所以如果被迭代的数据集中只有少量元素和模式相匹配, 那么迭代命令或许会在多次执行中都不返回任何元素。
SCAN 命令的保证
SCAN 命令, 以及其他增量式迭代命令, 在进行完整遍历的情况下可以为用户带来以下保证: 从完整遍历开始直到完整遍历结束期间, 一直存在于数据集内的所有元素都会被完整遍历返回; 这意味着, 如果有一个元素, 它从遍历开始直到遍历结束期间都存在于被遍历的数据集当中, 那么 SCAN 命令总会在某次迭代中将这个元素返回给用户。
然而因为增量式命令仅仅使用游标来记录迭代状态, 所以这些命令带有以下缺点:
- 同一个元素可能会被返回多次。 处理重复元素的工作交由应用程序负责, 比如说, 可以考虑将迭代返回的元素仅仅用于可以安全地重复执行多次的操作上。
- 如果一个元素是在迭代过程中被添加到数据集的, 又或者是在迭代过程中从数据集中被删除的, 那么这个元素可能会被返回, 也可能不会。
因为迭代的所有状态都保存在游标里面, 而服务器无须为迭代保存任何状态, 所以客户端可以在中途停止一个迭代, 而无须对服务器进行任何通知。
使用间断的(broken)、负数、超出范围或者其他非正常的游标来执行增量式迭代并不会造成服务器崩溃, 但可能会让命令产生未定义的行为。未定义行为指的是, 增量式命令对返回值所做的保证可能会不再为真。只有两种游标是合法的:
- 在开始一个新的迭代时, 游标必须为
0
。 - 增量式迭代命令在执行之后返回的, 用于延续(continue)迭代过程的游标。
SCAN 原理
Redis 使用了 Hash 表作为底层实现:
SCAN 命令就是对这个一维数组进行遍历,每次返回的游标值也都是这个数组的索引。如果不考虑字典的扩容缩容,直接按数组下标挨个遍历就行了。COUNT 参数表示遍历数据的数量,该值只是起到一个大致约束的作用,所以返回的结果取决于索引下挂接链表的长度,有些槽位可能是空的,还有些槽位上挂接的链表上的元素可能会有多个。
如果不考虑扩容与缩容,那么无论是从前遍历还是从后遍历都可以获取所有的key值。但是有扩容、缩容后就需要考虑遍历的准确性,是否存在重复遍历,是否存在遗漏的遍历。如果我们按照低位加法,即从前向后遍历,当扩容或者缩容时进行的rehash操作使得数据分散到不同的槽位,这就有可能发生重复遍历与遗漏遍历的情况。
因此,Redis 采用了高位加法进位计算游标的方式遍历集合,Redis 的 SCAN 命令遍历流程如下图:
观察这张图,我们发现采用高位加法进位计算游标的遍历顺序,rehash 时游标指向的槽位在遍历顺序上是相邻的。这得益于 Redis rehash 机制:
- 扩容时:原来属于
1xx
槽位的所有元素分散到01xx
和11xx
槽位中,其中前者是原数组的槽位,后者是扩容的新数组的槽位; - 缩容时:
1xx
槽位的所有元素由01xx
和11xx
槽位中元素融合;
假设当前即将要遍历 110 这个槽位 (橙色),那么扩容发生时,当前槽位上所有的元素分散在槽位 0110 和 1110(深绿色)中,也就是在槽位的二进制数增加一个高位 0 或 1,其中 1110 就是通过高位加法进位算出的下个要扫描的槽位,该槽位对应了扩容的新数组的位置。这时我们可以直接从 0110 这个槽位开始往后继续遍历,0110 槽位之前的所有槽位都是已经遍历过的,这样就可以避免扩容后对已经遍历过的槽位进行重复遍历。注意,主线程在 SCAN
和 rehash 时只能进行一个。
再考虑缩容,假设当前即将要遍历 110 这个槽位 (橙色),那么缩容时,当前槽位所有的元素分散在槽位 10(深绿色)和 110(橙色)中,其中槽位 10 也就是去掉槽位二进制最高位,这是通过高位加法进位算出的,该槽位对应了缩容的新数组的位置。这时我们可以直接从 10 这个槽位继续往后遍历,10 槽位之前的所有槽位都是已经遍历过的,但图中 010 这个槽位上的元素已经被遍历过了。可以理解为,对于遍历来说,缩容后 10 槽位的元素是 010 和 110 上挂接的元素的融合。
在使用SCAN命令时,不会漏key,但可能会得到重复的key,这主要和Redis的rehash机制有关。Redis的所有key存在一个全局的哈希表中,如果存入的key慢慢变多,在达到一定阈值后,为了避免哈希冲突导致查询效率降低,这个哈希表会进行扩容。与之对应的,key数量逐渐变少时,这个哈希表会缩容以节省空间。
- 为什么不会漏 key:Redis在SCAN遍历全局哈希表时,采用高位加法进位的方式遍历哈希桶,当哈希表扩容后,通过这种算法遍历,旧哈希表中的数据映射到新哈希表,依旧会保留原来的先后顺序,这样就可以保证遍历时不会遗漏也不会重复。
- 为什么会得到重复的key:这个情况主要发生在哈希表缩容。已经遍历过的哈希桶在缩容时,可能会映射到新哈希表没有遍历到的位置,所以继续遍历就会对同一个key返回多次。
SCAN是遍历整个实例的所有key,另外Redis针对Hash/Set/Sorted Set也提供了HSCAN/SSCAN/ZSCAN命令,用于遍历一个key中的所有元素,建议在获取一个bigkey的所有数据时使用,避免发生阻塞风险。
Hash/Set/Sorted Set元素数量比较少时,底层会采用intset/ziplist方式存储,如果以这种方式存储,在执行HSCAN/SSCAN/ZSCAN命令时,会无视count参数,直接把所有元素一次性返回。
在分片集群场景下 SCAN 命令是无法跨节点扫描的,只能是一个节点一个节点的进行扫描。
SCAN 源码分析
Redis 对 SCAN 命令集处理的底层函数是 scanGenericCommand
。下面我们对该函数处理的主要过程进行说明。
解析参数
1 | void scanGenericCommand(client *c, robj *o, unsigned long cursor) { |
如果给定了对象 o ,那么它必须是一个哈希对象或者集合对象,如果 o 为 NULL 的话,函数将使用当前数据库作为迭代对象。
判断要扫描的是否是哈希表
1 | void scanGenericCommand(client *c, robj *o, unsigned long cursor) { |
判断要扫描的集合,是否使用内存紧凑型结构。如果对象的底层实现为 ziplist 、intset 而不是哈希表,那么这些对象通常都只包含了少量元素, 因此,为了避免服务器记录迭代状态,我们将 ziplist 或者 intset 里面的所有元素都一次返回给调用者,无视 count 参数,并向调用者返回游标 cursor 0 。
扫描哈希表
1 | void scanGenericCommand(client *c, robj *o, unsigned long cursor) { |
可以看出,如果 SCAN 操作的数据结构是哈希表,那么就会采用高位加法进位的方式遍历哈希表,具体实现在 dictScan
函数中。从以上代码可以看出:
- COUNT 参数项起到大致约束作用,告知 Redis 大约扫描 count 个数据;
- 这里设置了最大扫描哈希桶(槽位)的数量为 10*count ,目的是防止遇到过多空的哈希桶,一直达不到 count 的条件阻塞主线程;
高位加法进位遍历
由于 Redis 使用的是渐进式 rehash 机制,因此当 SCAN 命令执行时处于 rehash 阶段,就需要同时扫描新表和旧表,然后将结果返回客户端。
未处于 rehash 过程
1 | unsigned long dictScan(dict *d, |
根据游标定位到哈希桶后,处理完后会紧接着计算下次使用的游标,而计算方法就是 高位加法进位。
下面举例进行说明:
假设当前哈希表有 4 个桶,也就是默认桶数量,使用 SCAN 命令如下:
1 | SCAN 0 MATCH * COUNT 1 |
按照正规的操作步骤,假设得到一个完整的桶迭代顺序如下:
0->2->1->3 ,转换成二进制为:00->10->01->11
我们发现每次这个序列变化是,将游标倒置 -> 加 1 -> 再倒置。
要知道,普通二进制的加法,是从右往左相加、进位。而这个序列是从左往右相加、进位的。代码体现如下:
1 | // 1)倒置游标 v |
处于 rehash 过程
1 | unsigned long dictScan(dict *d, |
SCAN 命令处于 rehash 过程需要同时扫描新表和旧表,虽然扩容和缩容的情况使用的是同一套流程,但是意义上是不同的。下面对关键流程进行总结:
根据字典中的两个哈希表的大小排序,将小的哈希表赋值给 t0,将大的哈希表赋值给 t1;
分别记录 t0、t1 的掩码值,用于后续根据游标计算桶的索引;
根据索引先后迭代 t0表、t1表;
把 SCAN 扫描图再拿过来:
对于扩容来说,t0 是原数组,t1 是新数组:
在读取游标 v 110 对应的槽位前,哈希表扩容了,此时原数组 t0 数组的 110 槽位的数据可能还在,也可能已经迁移到了新数组 t1 1110 槽位中。此时,SCAN 顺序是先 t0 的 110 槽位,再 t1 的 1110 槽位。可以看到,通过高位加法进位遍历,依旧会保留原来的先后顺序,这种情况可以保证遍历时不会遗漏也不会重复。
对于缩容来说,t0 是新数组,t1 是原数组:
在读取游标 v 110 对应的槽位前,哈希表缩容了,此时原数组 t1 数组的 110 槽位的数据可能还在,也可能已经迁移到了新数组 t0 10 槽位中。此时,SCAN 顺序是先 t0 的 10 槽位,再 t1 的 110 槽位。可以看到,通过高位加法进位遍历,虽然依旧会保留原来的先后顺序,但是出现了“往回读一个哈希桶”,要知道已经遍历过的哈希桶在缩容时,可能会映射到新哈希表没有遍历到的位置,所以继续遍历就会对同一个key返回多次。按照图中的就是,010 槽位的元素会被重复遍历,但 010 之前的都不会。
扫描内存紧凑型结构
1 | void scanGenericCommand(client *c, robj *o, unsigned long cursor) { |
可以看到,如果对象的底层实现为 ziplist 、intset 而不是哈希表,那么这些对象通常都只包含了少量元素, 因此会将 ziplist 或者 intset 里面的所有元素都一次返回给调用者,无视 count 参数。
匹配过滤
1 | void scanGenericCommand(client *c, robj *o, unsigned long cursor) { |
需要注意的是,对元素的模式匹配工作是在命令从数据集中取出元素之后, 向客户端返回元素之前的这段时间内进行的。
返回扫描结果
1 | void scanGenericCommand(client *c, robj *o, unsigned long cursor) { |
小结
Redis 提供的 SCAN 命令就是用于增量迭代的。这个命令可以每次返回少量的元素,所以非常适合用来处理大的数据集的迭代。常见的使用场景如下:
- 替代阻塞式的
KEYS
命令; - 替代复杂度过高的集合查询命令,如
SMEMBERS
、LRANGE
等。
虽然 SCAN 命令在效率上具有优势,但是也要注意它的缺陷,如有重复读取的可能、扫描过程对键值对的增删支持不友好。