Redis - Scan
Redis - Scan
在平时线上Redis维护工作中, 有时候需要从Redis实例成千上万的key中找出特定前缀的key列表来手动处理数据。可能是修改它的值, 也可能是删除可以。这里有一个问题,如何从海量的key中找出满足特定前缀的key列表来?
Redis提供了一个简单粗暴的指令keys用来列出所有满足特定正则字符串规则的key。
127.0.0.1:6379> set codehole1 a
OK
127.0.0.1:6379> set codehole2 b
OK
127.0.0.1:6379> set codehole3 c
OK
127.0.0.1:6379> set code1hole a
OK
127.0.0.1:6379> set code2hole b
OK
127.0.0.1:6379> set code3hole c
OK
127.0.0.1:6379> keys *
1) "codehole1"
2) "code1hole"
3) "company"
4) "code2hole"
5) "code3hole"
6) "codehole3"
7) "codehole2"
127.0.0.1:6379> keys codehole*
1) "codehole1"
2) "codehole3"
3) "codehole2"
127.0.0.1:6379> keys code*hole
1) "code1hole"
2) "code2hole"
3) "code3hole"
这个指令使用非常简单, 提供一个简单的正则字符串即可, 但是又很明显的两个缺点。
1、没有offset、limit参数, 一次性吐出所有满足条件的key, 万一实例中有几百万个key满足条件, 当你看到满屏的字符串刷的没有尽头时,就知道难受了。
2、keys算法是遍历算法, 复杂度是O(n),如果实例中有千万级别以上的key,这个指令就会导致Redis服务卡顿, 所有读写的Redis的其他指令都会被延后甚至超时报错, 因为Redis是单线程程序, 顺序执行所有指令, 其他指令必须等到当前指令的keys执行完了后才可以继续执行。
面对这两个显著的缺点该怎么办?
Redis为了解决这个问题,在Redis2.8版本中加入了指令--scan。scan相比keys具备一下特点:
1、复杂度虽然也是O(n), 但是它是通过游标分步进行的,不会阻塞线程
2、提供limit参数, 可以控制每次返回结果的最大条数, limit只有一个hint,返回的结果可多可少
3、同keys一样, 也提供模式匹配功能
4、服务器不需要位游标保存状态, 游标的唯一状态就是scan返回给客户端的游标整数
5、返回的结果可能会有重复, 需要客户端去重, 这点非常重要
6、遍历的过程中如果有数据修改, 改动后的数据能不能遍历到时不确定的
7、单次返回的结果时空的并不意味着遍历结束,而要看返回的游标值是否为零
scan使用
在使用之前, 先往Redis里插入10000条数据来进行测试
import redis
client = redis.StrictRedis()
for i in range(10000):
client.set("key%d"%i, i)
scan指令提供了三个参数, 第一个时cursor整数值, 第二个是key的正则模式, 第三个是遍历的limit hint。第一次遍历时, cursor值为0, 然后将返回结果中第一个整数值作为下一次遍历的cursor。一直遍历到返回cursor值为0结束。
127.0.0.1:6379> scan 0 match key99* count 1000
1) "13976"
2) 1) "key9911"
2) "key9974"
3) "key9994"
4) "key9910"
5) "key9907"
6) "key9989"
7) "key9971"
8) "key99"
9) "key9966"
10) "key992"
11) "key9903"
12) "key9905"
127.0.0.1:6379> scan 13976 match key99* count 1000
1) "1996"
2)
1) "key9982"
2) "key9997"
3) "key9963"
4) "key996"
5) "key9912"
6) "key9999"
7) "key9921"
8) "key994"
9) "key9956"
10) "key9919"
127.0.0.1:6379> scan 1996 match key99* count 1000
1) "12594"
2)
1) "key9939"
2) "key9941"
3) "key9967"
4) "key9938"
5) "key9906"
6) "key999"
7) "key9909"
8) "key9933"
9) "key9992"
......
127.0.0.1:6379> scan 11687 match key99* count 1000
1) "0"
2)
1) "key9969"
2) "key998"
3) "key9986"
4) "key9968"
5) "key9965"
6) "key9990"
7) "key9915"
8) "key9928"
9) "key9908"
10) "key9929"
11) "key9944"
从上面的过程中可以看到虽然提供的limit是1000, 但是返回的结果只有10个左右。因为这个limit不是限定返回结果的数量, 而是限定服务器单次遍历的字典槽位数量。如果将limit设置为10,会发现返回结果是空的, 但是游标值不为零,意味着遍历还没结束。
127.0.0.1:6379> scan 0 match key99* count 10
1) "3072"
2) (empty list or set)
字典的结构
在Redis中所有的key都存储在一个很大的字典中, 这个字典的结构和Java中的HashMap一样, 是一维数组+二维链表结构, 第一维数组的大小总是2^n(n>=0),扩容一次数组大小空间加倍, 也就是n++。
scan指令返回的游标就是第一维数组的位置索引, 我们将这个位置索引称为槽(slot)。如果不考虑字典的扩容缩容, 直接按数组小标挨个遍历就行。limit参数表示需要遍历的槽位数, 之所以返回的结果可能多可能少, 是因为不是所有的槽位都会挂链接表, 有效槽位可能是空的,还有些槽位上挂的链表上的元素可能会有多个, 每一次遍历都会将limit数量的槽位上挂接的所有链表元素进行模式匹配过滤后,一次性返回给客户端。
scan遍历顺序
scan的遍历顺序非常特别。它不是从第一维数组的0位一直遍历到末尾, 而是采用了高位进位加法来遍历。 之所以使用这样的特殊的方式进行遍历, 是考虑到字典的扩容和缩容时避免槽位的遍历重复和遗漏。
普通加法和高位进位加法的区别
高位进位从左边加, 进位往右边移动, 同普遍加法正好相反。 但是最终他们都会遍历所有的槽位并且没有重复。
字典扩容
Java中HashMap有扩容的概念, 当loadFactor达到阈值时,需要重新分配一个新的2被大小的数组, 然后将所有元素全部rehash挂到新的数组下面。rehash就是将元素的hash值对数组长度进行取模运算,因为长度变了, 所以每个元素挂接的槽位可能也发生了变化。又因为数组的长度时2^n次方,所以取模运算等价于位与操作。
a mod 8 = a & (8-1) = a & 7
a mod 16 = a & (16-1) = a & 15
a mod 32 = a & (32-1) = a & 31
这里的7,15, 31称之为字典的mask值, mash的作用就是保留hash值的地位, 高位都被设置为0.
接下来看rehash前后元素槽位的变化。
假设当前的字典数组长度由8位扩容到16位, 那么3好槽位011将会被rehash到3号槽位和11号槽位, 也就是说该槽位链表中大约有一般的元素还是3号槽位, 其他的元素会被放到11号槽位, 11这个数字的二进制是1011, 就是对3的二进制011增加了一个高位1。
抽象点说,假设开始槽位的二进制数是xxx, 那么该槽位中的元素将被rehash到0xxx和1xxx(xxx+8)中。如果字典的长度由16位扩容到32位, 那么对于二进制槽位xxxx中的元素将被rehash到0xxxx和1xxxx(xxxx+16)中。
渐进式rehash
Java的HashMap在扩容时会一次性将旧数组下挂接的元素远不转移到新数组下面, 如果HashMap中元素特别多, 线程就会出现卡顿现象。Redis为了解决这个问题, 它采用了渐进式rehash。
它会同时保留旧数组和新数组, 然后在定时任务中以及后续对hash的指令操作中渐进地将旧数组中挂接的元素迁移到新数组上。 这意味着要操作处于rehash中的字典, 需要同时访问新旧两个数组结构。如果在旧数组下面找不到元素, 还需要去新数组下面去寻找。
scan也需要考虑这个问题, 对于rehash中的字典, 它需要同时扫描新旧槽位, 然后将结果融合后返回给客户端。
更多的scan指令
scan指令是一系列指令, 除了可以遍历所有的key之外, 还可以对指定容器集合进行遍历。 如zscan遍历zset集合元素, hscan遍历hash字典的元素、sscan遍历set集合的元素。
它们的原理同scan都会类似交的, 因为has底层就是字典, set也是一个特殊的hash(所有的value指向同一个元素), zset内部也使用了字典来存储所有的元素内容。
大key扫描
有时候会因为业务人员使用不当, 在Redis实例中会形成很大的对象, 比如一个很大的hash, 一个很大的zset都是经常会出现的。 这个的对象对Redis的集群数据迁移带来了很大的问题, 因为在集群环境下, 如果某个key太大, 会导致迁移卡顿。另外在内存分配上,如果一个key太大, 那么当需要扩容时, 会一次性申请更大的一块内存, 这也会导致卡顿, 如果这个大key被删除, 内存会一次性回收, 卡顿现象会再一次产生。
在平时业务开发中, 要尽量避免大key的产生
如果你观察到Redis的内存大起大落, 这极有可能是因为dakey导致的, 这时候你就需要定位出具体是哪个key, 进一步定位出具体的业务来源, 然后再改进相关的业务代码设计。
如何定位大key呢?
为了避免对线上Redis带来卡顿, 这就需要scan指令, 对于扫描出来的每个key, 使用type指令获得key的类型, 然后使用相应数据结构的size或者len方法来得到它的大小, 对于每一种类型, 保留大小的前N名作为扫描结果展示出来。
上面这样的过程需要编写脚本, 比较繁琐, 不过Redis官方已经在redis-cli指令中提供了这样的扫描功能, 可以直接拿来即用。
redis-cli -h 127.0.0.1 -p 7001 --bigkeys
如果你担心这个指令会大幅抬升Redis的ops导致线上报警,还可以增加一个休眠参数。
redis-cli -h 127.0.0.1 -0 7001 --bigkeys -i 0.1
这上面的指令每隔100条scan指令就会休眠0.1s, ops就不会剧烈抬升, 但是扫描时间会变长。