【Redis入门系列】从 KEYS 到 SCAN:渐进式遍历、Cursor 与位反转原理

🔥 本文专栏:Redis

🌸作者主页:努力努力再努力wz

💪 今日博客励志语录:真正把一个知识点学懂,往往不是记住结论,而是能够顺着"为什么"一步一步把它重新推导出来。


思维导图

text 复制代码
Redis Key Space 遍历
        │
        ├── KEYS
        │     │
        │     ├── 一次完成全量扫描
        │     ├── O(N)
        │     └── 大数据量下可能长时间占用命令执行线程
        │
        └── SCAN
              │
              ├── 渐进式遍历
              │     └── 一次完整遍历拆成多次独立 SCAN
              │
              ├── cursor
              │     ├── 不是数组下标
              │     ├── 不是已经遍历的 key 数量
              │     ├── 不要求递增
              │     └── 用于延续哈希桶遍历状态
              │
              ├── 无状态迭代
              │     └── Redis 不为每个 Client 保存 SCAN Context
              │
              ├── Hash Table
              │     ├── Bucket Array
              │     ├── Bucket → Entry 链表
              │     └── 以 Bucket 为基本遍历单元
              │
              ├── 渐进式 Rehash
              │     ├── 小表 + 大表暂时并存
              │     ├── Bucket 逐步迁移
              │     └── 一个旧桶扩容后对应多个新桶
              │
              └── Reverse Cursor
                    ├── 完整整数参与游标推进
                    ├── cursor & sizemask 定位 Bucket
                    ├── 高位优先的逻辑遍历顺序
                    └── 兼容扩容 / 缩容期间的桶映射变化

引入:从 KEYS 的问题过渡到 SCAN

在此前学习 Redis 命令时,我们已经接触过:

redis 复制代码
KEYS pattern

这个命令可以用于查询当前 Redis 数据库中符合某个模式的 key。

例如:

redis 复制代码
KEYS *

可以获取当前数据库中的所有 key。

从功能上来看,KEYS 非常直接:

text 复制代码
Redis Key Space
      ↓
遍历所有 key
      ↓
判断是否匹配 pattern
      ↓
返回符合条件的 key

但是问题在于:

KEYS 会在一次命令执行过程中完成整个 Key Space 的遍历。

当 Redis 中只有几十个或者几百个 key 时,这个问题并不明显。

但是随着数据规模不断增大:

text 复制代码
1 万个 key
10 万个 key
100 万个 key
1000 万个 key
...

一次全量遍历所需要的时间也会不断增加。

而要真正理解 KEYS 为什么危险,我们还需要重新回到 Redis 的命令执行模型。


一、为什么 Redis 中的耗时命令会产生明显影响

1. Redis 的核心命令执行模型

Redis 本质上是一个:

text 复制代码
Client / Server

架构的网络服务程序。

多个客户端可以同时与 Redis Server 建立连接:

text 复制代码
Client A ─┐
Client B ─┼──→ Redis Server
Client C ─┘

客户端不断发送:

text 复制代码
GET
SET
HSET
ZADD
DEL
SCAN
...

Redis Server 接收到请求以后,需要完成:

text 复制代码
读取请求
    ↓
解析 Redis Command
    ↓
执行命令
    ↓
访问内存中的数据结构
    ↓
生成响应
    ↓
返回 Client

在我们当前讨论的模型中,最重要的一点是:

Redis 对核心数据命令的执行主要仍然由命令执行线程串行完成。

现代 Redis 在网络 I/O、后台持久化等部分可以使用其他线程或者进程,但是对于我们现在讨论的:

text 复制代码
GET / SET / HSET / ZADD / KEYS / SCAN

这些核心数据访问逻辑,可以先理解为:

text 复制代码
Command 1
    ↓
执行完成

Command 2
    ↓
执行完成

Command 3
    ↓
执行完成

而不是多个线程同时修改同一份 Redis 数据结构。


2. 为什么这种模型要求单条命令尽量轻量

Redis 中大量操作本身都是内存操作。

例如:

redis 复制代码
GET name
SET age 20
HGET user:1 name

这些操作通常执行得非常快。

如果每一个这样的轻量任务都必须:

text 复制代码
主线程
   ↓
投递到工作线程
   ↓
访问共享任务队列
   ↓
线程同步
   ↓
工作线程执行
   ↓
结果再传回

那么反而会引入:

text 复制代码
线程调度
锁竞争
上下文切换
共享数据同步
任务跨线程传递

等额外成本。

因此,对于大量短小的内存操作来说:

text 复制代码
请求本身很轻量
        ↓
主执行线程直接串行处理
        ↓
减少同步以及调度开销

是一种非常合理的设计。

但是这种设计也带来了一个非常明显的前提:

单条命令最好不要长时间霸占命令执行线程。

否则:

text 复制代码
一个耗时命令正在执行
        ↓
其他命令无法在中间插入执行
        ↓
后续请求只能等待

这就自然引出了 KEYS 的问题。


二、KEYS:一次性全量扫描带来的风险

1. KEYS 本质上是一次全量遍历

例如:

redis 复制代码
KEYS user:*

Redis 需要在当前数据库的 Key Space 中不断检查 key:

text 复制代码
key1
key2
key3
...
keyN

然后判断:

text 复制代码
当前 key 是否匹配 user:*

因此从整体上看:

text 复制代码
KEYS
  ↓
遍历整个 Key Space
  ↓
O(N)

其关键风险并不仅仅在于:

text 复制代码
O(N)

而在于:

这个 O(N) 的遍历是在一次 Redis 命令中连续完成的。

例如:

text 复制代码
Client A:
KEYS *
   ↓
Redis 开始全量遍历
████████████████████████

此时:

Client B:GET name
Client C:SET age 20
Client D:HGET user:1 name

都只能等待 KEYS 当前这一条命令执行完成

所以 KEYS 在测试环境、调试环境或者数据量很小时可能非常方便,但是在生产环境的大 Key Space 中需要非常谨慎。


2. 问题的解决方向:不要一次做完

既然问题来自:

text 复制代码
一次命令做了太多工作

那么一个非常自然的解决思路就是:

不再要求一次命令完成整个 Key Space 的遍历,而是把一次大遍历拆成很多次小遍历。

于是:

text 复制代码
一次全量扫描

████████████████████████████

被拆成:

text 复制代码
███
   ↓
其他命令

███
   ↓
其他命令

███
   ↓
其他命令

...

这就是 Redis SCAN 命令的核心思想:

渐进式遍历。


三、SCAN 的基本使用模型

Redis SCAN 的基本语法可以理解为:

redis 复制代码
SCAN cursor [MATCH pattern] [COUNT count] [TYPE type]

其中最关键的是:

text 复制代码
cursor

它是必选参数。

而:

text 复制代码
MATCH
COUNT
TYPE

属于可选条件。


1. 一次完整 SCAN 从 cursor = 0 开始

例如:

redis 复制代码
SCAN 0

这里的 0 表示:

开启一次新的完整迭代。

Redis 执行一部分遍历以后,会返回两部分内容:

text 复制代码
新的 cursor
+
本轮得到的一批 key

例如可以抽象成:

text 复制代码
SCAN 0
    ↓
返回:
cursor = 24
[key1, key2, key3 ...]

下一次客户端继续发送:

redis 复制代码
SCAN 24

Redis 再完成下一小段遍历:

text 复制代码
SCAN 24
    ↓
返回:
cursor = 6
[key4, key5 ...]

继续:

redis 复制代码
SCAN 6

直到最终某一次返回:

text 复制代码
cursor = 0

才表示:

当前这一次完整 SCAN 迭代结束。

所以:

text 复制代码
cursor = 0

既可以表示:

text 复制代码
开始一次新的完整遍历

也可以出现在返回值中表示:

text 复制代码
本轮完整遍历已经结束

2. MATCH:控制返回 key 的匹配模式

例如:

redis 复制代码
SCAN 0 MATCH user:*

这里表示:

text 复制代码
遍历 Key Space
      ↓
只返回匹配 user:* 的 key

需要注意:

MATCH 并不意味着 Redis 可以直接跳到所有 user:* 所在的位置。

Redis 仍然需要按照 SCAN 自己的遍历过程扫描数据,然后再对扫描出来的元素进行模式匹配。

因此一次 SCAN 完全可能出现:

text 复制代码
本轮返回 key 数量 = 0

但是:

cursor != 0

这并不表示遍历结束。

判断是否结束仍然只能看:

text 复制代码
cursor == 0

3. COUNT:只是工作量提示,不是精确数量

例如:

redis 复制代码
SCAN 0 COUNT 100

很容易把它误解为:

text 复制代码
这一次必须返回 100 个 key

其实不是。

COUNT 更准确地理解为:

向 Redis 提供一个本轮扫描工作量的建议值。

也就是说:

text 复制代码
COUNT 100

更加接近:

text 复制代码
"这一轮尽量按照大约 100 左右的工作量去处理"

而不是:

text 复制代码
"必须精确返回 100 个元素"

因此实际返回数量可能:

text 复制代码
小于 100
大于 100
甚至为 0

并且客户端在不同轮 SCAN 中,也不要求使用完全相同的 COUNT


4. TYPE:进一步按照 Redis 数据类型过滤

当前 Redis 的 SCAN 还可以使用:

redis 复制代码
TYPE

例如:

redis 复制代码
SCAN 0 TYPE zset

用于只返回对应 Redis 类型的 key。

不过对于我们当前理解 SCAN 底层遍历机制来说,最核心的仍然是:

text 复制代码
cursor

因为真正决定渐进式遍历如何连续推进的,就是 cursor。


四、cursor 到底是什么:不要把它理解成普通数组下标

认识了 SCAN 的基本用法以后,很自然会产生一个想法:

text 复制代码
第一次返回 cursor = 24

是不是表示:
已经扫描了前 24 个 key?

下一次是不是:
从第 24 个 key 开始继续?

答案是否定的。

这里一定要建立一个非常重要的认识:

text 复制代码
cursor ≠ key 的数组下标
cursor ≠ 已经遍历的 key 数量
cursor ≠ 下一次从第几个 key 开始

cursor 更准确的理解应该是:

用于延续哈希表渐进式遍历状态的逻辑标识。

例如:

text 复制代码
SCAN 0
→ cursor = 24

SCAN 24
→ cursor = 6

SCAN 6
→ cursor = 37

SCAN 37
→ cursor = 0

这个过程完全可能:

text 复制代码
0 → 24 → 6 → 37 → 0

而不是:

text 复制代码
0 → 1 → 2 → 3 → 4

所以从上层十进制数值来看:

cursor 并不存在普通数组下标那种单调递增关系。


五、SCAN 为什么可以是"无状态"的

这里还有一个非常值得理解的问题。

Redis 是一个 Client / Server 网络服务程序:

text 复制代码
Client A
Client B
Client C
...
       ↓
Redis Server

不同客户端完全可以同时进行自己的 SCAN

text 复制代码
Client A:
SCAN 0
→ cursor = 24

Client B:
SCAN 0
→ cursor = 56

那么 Redis 是否需要维护:

text 复制代码
Client A → ScanContextA
Client B → ScanContextB

然后 cursor 再作为 ScanContext 的 ID?

并不是。


1. cursor 不是服务器内部 SCAN Context 的编号

我们此前在编写网络服务器时,可能会使用:

text 复制代码
socket fd
    ↓
定位 Connection Object
    ↓
Connection Object 中保存:
连接状态
输入缓冲区
输出缓冲区
用户信息
回调函数
...

这种情况下:

text 复制代码
fd

更像是:

用于定位服务器内部连接上下文对象的标识符。

但是 SCAN cursor 并不是这样。

Redis 并不存在一个简单的:

text 复制代码
cursor = 24
       ↓
scan_context[24]
       ↓
找到某个客户端此前的遍历对象

的机制。


2. cursor 本身参与下一轮遍历计算

Redis 更接近于:

text 复制代码
客户端传入 cursor
        ↓
Redis 查看当前哈希表状态
        ↓
根据 cursor + 哈希表 mask
计算本轮应该访问的位置
        ↓
扫描
        ↓
计算新的 cursor
        ↓
返回客户端

所以 Redis 不需要关心:

text 复制代码
这是 Client A 的 cursor
还是 Client B 的 cursor

如果两个客户端在哈希表状态一致的情况下,都发送:

redis 复制代码
SCAN 24

那么这个 24 表示的是相同的逻辑遍历位置。

这也是为什么:

SCAN 可以做到非常弱状态甚至近似无服务器会话状态的遍历。

客户端自己保存 cursor,并在下一次请求时重新传给 Redis 即可。


六、SCAN 真正遍历的是什么:Hash Bucket

想继续理解 cursor,就必须下沉到 Redis 哈希表。

Redis 的 Key Space 底层可以先抽象理解成一张哈希表:

text 复制代码
Hash Table
    ↓
Bucket Array

桶数组本质上是一段连续的桶入口:

text 复制代码
bucket[0]
bucket[1]
bucket[2]
bucket[3]
...

发生哈希冲突时,同一个桶下面可以挂载多个 entry。

因此可以抽象成:

text 复制代码
bucket[0] → entry → entry
bucket[1] → entry
bucket[2] → entry → entry → entry
bucket[3] → NULL
...

每一个 entry 中保存对应的键值信息。


1. SCAN 的基本遍历粒度是 Bucket

这里非常重要。

SCAN 并不是说:

text 复制代码
本轮遍历 bucket[2] 中前两个节点
下轮再回来处理剩下三个节点

不是这样的。

如果当前已经决定访问:

text 复制代码
bucket[2]

那么这个桶中挂载的 entry 链会被这一轮完整处理。

例如:

text 复制代码
bucket[2]
   ↓
A → B → C → D

当前轮访问到 bucket[2] 后,会把:

text 复制代码
A
B
C
D

这条链处理完。

所以我们可以把当前心智模型建立为:

Bucket 是 SCAN 底层遍历时的基本处理单元。


2. 一次 SCAN 不一定只扫描一个 Bucket

这里也要注意。

一次:

redis 复制代码
SCAN cursor COUNT 100

并不意味着:

text 复制代码
只访问一个 bucket

一次命令可能会访问多个 bucket。

整体可以抽象成:

text 复制代码
一次 SCAN
    ↓
访问若干 bucket
    ↓
每个被访问的 bucket
其挂载链表完整遍历
    ↓
得到本轮结果
    ↓
返回 next cursor

因此:

text 复制代码
COUNT

本身也不能简单理解成:

text 复制代码
精确扫描 N 个 bucket

它仍然只是一个工作量建议值。


七、"渐进式"并不等于一条命令执行到一半被打断

认识到 SCAN 一次只做一小部分工作以后,还容易产生另一个误区:

text 复制代码
SCAN 执行一部分
    ↓
Redis 切出去执行 SET
    ↓
回来继续同一个 SCAN

不是这样。

Redis 对单条命令的执行仍然是完整的。

例如:

text 复制代码
SCAN 第一次调用
        ↓
扫描当前这一小部分
        ↓
生成 cursor
        ↓
返回客户端
        ↓
当前命令执行结束

然后 Redis 才能继续处理:

text 复制代码
GET
SET
DEL
HSET
...

之后客户端再次发送:

redis 复制代码
SCAN next_cursor

Redis 再执行下一小段。

所以:

SCAN 的渐进式,不是"同一条命令内部被其他命令抢占",而是把一次完整的大遍历拆成了很多条彼此独立的小 SCAN 命令。

可以形象地理解为:

text 复制代码
KEYS:

██████████████████████████
一次性做完


SCAN:

███
   ↓
其他命令

███
   ↓
其他命令

███
   ↓
其他命令

这才是 SCAN 能够减小长时间阻塞风险的真正原因。


八、为什么 cursor 不能简单设计成 bucket0、bucket1、bucket2......

如果哈希表永远不会变化,那么 cursor 最简单的设计当然可以是:

text 复制代码
0
1
2
3
4
...

例如:

text 复制代码
cursor = 0
→ bucket[0]

cursor = 1
→ bucket[1]

cursor = 2
→ bucket[2]

但是 Redis 哈希表并不是永远静止不变的。

它会发生:

text 复制代码
扩容
缩容
rehash

尤其是在数据量不断增加时,哈希表需要扩容。

这就使问题变得复杂起来。


九、先理解 Redis 哈希表扩容:为什么一个旧桶会"分裂"

Redis 哈希表的桶数量通常按照 2 的幂进行组织。

例如:

text 复制代码
4 个桶
8 个桶
16 个桶
32 个桶
...

假设当前有:

text 复制代码
4 个 bucket

那么:

text 复制代码
size = 4
sizemask = 4 - 1 = 3
         = 011₂

计算 key 所在桶时,可以抽象成:

text 复制代码
index = hash(key) & sizemask

由于 mask 为:

text 复制代码
011

实际上只会观察 hash 的低 2 位。


1. 4 个桶时只看 hash 的低 2 位

例如两个 key 的 hash 可以抽象成:

text 复制代码
hashA = ...010
hashB = ...110

当前只有 4 个桶:

text 复制代码
sizemask = 011

于是:

text 复制代码
hashA:
...010
&
...011
=
...010
= bucket[2]

对于 hashB:

text 复制代码
hashB:
...110
&
...011
=
...010
= bucket[2]

所以:

text 复制代码
bucket[2]
   ↓
A → B

两个 entry 被放在同一个桶中。


2. 扩容到 8 个桶以后多看一位

现在:

text 复制代码
4 → 8

新的:

text 复制代码
size = 8
sizemask = 7
         = 111₂

原来:

text 复制代码
011

现在变成:

text 复制代码
111

也就是说:

桶下标计算从"观察 hash 的低 2 位",变成了"观察 hash 的低 3 位"。

于是:

text 复制代码
hashA = ...010

重新计算:

text 复制代码
...010 & ...111
=
...010
=
bucket[2]

而:

text 复制代码
hashB = ...110

重新计算:

text 复制代码
...110 & ...111
=
...110
=
bucket[6]

于是原来的:

text 复制代码
bucket[2]
   ↓
A → B

扩容之后变成:

text 复制代码
bucket[2] → A

bucket[6] → B

3. 一个旧桶扩容后只会对应固定的新桶

对于:

text 复制代码
4 → 8

原来旧表只看低 2 位。

扩容之后多看一位。

因此旧:

text 复制代码
bucket[2] = 10

扩容以后,新增加的那一位只有:

text 复制代码
0
或者
1

两种情况。

于是:

text 复制代码
010 → bucket[2]
110 → bucket[6]

所以:

text 复制代码
旧 bucket[2]
      ↓
   ┌──┴──┐
新 2    新 6

推广以后:

text 复制代码
旧 bucket[0] → 新 bucket[0] / bucket[4]
旧 bucket[1] → 新 bucket[1] / bucket[5]
旧 bucket[2] → 新 bucket[2] / bucket[6]
旧 bucket[3] → 新 bucket[3] / bucket[7]

这里所谓的"桶分裂"并不是桶对象真的被切成两半。

而是:

原来挂在同一个旧桶中的 entry,在新的 mask 下重新计算桶下标以后,会重新分散到对应的新桶中。


十、Redis 为什么采用渐进式 Rehash

如果 Redis 中只有几十个 entry,那么:

text 复制代码
创建新表
↓
一次性重新计算所有 entry
↓
全部搬过去

问题并不明显。

但是如果哈希表中有:

text 复制代码
100 万
1000 万
甚至更多

entry,一次性完成整个 rehash 本身就是一个非常耗时的操作。

这又会回到我们文章最开始的问题:

text 复制代码
耗时操作
    ↓
长时间占用命令执行线程
    ↓
其他命令等待

所以 Redis 的 rehash 同样采用了:

渐进式迁移。


1. Rehash 期间新旧两张表暂时并存

扩容开始以后,可以先理解为:

text 复制代码
旧表 ht[0]
    ↓
仍然存在

新表 ht[1]
    ↓
已经创建

于是某一阶段可能是:

text 复制代码
ht[0]:还有大量旧 bucket 没有迁移

ht[1]:已经接收了一部分 entry

Redis 再通过:

text 复制代码
rehashidx

记录当前渐进式迁移推进到了旧表的什么位置。

整体可以抽象成:

text 复制代码
旧表 ht[0]
bucket[0]  已迁移
bucket[1]  已迁移
bucket[2]  正在 / 等待迁移
bucket[3]  等待迁移
...

        ↓ 渐进式 rehash

新表 ht[1]
逐步接收 entry

直到旧表最终被迁空:

text 复制代码
ht[0] → 释放

ht[1] → 成为新的主哈希表

十一、这就给 SCAN 带来了真正的难题

现在把两条线合在一起:

text 复制代码
SCAN
→ 正在渐进式遍历哈希表

与此同时:

Redis Hash Table
→ 可能正在渐进式 rehash

也就是说,两次 SCAN 之间:

text 复制代码
第一次 SCAN
    ↓
返回 cursor

Redis 执行其他命令
    ↓
触发 / 推进 rehash
    ↓
桶数组大小发生变化

第二次 SCAN
    ↓
继续使用刚才的 cursor

于是问题出现了:

如果 cursor 只是普通的 0 → 1 → 2 → 3 桶下标,那么哈希表大小突然发生变化以后,之前的扫描进度应该如何继续?

这就是 Redis 没有简单使用:

text 复制代码
cursor++

顺序遍历 bucket 的重要原因。


十二、cursor 与 bucket 下标之间到底是什么关系

这里需要把:

text 复制代码
cursor

和:

text 复制代码
bucket index

区分开。

cursor 本身是一个完整位宽的整数。

但是当前真正要定位哪个 bucket,需要结合当前哈希表的:

text 复制代码
sizemask

进行计算。

也就是:

text 复制代码
bucket_index = cursor & sizemask

假设当前有 8 个桶:

text 复制代码
size = 8
sizemask = 111₂

那么无论 cursor 的高位是什么:

text 复制代码
cursor = ...10110110

真正参与桶定位的只有低 3 位:

text 复制代码
...10110110
&
...00000111
=
...00000110

最终就是:

text 复制代码
bucket[6]

所以需要建立一个非常重要的认识:

cursor 自身是完整整数,但是当前哈希表真正用于 bucket 定位的,只是经过 sizemask 截取出来的低位部分。


十三、Reverse Cursor:为什么 SCAN 不是 0、1、2、3 顺序遍历

对于一个 4 个桶的哈希表:

text 复制代码
bucket[0] = 00
bucket[1] = 01
bucket[2] = 10
bucket[3] = 11

普通数组遍历自然是:

text 复制代码
00
01
10
11

也就是:

text 复制代码
0 → 1 → 2 → 3

但是 SCAN 的逻辑遍历顺序并不是这样。

如果只观察与当前桶定位相关的低 2 位,其顺序可以表现为:

text 复制代码
00
10
01
11

也就是:

text 复制代码
0 → 2 → 1 → 3

这是一种:

高位优先变化的位反转式遍历顺序。


1. 为什么称为"位反转"

普通递增顺序:

text 复制代码
0 = 00
1 = 01
2 = 10
3 = 11

把这 2 位左右反转:

text 复制代码
00 → 00
01 → 10
10 → 01
11 → 11

就得到:

text 复制代码
00
10
01
11

也就是:

text 复制代码
0
2
1
3

因此,从逻辑效果上看:

text 复制代码
普通递增:
低位优先变化

SCAN:
高位优先变化

2. 真正的 cursor 并不只有这 2 位

这里一定要避免另一个误区。

不是说 Redis 真的只拿:

text 复制代码
00
01
10
11

这几位进行整个 cursor 运算。

真正的 cursor 是一个完整位宽整数,底层位反转推进也是围绕完整整数完成的。

我们之所以只画:

text 复制代码
00 → 10 → 01 → 11

是因为:

text 复制代码
当前哈希表只有 4 个桶
        ↓
sizemask 只保留低 2 位
        ↓
只有这两位最终影响 bucket index

所以从"桶访问顺序"的视角看,才表现成:

text 复制代码
0 → 2 → 1 → 3

十四、位反转顺序到底有什么用

现在重新回到:

text 复制代码
4 个桶 → 8 个桶

扩容前 4 个桶的位反转逻辑顺序:

text 复制代码
00
10
01
11

也就是:

text 复制代码
0 → 2 → 1 → 3

扩容到 8 个桶后,需要观察低 3 位。

对应顺序变成:

text 复制代码
000
100
010
110
001
101
011
111

也就是:

text 复制代码
0 → 4 → 2 → 6 → 1 → 5 → 3 → 7

现在再回头看我们此前得到的"旧桶分裂关系":

text 复制代码
旧 0 → 新 0 / 新 4
旧 1 → 新 1 / 新 5
旧 2 → 新 2 / 新 6
旧 3 → 新 3 / 新 7

再看新的遍历顺序:

text 复制代码
0 → 4 → 2 → 6 → 1 → 5 → 3 → 7

就会发现:

text 复制代码
旧 bucket[0]
扩展得到:
0 / 4

遍历顺序:
0 → 4

旧:

text 复制代码
bucket[2]

扩展得到:

text 复制代码
2 / 6

遍历顺序:

text 复制代码
2 → 6

同理:

text 复制代码
1 → 5
3 → 7

于是位反转顺序最核心的价值就体现出来了:

同一个旧桶扩容以后所对应的多个新桶,在 SCAN 的逻辑遍历顺序中天然被组织在同一个连续的逻辑区域中。

这使 Redis 在哈希表大小发生变化时,仍然能够根据旧的逻辑位置找到与之对应的扩展桶。


十五、Rehash 期间 SCAN 如何同时覆盖小表和大表

现在已经有了全部前置知识,就可以正式理解 SCAN 在 rehash 期间的行为。

假设:

text 复制代码
旧表:4 个桶
新表:8 个桶

此时两张表同时存在。

为了避免某个 entry:

text 复制代码
还在旧表

或者:

text 复制代码
已经迁移到了新表

而导致遗漏,SCAN 在 rehash 期间不会只看其中一张表。

其核心思路可以理解为:

以较小哈希表的当前逻辑桶作为基准,同时扫描大表中由这个小桶扩展出来的对应桶。


1. 以 bucket2 为例

小表:

text 复制代码
size = 4
mask = 011

大表:

text 复制代码
size = 8
mask = 111

当前逻辑位置对应小表:

text 复制代码
bucket[2]

那么 Redis 会考虑:

text 复制代码
小表:
bucket[2]

同时还要处理大表中由旧 bucket[2] 扩展出来的:

text 复制代码
bucket[2]
bucket[6]

也就是:

text 复制代码
小表 bucket[2]
        +
大表 bucket[2]
大表 bucket[6]

这样一来:

text 复制代码
entry 还没迁移
→ 有机会在小表 bucket[2] 中扫描到

entry 已经迁移
→ 有机会在大表 bucket[2] / bucket[6] 中扫描到

这就是位反转顺序与渐进式 rehash 配合的核心。


十六、扩容发生以后,客户端手里的 cursor 会不会被修改

这里还有一个非常重要的问题。

假设客户端第一次:

text 复制代码
SCAN ...
    ↓
Redis 返回 cursor = X

客户端把:

text 复制代码
X

保存在自己这里。

此时 Redis 正好开始或者推进:

text 复制代码
4 → 8

扩容。

Redis 并不会:

text 复制代码
找到这个客户端
    ↓
修改它保存的 cursor

客户端手里的 cursor 不会被 Redis 主动修改。

下一次客户端仍然是:

redis 复制代码
SCAN X

关键在于:

Redis 收到这个 cursor 后,会结合"此刻"的哈希表状态重新解释这个逻辑位置应该如何扫描。

于是:

text 复制代码
cursor
    ↓
客户端原样传回

Redis Server
    ↓
检查此刻是否 rehash
    ↓
检查小表 / 大表大小
    ↓
结合对应 mask
    ↓
扫描小表当前桶
    +
扫描大表扩展桶

因此:

扩容改变的不是客户端手里的 cursor,而是 Redis 根据 cursor 和当前哈希表状态决定扫描位置的过程。


1. Rehash 完成以后也不需要额外"切换 SCAN 状态"

这里同样体现出了 SCAN 的无状态设计。

Redis 不需要维护:

text 复制代码
当前某个 Client 的 SCAN
正在按小表遍历

这样的 Context。

每次 SCAN 都重新查看:

text 复制代码
当前哈希表状态

如果:

text 复制代码
rehash 还没完成

那么:

text 复制代码
小表 + 大表

一起考虑。

如果:

text 复制代码
rehash 已经完成

那么此时只剩新的哈希表,Redis 就自然按照新表:

text 复制代码
cursor & new_mask

继续解释 cursor。

所以整个过程中都不需要额外维护"客户端扫描到了哪张表"的服务器状态。


十七、SCAN 的结果为什么可能重复:它不是快照遍历

理解了前面的机制以后,还需要认识 SCAN 的一个重要特性:

SCAN 并不会在遍历开始时把整个 Key Space 冻结下来。

例如:

text 复制代码
SCAN 第 1 轮
    ↓
返回 cursor

Client B:
SET new_key xxx

Client C:
DEL old_key

Redis:
推进 rehash

SCAN 第 2 轮
    ↓
继续遍历

整个过程中,Key Space 可以持续变化。

所以 SCAN 看到的并不是:

text 复制代码
某一个时刻完整冻结的数据库快照

而是一段时间内不断变化的数据集合。


1. 同一个元素可能被返回多次

由于:

text 复制代码
rehash
桶迁移
哈希表大小变化

等因素存在,完整遍历过程中:

text 复制代码
同一个 key

可能被返回不止一次。

因此如果上层业务需要对每一个扫描结果执行操作,例如:

text 复制代码
删除
迁移
统计
更新

最好保证这个操作:

text 复制代码
可重复执行
或者
具备幂等性

而不能假设:

text 复制代码
SCAN 天然保证每一个 key 只返回一次

2. 始终存在于整个遍历期间的元素能够被覆盖

虽然 SCAN 不是快照,也可能返回重复元素,但是它仍然提供基本的完整迭代保证。

可以先这样理解:

text 复制代码
某个 key
在完整 SCAN 开始之前就存在
并且
直到完整 SCAN 结束时始终存在

那么这次完整遍历应该能够扫描到它。

而如果某个 key:

text 复制代码
遍历中途才插入
或者
遍历中途被删除

那么它是否一定出现在本轮结果中,就不能做同样的确定性保证。


3. 返回 0 个 key 不等于结束

这里再强调一次:

text 复制代码
本轮结果为空

不代表:

text 复制代码
SCAN 结束

例如:

text 复制代码
cursor = 38
keys = []

只说明:

text 复制代码
这一轮没有得到需要返回的元素

只要:

text 复制代码
cursor != 0

就必须继续下一轮。

完整遍历结束的唯一核心判断仍然是:

text 复制代码
cursor == 0

十八、KEYS 与 SCAN 的核心区别

至此,可以把两者的核心区别整理为:

对比维度 KEYS SCAN
遍历方式 一次全量遍历 渐进式遍历
完整遍历是否拆分
单次命令工作量 可能非常大 通常较小,可通过 COUNT 调节
是否需要 cursor
是否适合大 Key Space 常规遍历 风险较高 更合适
是否是快照式结果 一次命令期间完成 不是完整快照
是否可能重复返回元素 不涉及多轮遍历 可能
完整遍历复杂度 O(N) 总体仍然是 O(N)
对其他命令的影响 大数据量时可能长时间阻塞 将工作拆开,使其他命令可在多轮之间执行

这里需要注意:

SCAN 并没有把一次完整遍历的总工作量从 O(N) 变成 O(1)。

它解决的并不是:

text 复制代码
总共不用扫描这么多数据了

而是:

text 复制代码
原来:
一次命令连续完成 O(N)

现在:
把 O(N) 拆到很多次较小的 SCAN 中完成

因此:

text 复制代码
总工作量仍然存在

但是:

text 复制代码
单次连续占用 Redis 命令执行线程的时间被显著拆散

这才是渐进式遍历真正解决的问题。


最终心智模型

现在可以把整个 KEYS → SCAN → cursor → bit reversal → rehash 的逻辑链完整串起来。

text 复制代码
Redis 核心数据命令主要串行执行
        ↓
普通内存操作通常非常轻量
        ↓
如果出现长时间执行的命令
        ↓
会影响后续请求的处理
        ↓
KEYS 需要一次遍历完整 Key Space
        ↓
大数据量下存在明显风险
        ↓
Redis 提供 SCAN
        ↓
把一次完整遍历拆成多次独立的小遍历
        ↓
每次返回:
部分结果 + next cursor
        ↓
cursor 不是 key 数量,也不是普通数组下标
        ↓
cursor 用于延续哈希桶遍历的逻辑状态
        ↓
Redis 不需要为每个客户端保存 ScanContext
        ↓
Client 自己保存 cursor
        ↓
下一次请求再把 cursor 传回来
        ↓
Redis 根据 cursor + 当前哈希表状态继续计算
        ↓
SCAN 的基本遍历单位是 Hash Bucket
        ↓
一个被访问的 bucket,其 entry 链会完整处理
        ↓
但是 Hash Table 自身可能正在渐进式 rehash
        ↓
旧表与新表暂时同时存在
        ↓
2 倍扩容以后:
一个旧 bucket 会对应两个新 bucket
        ↓
如果 cursor 只是普通 0、1、2、3 顺序
很难优雅适配桶数量动态变化
        ↓
Redis 使用位反转式 cursor 推进
        ↓
4 个桶:
0 → 2 → 1 → 3

8 个桶:
0 → 4 → 2 → 6 → 1 → 5 → 3 → 7
        ↓
旧桶扩展出的新桶
在逻辑遍历顺序中天然聚在一起
        ↓
rehash 期间:
扫描小表当前桶
+
扫描大表对应扩展桶
        ↓
尽可能保证完整遍历期间持续存在的元素不会被漏掉

所以对于 SCAN,真正值得记住的并不只是:

redis 复制代码
SCAN cursor MATCH pattern COUNT count

这条命令格式。

更重要的是理解它背后的设计思想:

Redis 并没有试图让一次遍历"做得更快",而是通过 cursor 把大任务拆成很多小任务;同时利用哈希表 2 的幂扩容特性以及位反转遍历顺序,使这种无状态的渐进式遍历即使遇到 rehash,也仍然能够继续推进。

这才是 SCAN 命令底层设计真正巧妙的地方。


相关推荐
坐吃山猪2 小时前
【多线程】Lock与Condition
大数据·数据库
互联网叫兽2 小时前
redis深入学习一
redis
小宋10212 小时前
大模型成本怎么控制:Token统计、缓存与模型路由实战
人工智能·缓存
Lightpwd2 小时前
Spring Boot 多数据源落地:AbstractRoutingDataSource + 注解切面(附源码)
数据库·后端
LabVIEW开发3 小时前
LabVIEW 64位安装的位深陷阱:工具包、内存与工程兼容
数据库·labview·labview知识·labview功能·labview程序
寺中人3 小时前
MySQL 8.0 Windows 完整安装教程:环境配置、密码重置与常见报错排查
数据库·windows·mysql·环境搭建·mysql 安装
这个DBA有点耶3 小时前
同样48核配置TPS差1倍?高性价比数据库一体机的“软硬协同”才是分水岭
服务器·数据库·架构
这个DBA有点耶3 小时前
MySQL 8.0执行计划分析利器:EXPLAIN ANALYZE到底比EXPLAIN强在哪?
数据库·mysql·代码规范