🔥 本文专栏:Redis
🌸作者主页:努力努力再努力wz

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

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


思维导图

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 命令时,我们已经接触过:

KEYS pattern

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

例如:

KEYS *

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

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

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

但是问题在于:

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

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

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

1 万个 key
10 万个 key
100 万个 key
1000 万个 key
...

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

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


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

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

Redis 本质上是一个:

Client / Server

架构的网络服务程序。

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

Client A ─┐
Client B ─┼──→ Redis Server
Client C ─┘

客户端不断发送:

GET
SET
HSET
ZADD
DEL
SCAN
...

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

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

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

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

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

GET / SET / HSET / ZADD / KEYS / SCAN

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

Command 1
    ↓
执行完成

Command 2
    ↓
执行完成

Command 3
    ↓
执行完成

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


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

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

例如:

GET name
SET age 20
HGET user:1 name

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

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

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

那么反而会引入:

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

等额外成本。

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

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

是一种非常合理的设计。

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

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

否则:

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

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


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

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

例如:

KEYS user:*

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

key1
key2
key3
...
keyN

然后判断:

当前 key 是否匹配 user:*

因此从整体上看:

KEYS
  ↓
遍历整个 Key Space
  ↓
O(N)

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

O(N)

而在于:

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

例如:

Client A:
KEYS *
   ↓
Redis 开始全量遍历
████████████████████████

此时:

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

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

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


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

既然问题来自:

一次命令做了太多工作

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

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

于是:

一次全量扫描

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

被拆成:

███
   ↓
其他命令

███
   ↓
其他命令

███
   ↓
其他命令

...

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

渐进式遍历。


三、SCAN 的基本使用模型

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

SCAN cursor [MATCH pattern] [COUNT count] [TYPE type]

其中最关键的是:

cursor

它是必选参数。

而:

MATCH
COUNT
TYPE

属于可选条件。


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

例如:

SCAN 0

这里的 0 表示:

开启一次新的完整迭代。

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

新的 cursor
+
本轮得到的一批 key

例如可以抽象成:

SCAN 0
    ↓
返回:
cursor = 24
[key1, key2, key3 ...]

下一次客户端继续发送:

SCAN 24

Redis 再完成下一小段遍历:

SCAN 24
    ↓
返回:
cursor = 6
[key4, key5 ...]

继续:

SCAN 6

直到最终某一次返回:

cursor = 0

才表示:

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

所以:

cursor = 0

既可以表示:

开始一次新的完整遍历

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

本轮完整遍历已经结束

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

例如:

SCAN 0 MATCH user:*

这里表示:

遍历 Key Space
      ↓
只返回匹配 user:* 的 key

需要注意:

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

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

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

本轮返回 key 数量 = 0

但是:

cursor != 0

这并不表示遍历结束。

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

cursor == 0

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

例如:

SCAN 0 COUNT 100

很容易把它误解为:

这一次必须返回 100 个 key

其实不是。

COUNT 更准确地理解为:

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

也就是说:

COUNT 100

更加接近:

“这一轮尽量按照大约 100 左右的工作量去处理”

而不是:

“必须精确返回 100 个元素”

因此实际返回数量可能:

小于 100
大于 100
甚至为 0

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


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

当前 Redis 的 SCAN 还可以使用:

TYPE

例如:

SCAN 0 TYPE zset

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

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

cursor

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


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

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

第一次返回 cursor = 24

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

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

答案是否定的。

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

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

cursor 更准确的理解应该是:

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

例如:

SCAN 0
→ cursor = 24

SCAN 24
→ cursor = 6

SCAN 6
→ cursor = 37

SCAN 37
→ cursor = 0

这个过程完全可能:

0 → 24 → 6 → 37 → 0

而不是:

0 → 1 → 2 → 3 → 4

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

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


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

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

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

Client A
Client B
Client C
...
       ↓
Redis Server

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

Client A:
SCAN 0
→ cursor = 24

Client B:
SCAN 0
→ cursor = 56

那么 Redis 是否需要维护:

Client A → ScanContextA
Client B → ScanContextB

然后 cursor 再作为 ScanContext 的 ID?

并不是。


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

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

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

这种情况下:

fd

更像是:

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

但是 SCAN cursor 并不是这样。

Redis 并不存在一个简单的:

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

的机制。


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

Redis 更接近于:

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

所以 Redis 不需要关心:

这是 Client A 的 cursor
还是 Client B 的 cursor

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

SCAN 24

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

这也是为什么:

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

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


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

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

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

Hash Table
    ↓
Bucket Array

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

bucket[0]
bucket[1]
bucket[2]
bucket[3]
...

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

因此可以抽象成:

bucket[0] → entry → entry
bucket[1] → entry
bucket[2] → entry → entry → entry
bucket[3] → NULL
...

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


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

这里非常重要。

SCAN 并不是说:

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

不是这样的。

如果当前已经决定访问:

bucket[2]

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

例如:

bucket[2]
   ↓
A → B → C → D

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

A
B
C
D

这条链处理完。

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

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


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

这里也要注意。

一次:

SCAN cursor COUNT 100

并不意味着:

只访问一个 bucket

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

整体可以抽象成:

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

因此:

COUNT

本身也不能简单理解成:

精确扫描 N 个 bucket

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


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

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

SCAN 执行一部分
    ↓
Redis 切出去执行 SET
    ↓
回来继续同一个 SCAN

不是这样。

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

例如:

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

然后 Redis 才能继续处理:

GET
SET
DEL
HSET
...

之后客户端再次发送:

SCAN next_cursor

Redis 再执行下一小段。

所以:

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

可以形象地理解为:

KEYS:

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


SCAN:

███
   ↓
其他命令

███
   ↓
其他命令

███
   ↓
其他命令

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


八、为什么 cursor 不能简单设计成 bucket[0]、bucket[1]、bucket[2]……

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

0
1
2
3
4
...

例如:

cursor = 0
→ bucket[0]

cursor = 1
→ bucket[1]

cursor = 2
→ bucket[2]

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

它会发生:

扩容
缩容
rehash

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

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


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

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

例如:

4 个桶
8 个桶
16 个桶
32 个桶
...

假设当前有:

4 个 bucket

那么:

size = 4
sizemask = 4 - 1 = 3
         = 011₂

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

index = hash(key) & sizemask

由于 mask 为:

011

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


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

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

hashA = ...010
hashB = ...110

当前只有 4 个桶:

sizemask = 011

于是:

hashA:
...010
&
...011
=
...010
= bucket[2]

对于 hashB:

hashB:
...110
&
...011
=
...010
= bucket[2]

所以:

bucket[2]
   ↓
A → B

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


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

现在:

4 → 8

新的:

size = 8
sizemask = 7
         = 111₂

原来:

011

现在变成:

111

也就是说:

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

于是:

hashA = ...010

重新计算:

...010 & ...111
=
...010
=
bucket[2]

而:

hashB = ...110

重新计算:

...110 & ...111
=
...110
=
bucket[6]

于是原来的:

bucket[2]
   ↓
A → B

扩容之后变成:

bucket[2] → A

bucket[6] → B

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

对于:

4 → 8

原来旧表只看低 2 位。

扩容之后多看一位。

因此旧:

bucket[2] = 10

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

0
或者
1

两种情况。

于是:

010 → bucket[2]
110 → bucket[6]

所以:

旧 bucket[2]
      ↓
   ┌──┴──┐
新 2    新 6

推广以后:

旧 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,那么:

创建新表
↓
一次性重新计算所有 entry
↓
全部搬过去

问题并不明显。

但是如果哈希表中有:

100 万
1000 万
甚至更多

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

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

耗时操作
    ↓
长时间占用命令执行线程
    ↓
其他命令等待

所以 Redis 的 rehash 同样采用了:

渐进式迁移。


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

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

旧表 ht[0]
    ↓
仍然存在

新表 ht[1]
    ↓
已经创建

于是某一阶段可能是:

ht[0]:还有大量旧 bucket 没有迁移

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

Redis 再通过:

rehashidx

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

整体可以抽象成:

旧表 ht[0]
bucket[0]  已迁移
bucket[1]  已迁移
bucket[2]  正在 / 等待迁移
bucket[3]  等待迁移
...

        ↓ 渐进式 rehash

新表 ht[1]
逐步接收 entry

直到旧表最终被迁空:

ht[0] → 释放

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

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

现在把两条线合在一起:

SCAN
→ 正在渐进式遍历哈希表

与此同时:

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

也就是说,两次 SCAN 之间:

第一次 SCAN
    ↓
返回 cursor

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

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

于是问题出现了:

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

这就是 Redis 没有简单使用:

cursor++

顺序遍历 bucket 的重要原因。


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

这里需要把:

cursor

和:

bucket index

区分开。

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

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

sizemask

进行计算。

也就是:

bucket_index = cursor & sizemask

假设当前有 8 个桶:

size = 8
sizemask = 111₂

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

cursor = ...10110110

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

...10110110
&
...00000111
=
...00000110

最终就是:

bucket[6]

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

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


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

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

bucket[0] = 00
bucket[1] = 01
bucket[2] = 10
bucket[3] = 11

普通数组遍历自然是:

00
01
10
11

也就是:

0 → 1 → 2 → 3

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

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

00
10
01
11

也就是:

0 → 2 → 1 → 3

这是一种:

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


1. 为什么称为“位反转”

普通递增顺序:

0 = 00
1 = 01
2 = 10
3 = 11

把这 2 位左右反转:

00 → 00
01 → 10
10 → 01
11 → 11

就得到:

00
10
01
11

也就是:

0
2
1
3

因此,从逻辑效果上看:

普通递增:
低位优先变化

SCAN:
高位优先变化

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

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

不是说 Redis 真的只拿:

00
01
10
11

这几位进行整个 cursor 运算。

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

我们之所以只画:

00 → 10 → 01 → 11

是因为:

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

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

0 → 2 → 1 → 3

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

现在重新回到:

4 个桶 → 8 个桶

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

00
10
01
11

也就是:

0 → 2 → 1 → 3

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

对应顺序变成:

000
100
010
110
001
101
011
111

也就是:

0 → 4 → 2 → 6 → 1 → 5 → 3 → 7

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

旧 0 → 新 0 / 新 4
旧 1 → 新 1 / 新 5
旧 2 → 新 2 / 新 6
旧 3 → 新 3 / 新 7

再看新的遍历顺序:

0 → 4 → 2 → 6 → 1 → 5 → 3 → 7

就会发现:

旧 bucket[0]
扩展得到:
0 / 4

遍历顺序:
0 → 4

旧:

bucket[2]

扩展得到:

2 / 6

遍历顺序:

2 → 6

同理:

1 → 5
3 → 7

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

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

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


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

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

假设:

旧表:4 个桶
新表:8 个桶

此时两张表同时存在。

为了避免某个 entry:

还在旧表

或者:

已经迁移到了新表

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

其核心思路可以理解为:

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


1. 以 bucket[2] 为例

小表:

size = 4
mask = 011

大表:

size = 8
mask = 111

当前逻辑位置对应小表:

bucket[2]

那么 Redis 会考虑:

小表:
bucket[2]

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

bucket[2]
bucket[6]

也就是:

小表 bucket[2]
        +
大表 bucket[2]
大表 bucket[6]

这样一来:

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

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

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


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

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

假设客户端第一次:

SCAN ...
    ↓
Redis 返回 cursor = X

客户端把:

X

保存在自己这里。

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

4 → 8

扩容。

Redis 并不会:

找到这个客户端
    ↓
修改它保存的 cursor

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

下一次客户端仍然是:

SCAN X

关键在于:

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

于是:

cursor
    ↓
客户端原样传回

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

因此:

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


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

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

Redis 不需要维护:

当前某个 Client 的 SCAN
正在按小表遍历

这样的 Context。

每次 SCAN 都重新查看:

当前哈希表状态

如果:

rehash 还没完成

那么:

小表 + 大表

一起考虑。

如果:

rehash 已经完成

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

cursor & new_mask

继续解释 cursor。

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


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

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

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

例如:

SCAN 第 1 轮
    ↓
返回 cursor

Client B:
SET new_key xxx

Client C:
DEL old_key

Redis:
推进 rehash

SCAN 第 2 轮
    ↓
继续遍历

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

所以 SCAN 看到的并不是:

某一个时刻完整冻结的数据库快照

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


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

由于:

rehash
桶迁移
哈希表大小变化

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

同一个 key

可能被返回不止一次。

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

删除
迁移
统计
更新

最好保证这个操作:

可重复执行
或者
具备幂等性

而不能假设:

SCAN 天然保证每一个 key 只返回一次

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

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

可以先这样理解:

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

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

而如果某个 key:

遍历中途才插入
或者
遍历中途被删除

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


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

这里再强调一次:

本轮结果为空

不代表:

SCAN 结束

例如:

cursor = 38
keys = []

只说明:

这一轮没有得到需要返回的元素

只要:

cursor != 0

就必须继续下一轮。

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

cursor == 0

十八、KEYS 与 SCAN 的核心区别

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

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

这里需要注意:

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

它解决的并不是:

总共不用扫描这么多数据了

而是:

原来:
一次命令连续完成 O(N)

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

因此:

总工作量仍然存在

但是:

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

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


最终心智模型

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

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,真正值得记住的并不只是:

SCAN cursor MATCH pattern COUNT count

这条命令格式。

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

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

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


在这里插入图片描述

Logo

码道开发者社区,聚焦华为云码道 CodeArts 代码智能体,沉淀 Agent、Skill、鸿蒙开发实战内容,供开发者查阅资料、交流技术、分享工程实践

更多推荐