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



💪 今日博客励志语录:
真正把一个知识点学懂,往往不是记住结论,而是能够顺着“为什么”一步一步把它重新推导出来。
思维导图
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 的核心区别
至此,可以把两者的核心区别整理为:
| 对比维度 | KEYS | SCAN |
|---|---|---|
| 遍历方式 | 一次全量遍历 | 渐进式遍历 |
| 完整遍历是否拆分 | 否 | 是 |
| 单次命令工作量 | 可能非常大 | 通常较小,可通过 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 命令底层设计真正巧妙的地方。

更多推荐




所有评论(0)