概述
在使用 Redis 过程中,需要获取对 key 固定前缀或者后缀的数据,Redis 提供了一种简单粗暴的方法 keys。
一、 keys
127.0.0.1:6379> set student_1_num_1 1
OK
127.0.0.1:6379> set student_1_num_2 2
OK
127.0.0.1:6379> set student_2_num_1 3
OK
127.0.0.1:6379> keys *
1) "student_1_num_1"
2) "student_2_num_1"
3) "student_1_num_2"
127.0.0.1:6379> keys student_1_num_*
1) "student_1_num_1"
2) "student_1_num_2"
127.0.0.1:6379> keys student*1
1) "student_1_num_1"
2) "student_2_num_1"
127.0.0.1:6379> keys *_1
1) "student_1_num_1"
2) "student_2_num_1"
慎用
- 没有
limit、offset, 一次性吐出所有满足条件的key,时间复杂度是O(n),如果数据量百万级甚至更多,我们知道 Redis 是单线程的,会导致整个 Redis 卡顿。
二、 scan
scan特点
Redis 在2.8 版本中加入了 scan。scan 具有以下特点:
- 复杂度虽然也是
O(n),但是它是通过游标分步进行的,不会阻塞线程; - 提供
limit参数,可以控制每次返回结果的最大条数,limit只是一个hint,返回的结果可多可少; - 同
keys一样,它也提供模式匹配功能; - 服务器不需要为游标保存状态,游标的唯一状态就是
scan返回给客户端的游标整数; - 返回的结果可能会有重复,需要客户端去重复,这点非常重要;
- 遍历的过程中如果有数据修改,改动后的数据能不能遍历到是不确定的;
- 单次返回的结果是空的并不意味着遍历结束,而要看返回的游标值是否为零;
scan使用
scan cursor [MATCH pattern] [COUNT count]
cursor:整数值pattern:key 的正则模式count:遍历的 limit hint 第一次遍历时,cursor 值为 0,然后将返回结果中第一个整数值作为下一次遍历的 cursor。一直遍历到返回的 cursor 值为 0 时结束。
首先循环插入 key,file_1 -> file_20
127.0.0.1:6379> scan 0 match file_* count 1000
1) "1749"
2) 1) "file_20"
2) "file_9"
3) "file_6"
4) "file_4"
5) "file_2"
6) "file_7"
7) "file_19"
8) "file_18"
9) "file_3"
10) "file_17"
11) "file_13"
127.0.0.1:6379> scan 1794 match file_* count 1000
1) "23"
2) 1) "file_4"
2) "file_2"
3) "file_7"
4) "file_19"
5) "file_18"
6) "file_3"
7) "file_17"
8) "file_13"
9) "file_16"
10) "file_12"
11) "file_15"
12) "file_5"
13) "file_14"
14) "file_11"
127.0.0.1:6379> scan 23 match file_* count 1000
1) "0"
2) 1) "file_10"
2) "file_1"
3) "file_8"
从上面的过程可以看到虽然提供的 limit 是 1000,但是返回的结果只有 10 个左右。因为这个 limit 不是限定返回结果的数量,而是限定服务器单次遍历的字典槽位数量(约等于) 。
scan原理
1. 字典的结构
在 Redis 中所有的 key 都存储在一个很大的字典中,这个字典的结构和 Java 中的 HashMap 一样,是一维数组 + 二维链表结构,第一维数组的大小总是 2^n(n>=0),扩容一次数组大小空间加倍,也就是 2^(n+1)。
scan 指令返回的游标就是第一维数组的位置索引,我们将这个位置索引称为槽 (slot) 。如果不考虑字典的扩容缩容,直接按数组下标挨个遍历就行了。limit 参数就表示需要遍历的槽位数,之所以返回的结果可能多可能少,是因为不是所有的槽位上都会挂接链表,有些槽位可能是空的,还有些槽位上挂接的链表上的元素可能会有多个。每一次遍历都会将 limit 数量的槽位上挂接的所有链表元素进行模式匹配过滤后,一次性返回给客户端。
2. 遍历顺序
scan 的遍历顺序非常特别。它不是从第一维数组的第 0 位一直遍历到末尾,而是采用了 高位进位 加法来遍历。之所以使用这样特殊的方式进行遍历,是考虑到字典的扩容和缩容时避免槽位的遍历重复和遗漏。
高位进位法从左边加,进位往右边移动,同普通加法正好相反。但是最终它们都会遍历所有的槽位并且没有重复。
低位进位会带来重复读的问题
- 正常的遍历顺序,是
00 -> 01 -> 10 -> 11 - 这时进行扫描,如果在读到
10时,进行扩容了: 由 4 位扩容到 8 位 - 如果由 4 位扩容到 8 位顺序是,
000 -> 001 -> 010 -> 011 -> 100 -> 101 -> 110 -> 111 - 这时候从扩容后
010继续读,当读到100、101的数据时,会发现都是重复的数据,这些数据分别是从扩容前00、01扩容过来的数据,都已经读过了
高位进位
- 高位进位顺序,是
00 -> 10 -> 01 -> 11 - 扩容后顺序,
000 -> 100 -> 010 -> 110 -> 001 -> 101 -> 011 -> 111 - 我们会发现,扩容后的数据是相邻的,不必担心以上重复读的问题
3. 字典扩容
Java 中的 HashMap 有扩容的概念,当 loadFactor 达到阈值时,需要重新分配一个新的 2 倍大小的数组,然后将所有的元素全部 rehash 挂到新的数组下面。rehash 就是将元素的 hash 值对数组长度进行取模运算,因为长度变了,所以每个元素挂接的槽位可能也发生了变化。又因为数组的长度是 2^n 次方,所以取模运算等价于位与操作。
4. 渐进式 rehash
Java 的 HashMap 在扩容时会一次性 将旧数组下挂接的元素全部转移到新数组下面。如果 HashMap 中元素特别多,线程就会出现卡顿现象。Redis 为了解决这个问题,它采用渐进式 rehash。
它会同时保留旧数组和新数组,然后在定时任务中以及后续对 hash 的指令操作中渐渐地将旧数组中挂接的元素迁移到新数组上。这意味着要操作处于 rehash 中的字典,需要同时访问新旧两个数组结构。如果在旧数组下面找不到元素,还需要去新数组下面去寻找。
scan 也需要考虑这个问题,对与 rehash 中的字典,它需要同时扫描新旧槽位,然后将结果融合后返回给客户端。
5. 大 key 扫描
有时候会因为业务人员使用不当,在 Redis 实例中会形成很大的对象,比如一个很大的 hash,一个很大的 zset 这都是经常出现的。这样的对象对 Redis 的集群数据迁移带来了很大的问题,因为在集群环境下,如果某个 key 太大,会数据导致迁移卡顿。另外在内存分配上,如果一个 key 太大,那么当它需要扩容时,会一次性申请更大的一块内存,这也会导致卡顿。如果这个大 key 被删除,内存会一次性回收,卡顿现象会再一次产生。
在平时的业务开发中,要尽量避免大 key 的产生。
那如何定位大 key 呢?
Redis 官方已经在 redis-cli 指令中提供了这样的扫描功能,我们可以直接拿来即用。
redis-cli -h 127.0.0.1 -p 7001 –-bigkeys
如果你担心这个指令会大幅抬升 Redis 的 ops 导致线上报警,还可以增加一个休眠参数。
redis-cli -h 127.0.0.1 -p 7001 –-bigkeys -i 0.1
上面这个指令每隔 100 条 scan 指令就会休眠 0.1s,ops 就不会剧烈抬升,但是扫描的时间会变长。
备注
以上内容摘自 《Redis 深度历险:核心原理与应用实践》钱文品