转载:https://blog.csdn.net/qq_41144667/article/details/105297002
前言
✍Redis中有一个经典的问题,在巨大的数据量的情况下,做类似于查找符合某种规则的Key的信息,这里就有两种方式:
- keys命令,简单粗暴,由于Redis单线程这一特性,keys命令是以阻塞的方式执行的,keys是以遍历的方式实现的复杂度是 O(n),Redis库中的key越多,查找实现代价越大,产生的阻塞时间越长。
- scan命令,以非阻塞的方式实现key值的查找,绝大多数情况下是可以替代keys命令的,可选性更强,推荐此方式。
✍ 指令示例
Redis 提供了一个简单暴力的指令 keys 用来列出所有满足特定正则字符串规则的 key。
查询指令:
127.0.0.1:1>keys key*
查询结果:
- “key3”
- “key6”
- “key1”
- “key7”
- “key9”
- “key8”
- “key4”
- “key2”
- “key5”
这个指令使用非常简单,提供一个简单的正则字符串即可,但是有很明显的两个缺点。
- 没有 offset、limit 参数,一次性吐出所有满足条件的 key,万一实例中有几百 w 个 key 满足条件,当你看到满屏的字符串刷的没有尽头时,你就知道难受了。
- keys 算法是遍历算法,复杂度是 O(n),如果实例中有千万级以上的 key,这个指令就会导致 Redis 服务卡顿,所有读写 Redis 的其它的指令都会被延后甚至会超时报错,因为 Redis 是单线程程序,顺序执行所有指令,其它指令必须等到当前的 keys 指令执行完了才可以继续。
- 建议生产环境屏蔽keys命令;
✍Redis 为了解决这个问题,它在 2.8 版本中加入了指令 scan 指令
✍ scan 相比 keys 具备有以下特点:
- 复杂度虽然也是 O(n),但是它是通过游标分步进行的,分次进行不会阻塞线程;
- 提供 limit 参数,可以控制每次返回结果的最大条数,limit 只是对增量式迭代命令的一种提示(hint),返回的结果可多可少;
- 同 keys 一样,它也提供模式匹配功能;
- 服务器不需要为游标保存状态,游标的唯一状态就是 scan 返回给客户端的游标整数;
- 返回的结果可能会有重复,需要客户端去重,这点非常重要;
- 遍历的过程中如果有数据修改,改动后的数据能不能遍历到是不确定的;
- 单次返回的结果是空的并不意味着遍历结束,而要看返回的游标值是否为零.
✍ scan 基础使用
语法:SCAN cursor [MATCH pattern] [COUNT count]
初始执行scan命令例如scan 0。SCAN命令是一个基于游标的迭代器。
这意味着命令每次被调用都需要使用上一次这个调用返回的游标作为该次调用的游标参数,以此来延续之前的迭代过程。当SCAN命令的游标参数被设置为0时,服务器将开始一次新的迭代,而当redis服务器向用户返回值为0的游标时,表示迭代已结束,这是唯一迭代结束的判定方式,而不能通过返回结果集是否为空判断迭代结束。
- scan 参数提供了三个参数,第一个是 cursor 整数值,第二个是 key 的正则模式,第三个是遍历的 limit hint。
- 第一次遍历时,cursor 值为 0,然后将返回结果中第一个整数值作为下一次遍历的 cursor。
- 一直遍历到返回的 cursor 值为 0 时结束。
查询指令:
127.0.0.1:1>scan 0 match key* count 20
查询结果:
查询指令:
127.0.0.1:1>scan 21 match key* count 20
查询结果:
返回结果分为两个部分:
- 第一部分即 1) :下一次迭代游标;
- 第二部分即 2) :本次迭代结果集。
注意: limit值20 不是限定返回结果的数量,而是限定服务器单次遍历的字典槽位数量(约等于)。
✍ scan 其他指令
scan 指令是一系列指令,除了可以遍历所有的 key 之外,还可以对指定的容器集合进行遍历。
- zscan 遍历 zset 集合元素;
- hscan 遍历 hash 字典的元素;
- sscan 遍历 set 集合的元素。
✍ 设置redis的key值的注意事项
平时业务开发中,应尽量避免大key的产生
原因: 有时候会因为业务人员使用不当,在 Redis 实例中会形成很大的对象,比如一个很大的 hash,一个很大的 zset 这都是经常出现的。这样的对象对 Redis 的集群数据迁移带来了很大的问题,因为在集群环境下,如果某个 key 太大,会让数据导致迁移卡顿。另外在内存分配上,如果一个 key 太大,那么当它需要扩容时,会一次性申请更大的一块内存,这也会导致卡顿。如果这个大 key 被删除,内存会一次性回收,卡顿现象会再一次产生。
2、Redis Scan原理
转载于:Redis scan 原理与踩坑
主要分析了 Redis Scan 命令基本使用和具体实现,包括 Count 参数与 Scan 总耗时的关系,以及核心的逆二进制迭代算法分析。
1. 概述
由于 Redis 是单线程在处理用户的命令,而 Keys 命令会一次性遍历所有 Key,于是在 命令执行过程中,无法执行其他命令。这就导致如果 Redis 中的 key 比较多,那么 Keys 命令执行时间就会比较长,从而阻塞 Redis。
所以很多教程都推荐使用 Scan 命令来代替 Keys,因为 Scan 可以限制每次遍历的 key 数量。
Keys 的缺点:
- 1)没有limit,我们只能一次性获取所有符合条件的key,如果结果有上百万条,那么等待你的就是“无穷无尽”的字符串输出。
- 2)keys命令是遍历算法,时间复杂度是O(N)。如我们刚才所说,这个命令非常容易导致Redis服务卡顿。因此,我们要尽量避免在生产环境使用该命令。
相比于keys命令,Scan命令有两个比较明显的优势:
- 1)Scan命令的时间复杂度虽然也是O(N),但它是分次进行的,不会阻塞线程。
- 2)Scan命令提供了 count 参数,可以控制每次遍历的集合数。
可以理解为 Scan 是渐进式的 Keys。
Scan 命令语法如下:
SCAN cursor [MATCH pattern] [COUNT count]
- cursor - 游标。
- pattern - 匹配的模式。
- count - 指定每次遍历多少个集合。
- 可以简单理解为每次遍历多少个元素
- 根据测试,推荐 Count大小为 1W。
Scan 返回值为数组,会返回一个游标+一系列的 Key
大致用法如下:
SCAN命令是基于游标的,每次调用后,都会返回一个游标,用于下一次迭代。当游标返回0时,表示迭代结束。
第一次 Scan 时指定游标为 0,表示开启新的一轮迭代,然后 Scan 命令返回一个新的游标,作为第二次 Scan 时的游标值继续迭代,一直到 Scan 返回游标为0,表示本轮迭代结束。
通过这个就可以看出,Scan 完成一次迭代,需要和 Redis 进行多次交互。
Scan 命令注意事项:
- 返回的结果可能会有重复,需要客户端去重复,这点非常重要;
- 遍历的过程中如果有数据修改,改动后的数据能不能遍历到是不确定的;
- 单次返回的结果是空的并不意味着遍历结束,而要看返回的游标值是否为零;
2. Scan 踩坑
使用时遇到一个 特殊场景,跨区域远程连接 Redis 并进行模糊查询,扫描所有指定前缀的 Key。
最开始也没多想,直接就是开始 Scan,然后 Count 参数指定的是 1000。
Redis 中大概几百万 Key。
最后发现这个接口需要几十上百秒才返回。
什么原因呢?
Scan 命令中的 Count 指定一次扫描多少 Key,这里指定为 1000,几百万Key就需要几千次迭代,即和 Redis 交互几千次,然后因为是远程连接,网络延迟比较大,所以耗时特别长。
最后将 Count 参数调大后,减少了交互次数,就好多了。
Count 参数越大,Redis 阻塞时间也会越长,需要取舍。
极限一点,Count 参数和总 Key 数一致时,Scan 命令就和 Keys 效果一样了。
Count 大小和 Scan 总耗时的关系如下图:
可以发现 Count 越大,总耗时就越短,不过越后面提升就越不明显了。
所以推荐的 Count 大小为 1W 左右。
如果不考虑 Redis 的阻塞,其实 Keys 比 Scan 会快很多,毕竟一次性处理,省去了多余的交互。
3. Scan原理
Redis使用了Hash表作为底层实现,原因不外乎高效且实现简单。类似于HashMap那样数组+链表的结构。其中第一维的数组大小为2n(n>=0)。每次扩容数组长度扩大一倍。
Scan命令就是对这个一维数组进行遍历。每次返回的游标值也都是这个数组的索引。Count 参数表示遍历多少个数组的元素,将这些元素下挂接的符合条件的结果都返回。因为每个元素下挂接的链表大小不同,所以每次返回的结果数量也就不同。
演示
关于 Scan 命令的遍历顺序,我们可以用一个小栗子来具体看一下:
|
如上所示,SCAN命令的遍历顺序是:0->2->1->3
这个顺序看起来有些奇怪,我们把它转换成二进制:00->10->01->11
可以看到每次这个序列是高位加1的。
普通二进制的加法,是从右往左相加、进位。而这个序列是从左往右相加、进位的。
相关源码:
将游标倒置,加一后,再倒置,也就是我们所说的“高位加1”的操作。
-
v
= rev(v);
-
v
+
+;
-
v
= rev(v);
相关源码
先贴一下代码:
-
unsigned long dictScan(dict
*d,
-
unsigned long v,
-
dictScanFunction
*fn,
-
void
*privdata)
-
{
-
dictht
*t
0,
*t
1;
-
const dictEntry
*
de;
-
unsigned long m
0, m
1;
-
-
if (dictSize(d)
=
=
0)
return
0;
-
-
if (!dictIsRehashing(d)) {
/
/没有在做rehash,所以只有第一个表有数据的
-
t
0
=
&(d-
>ht[
0]);
-
m
0
= t
0-
>sizemask;
-
/
/槽位大小-
1,因为大小总是
2^N,所以sizemask的二进制总是后面都为
1,
-
/
/比如
16个slot的字典,sizemask为
00001111
-
-
/
* Emit entries
at
cursor
*
/
-
de
= t
0-
>
table[v
& m
0];
/
/找到当前这个槽位,然后处理数据
-
while (
de) {
-
fn(privdata,
de);
/
/将这个slot的链表数据全部入队,准备返回给客户端。
-
de
=
de-
>
next;
-
}
-
-
}
else {
-
t
0
=
&d-
>ht[
0];
-
t
1
=
&d-
>ht[
1];
-
-
/
* Make sure t
0
is the smaller
and t
1
is the bigger
table
*
/
-
if (t
0-
>
size
> t
1-
>
size) {
/
/将地位设置为
-
t
0
=
&d-
>ht[
1];
-
t
1
=
&d-
>ht[
0];
-
}
-
-
m
0
= t
0-
>sizemask;
-
m
1
= t
1-
>sizemask;
-
-
/
* Emit entries
at
cursor
*
/
-
de
= t
0-
>
table[v
& m
0];
/
/处理小一点的表。
-
while (
de) {
-
fn(privdata,
de);
-
de
=
de-
>
next;
-
}
-
-
/
* Iterate over indices
in larger
table that
are the expansion
-
*
of the
index pointed
to
by the
cursor
in the smaller
table
*
/
-
do {
/
/扫描大点的表里面的槽位,注意这里是个循环,会将小表没有覆盖的slot全部扫描一次的
-
/
* Emit entries
at
cursor
*
/
-
de
= t
1-
>
table[v
& m
1];
-
while (
de) {
-
fn(privdata,
de);
-
de
=
de-
>
next;
-
}
-
-
/
* Increment bits
not covered
by the smaller mask
*
/
-
/
/下面的意思是,还需要扩展小点的表,将其后缀固定,然后看高位可以怎么扩充。
-
/
/其实就是想扫描一下小表里面的元素可能会扩充到哪些地方,需要将那些地方处理一遍。
-
/
/后面的(v
& m
0)是保留v在小表里面的后缀。
-
/
/((v | m
0)
+
1)
& ~m
0) 是想给v的扩展部分的二进制位不断的加
1,来造成高位不断增加的效果。
-
v
= (((v | m
0)
+
1)
& ~m
0) | (v
& m
0);
-
-
/
*
Continue while bits covered
by mask difference
is non-zero
*
/
-
} while (v
& (m
0 ^ m
1));
/
/终止条件是 v的高位区别位没有
1了,其实就是说到头了。
-
}
-
-
/
*
Set unmasked bits so incrementing the reversed
cursor
-
* operates
on the masked bits
of the smaller
table
*
/
-
v |
= ~m
0;
-
/
/按位取反,其实相当于v |
= m
0-
1 , ~m
0也就是
11110000,
-
/
/这里相当于将v的不相干的高位全部置为
1,待会再进行翻转二进制位,然后加
1,然后再转回来
-
-
/
* Increment the reverse
cursor
*
/
-
v
= rev(v);
-
v
+
+;
-
v
= rev(v);
-
/
/下面将v的每一位倒过来再加
1,再倒回去,这是什么意思呢,
-
/
/其实就是要将有效二进制位里面的高位第一个
0位设置置为
1,因为现在是
0嘛
-
-
return v;
-
}
reverse binary iteration
Redis Scan 命令最终使用的是 reverse binary iteration 算法,大概可以翻译为 逆二进制迭代,具体算法细节可以看一下这个Github 相关讨论
这个算法简单来说就是:
依次从高位(有效位)开始,不断尝试将当前高位设置为1,然后变动更高位为不同组合,以此来扫描整个字典数组。
其最大的优势在于,从高位扫描的时候,如果槽位是2^N个,扫描的临近的2个元素都是与2^(N-1)相关的就是说同模的,比如槽位8时,0%4 == 4%4, 1%4 == 5%4 , 因此想到其实hash的时候,跟模是很相关的。
比如当整个字典大小只有4的时候,一个元素计算出的整数为5, 那么计算他的hash值需要模4,也就是hash(n) == 5%4 == 1 , 元素存放在第1个槽位中。当字典扩容的时候,字典大小变为8, 此时计算hash的时候为5%8 == 5 , 该元素从1号slot迁移到了5号,1和5是对应的,我们称之为同模或者对应。
同模的槽位的元素最容易出现合并或者拆分了。因此在迭代的时候只要及时的扫描这些相关的槽位,这样就不会造成大面积的重复扫描。
3 种情况
迭代哈希表时,有以下三种情况:
- 从迭代开始到结束,哈希表不 Rehash;
- 从迭代开始到结束,哈希表Rehash,但每次迭代,哈希表要么不开始 Rehash,要么已经结束 Rehash;
- 从一次迭代开始到结束,哈希表在一次或多次迭代中 Rehash。
- 即再 Rehash 过程中,执行 Scan 命令,这时数据可能只迁移了一部分。
因此,游标的实现需要兼顾以上三种情况。上述三种情况下游标实现的要求如下:
第一种情况比较简单。假设redis的hash表大小为4,第一个游标为0,读取第一个bucket的数据,然后游标返回2,下次读取bucket 2 ,依次遍历。
第二种情况更复杂。假设redis的hash表大小为4,如果rehash后大小变成8。如果如上返回游标(即返回2),则显示下图:
假设bucket 0读取后返回到cursor 2,当客户端再次Scan cursor 2时,hash表已经被rehash,大小翻倍到8,redis计算一个key bucket如下:
hash(key)&(size-1)
即如果大小为4,hash(key)&11,如果大小为8,hash(key)&111。所以当size从4扩大到8时,2 号bucket中的原始数据会被分散到2 (010) 和 6 (110) 这两个 bucket中。
从二进制来看,size为4时,在hash(key)之后,取低两位,即hash(key)&11,如果size为8,bucket位置为hash(key) & 111,即取低三个位。
所以依旧不会出现漏掉数据的情况。
第三种情况,如果返回游标2时正在进行rehash,则Hash表1的bucket 2中的一些数据可能已经rehash到了的Hash表2 的bucket[2]或bucket[6],那么必须完全遍历 哈希表2的 bucket 2 和 6,否则可能会丢失数据。
Redis 全局有两个Hash表,扩容时会渐进式的将表1的数据迁移到表2,查询时程序会先在 ht[0] 里面进行查找, 如果没找到的话, 就会继续到 ht[1] 里面进行查找。
详细信息可以查看:Redis教程(四)—全局数据结构
游标计算
具体游标计算代码如下:
Scan 命令中的游标,其实就是 Redis 内部的 bucket。
-
v |
= ~m
0;
/
/ 将游标v的unmarsked 比特都置为
1
-
v
= rev(v);
/
/ 反转v
-
v
+
+;
/
/这个是关键,加
1,对一个数加
1,其实就是将这个数的低位的连续
1变为
0,然后将最低的一个
0变为
1,其实就是将最低的一个
0变为
1
-
v
= rev(v);
/
/再次反转,即得到下一个游标值
代码逻辑非常简单,计算过程如下:
图源:developpaper
- 大小为 4 时,游标状态转换为 0-2-1-3。
- 当大小为 8 时,游标状态转换为 0-4-2-6-1-5-3-7。
可以看出,当size由小变大时,所有原来的游标都能在大hashTable中找到对应的位置,并且顺序一致,不会重复读取,也不会被遗漏。
总结一下:redis在rehash 扩容的时候,不会重复或者漏掉数据。但缩容,可能会造成重复但不会漏掉数据。
缩容处理
之所以会出现重复数据,其实就是为了保证缩容后数据不丢。
假设当前 hash 大小为 8:
- 1)第一次先遍历了 0 号槽,返回游标为 4;
- 2)准备遍历 4 号槽,然后此时发生了缩容,4 号槽的元素也进到 0 号槽了。
- 3)但是0 号槽之前已经被遍历过了,此时会丢数据吗?
答案就在源码中:
-
do {
/
/扫描大点的表里面的槽位,注意这里是个循环,会将小表没有覆盖的slot全部扫描一次的
-
/
* Emit entries
at
cursor
*
/
-
de
= t
1-
>
table[v
& m
1];
-
while (
de) {
-
fn(privdata,
de);
-
de
=
de-
>
next;
-
}
-
-
/
* Increment bits
not covered
by the smaller mask
*
/
-
/
/下面的意思是,还需要扩展小点的表,将其后缀固定,然后看高位可以怎么扩充。
-
/
/其实就是想扫描一下小表里面的元素可能会扩充到哪些地方,需要将那些地方处理一遍。
-
/
/后面的(v
& m
0)是保留v在小表里面的后缀。
-
/
/((v | m
0)
+
1)
& ~m
0) 是想给v的扩展部分的二进制位不断的加
1,来造成高位不断增加的效果。
-
v
= (((v | m
0)
+
1)
& ~m
0) | (v
& m
0);
-
-
/
*
Continue while bits covered
by mask difference
is non-zero
*
/
-
} while (v
& (m
0 ^ m
1));
/
/终止条件是 v的高位区别位没有
1了,其实就是说到头了。
具体计算方法:
v = (((v | m0) + 1) & ~m0) | (v & m0);
右边的下半部分是v,左边的上半部分是v。 (v&m0) 取出v的低位,例如size=4时v&00000011
左半边(v|m0) + 1 将V 的低位设置为1,然后+1 将进位到v 的高位,再次&m0,V 的高位将被取出。
假设游标返回2并且正在rehashing,大小从4变为8,那么M0 = 00000011 v = 00000010
根据公式计算的下一个光标是 ((00000010 | 00000011) +1) & (11111111100) | (00000010 & 00000011) = (00000100) & (11111111100) | (00000000010) = (000000000110) 正好是 6。
4. 小结
- Scan Count 参数限制的是遍历的 bucket 数,而不是限制的返回的元素个数
- 由于不同 bucket 中的元素个数不同,其中满足条件的个数也不同,所以每次 Scan 返回元素也不一定相同
- Count 越大,Scan 总耗时越短,但是单次耗时越大,即阻塞Redis 时间边长
- 推荐 Count 大小为 1W左右
- 当 Count = Redis Key 总数时,Scan 和 Keys 效果一致
- Scan 采用 逆二进制迭代法来计算游标,主要为了兼容Rehash的情况
- Scan 为了兼容缩容后不漏掉数据,会出现重复遍历。
- 即客户端需要做去重处理
核心就是 逆二进制迭代法,比较复杂,而且算法作者也没有具体证明,为什么这样就能实现,只是测试发现没有问题,各种情况都能兼容。
具体算法细节可以看一下这个Github 相关讨论
antirez: Hello @pietern! I’m starting to re-evaluate the idea of an iterator for Redis, and the first item in this task is definitely to understand better your pull request and implementation. I don’t understand exactly the implementation with the reversed bits counter… I wonder if there is a way to make that more intuitive… so investing some more time into this, and if I fail I’ll just merge your code trying to augment it with more comments… Hard to explain but awesome.
pietern: Although I don’t have a formal proof for these guarantees, I’m reasonably confident they hold. I worked through every hash table state (stable, grow, shrink) and it appears to work everywhere by means of the reverse binary iteration (for lack of a better word).
所以只能说这个算法很巧妙。就像卡马克快速逆平方根算法:
-
float Q_rsqrt( float
number )
-
{
-
long i;
-
float x
2, y;
-
const float threehalfs
=
1.5F ;
-
x
2
=
number
*
0.5F ;
-
y
=
number ;
-
i
=
* ( long
* )
&y;
/
/ evil floating point
bit level hacking
-
i
=
0x
5f
3759df
- ( i
>> 1 ); // what the fuck?
-
y
=
* ( float
* )
&i;
-
y
= y
* ( threehalfs
- ( x
2
* y
* y ) );
/
/
1st iteration
-
/
/ y
= y
* ( threehalfs
- ( x
2
* y
* y ) );
/
/
2nd iteration, this can be removed
-
return y ;
-
}
其中的这个0x5f3759df
数就很巧妙。
5. 参考
http://antirez.com/news/63
https://developpaper.com/redis-scan-command-principle/
https://www.cnblogs.com/thrillerz/p/4527510.html
https://www.jianshu.com/p/abe5d8ae4852
https://zhuanlan.zhihu.com/p/46353221
https://docs.keydb.dev/blog/2020/08/10/blog-post/
- 原文作者:意琦行
- 原文链接:Redis Scan 原理解析与踩坑 | 指月小筑|意琦行的个人博客
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。