本文已参与「新人创作礼」活动,一起开启掘金创作之路。
key的扫描遍历与筛选
如果要在redis中的key中找到特定规则的key来进行操作,redis提供一个简单暴力的指令:keys,用来列出满足正则字符串的key。
用法就是这样: keys * :返回所有的 keys hello*:返回hello开头的key, keys hello*end:返回以hello开头,以end结尾的key。但是keys命令有个很大的问题,他不能限制条目,会一次返回所有匹配的key,如果匹配的结果很多,那结果可想而知,茫茫大的结果集。另外因为是使用的遍历算法,时间复杂度是O(n),如果redis内部key的数据量很大,那就需要很多的时间去遍历,而redis是单线程的,顺序执行指令,后面的指令就要等待keys指令遍历完毕才能执行,从而导致redis服务卡顿,后面的指令延后甚至超时报错。
为了解决这个问题,redis在2.8版本引入了scan命令:
- scan命令同样至此正则匹配
- scan命令同样是遍历算法,所以时间复杂度也是O(n)
- 是可喜的是scan命令可以通过游标分步扫描,即使key的基数很大也不会阻塞线程。但是这个范围却不能指定,一次扫描多少由redis自己决定。也就是说游标的开始只能是0,每次扫描完scan会返回下次扫描从哪里开始。如果使用间断的游标或者负数,超范围的游标,redis本身不会受到影响,但是却无法保证返回结果正确,至于原因看了下面scan的遍历算法:高位进位算法就明白了。
- scan命令可以设置返回的建议条目数,仅仅是建议,结果集也许多一些也许少一些。
- 需要注意的是返回的结果可能重复,需要自己去重,这一点是由分步遍历带来的后果,因为扩容和缩容会导致位置改变。
- 遍历的结果如果有数据改动,改动后的数据能不能遍历到是不确定的,这也是因此分步遍历导致的,毕竟不知道改动的数据是在已遍历的区域还是未遍历的区域。
- 并不是执行一次就代表扫描结束了,扫描结果会返回下次扫描起始位置的游标,也就是扫描到哪里了,以便下一步继续从这一步扫描。当返回的下标为0时说明全部扫描一遍了。
scan命令的使用如下:
scan startCursor match hello* count 1000 : startCursor 表示开始的游标, match hello*表示匹配条件, count 1000表示建议返回的条目,结果集也许多一些也许少一些。 其中match hello*和count 1000都是可选的,也就是说可以直接写了个scan startCursor,这样就是无条件匹配且无建议返回条目(无建议并不代表scan就会一次性全部返回,就是返回条目redis自己决定)。返回值有两个,第一个是下次开始扫描的游标(是下次开始扫描,下个sacn直接用这个数就行,不用再+1),第二个返回值就是匹配的结果了。
redis中key的存储结构和java的HashMap类似(HashMap会树化,redis不会),采用数组+链表的方式,数组的长度也就是容量要求为2的幂次方,每次扩容空间加倍,和HashMap是一致的,计算下标的也是采用与运算,这里不再多说感兴趣的可以看一下。直接说结论:以上这种方式带来一个特性,扩容时元素的新位置要么在原位置index,要么在index+oldCapacity,缩容时元素的新位置要么在原位置index(index小于新容量),要么在index-oldCapacity/2(index大于新容量)。 这个特性很重要,理解他才能理解scan采用扫描方式原理,然后来看scan的扫描方式:高位进位加法扫描。
高位进位加法扫描
把一个数转换成二进制,然后从低位开始加,往高位移动得到加算结果,这种加算是低位进位加法,也就是我们平常生活中用到的加算的计算机形式,结果就是 6(110)+1(001)=7(111)... 高位进位加法与低位进位加法相反,高位进位加法从高位开始加,往低位移动,如果发生溢出则丢弃。此时6(110)+1(100)=1(001)。
然后再看遍历,因为容量是2的N次方,假设N是4,那容量就是8,对应的二进制数是1000,也就是第N位是1,后面N-1位均是0,又因为下标是从0开始算的,所以下标的最大值就是7,对应的二进制是111, 也就是N-1位的1。
低位加算遍历时,从000开始,到111结束,过程是000(0) - 001(1) - 010(2) - 011(3) - 100(4) - 101(5) - 110(6) - 111(7), 而高位遍历是000到111,过程是000(0) - 100(4) - 010(2) - 110(6) - 001(1) - 101(5) - 011(3) - 111(7).无论高位还是低位算法,从000遍历到111都能遍历到所有元素(不是000到111这样的遍历就不行了)。
而上面提到了,数组的长度也就是容量要求为2的幂次方带来一个特性:扩容时元素的新位置要么在原位置index,要么在index+oldCapacity,缩容时元素的新位置要么在原位置index,要么在index-oldCapacity/2。以110(6)为例,扩容时元素要么还是在0110(6),要么是在新位置1110(14)处,缩容时元素在10(2)处。也就是扩容直接新增高位,然后分别补一个0或者1,缩容则是直接把高位丢弃。
下图是一个高位遍历算法的遍历过程:
可以看到扩缩容后,下标在遍历顺序上是相邻的。如果扩容时遍历到110的位置,那么扩容后就可以从0110位置开始遍历,0110之前的下标所拥有的元素肯定都是遍历过的。缩容时则从10的位置开始遍历,10之前的下标肯定都已经遍历过了,虽然这样会重复遍历010的数据(010和110缩容后都是10)。
为什么不用低位遍历算法呢?这是因为低位遍历算法在扩容之后index处的一部分元素到index+oldCapacity处会导致大量的元素被重新遍历,如遍历到110(6)时扩容,那么0-5处的元素会有一部分到8-13,6继续遍历,在8-13过程中其实就是在重复遍历0-5这些已遍历过的元素,在缩容时又会因为高位的数据被合并到低位而丢失数据,如遍历到2时出现缩容,那么4,5会合并到已遍历的0,1导致这些元素不会被遍历到。
高位遍历算法很好的解决了遍历期间的扩缩容问题,分段遍历时不必等遍历完才扩缩容,也不必在发生扩容时重新遍历。
redis的渐进式扩容
为了避免元素过多扩缩容时间长导致的redis卡顿,redis的扩容采用渐进式,就是同时保持新旧两个结构,查询时先去旧结构找,如果找不到就去新数据找。
scan除了遍历key之外,还可以用hscan遍历hash,sscan遍历set,zsacn遍历zset。hash,set底层都是数组+链表的字典方式。zset虽然是跳跃列表,但是也用了字段保存所有元素内容。
大key定位
一个大的hash,set,zset都会导致redis的性能下降:集群环境下数据迁移卡顿,扩缩容时一次要申请大量的内存,删除时要回收大量的内存空间,因此要尽量避免大key的产生,如果redis内存大起大落,那么有可能就是大key引起的。此时可以通过scan扫描所有key,用type指令获得key的类型,然后再使用对应的len或size获得大小,提取出最大的几个,但是这样需要通过编写代码,比较繁琐。redis官方提供了redis-cli指令来进行扫描:redis-cli -h ip -p port -bigkeys,如果担心持续扫描影响到redis的ops,还可以设置休眠参数redis-cli -h ip -p port -bigkeys -i 0.1,这样每隔100条scan休眠0.1s。
除了这种方式,还可以拉取redis的dump文件,然后使用redis-rdb-tools-master进行文件分析。
开发成长之旅 [持续更新中...]
关联导航:
34:Redis五种数据类型及其使用 ---- 《Redis深度历险》读书笔记 - 掘金 (juejin.cn)
35:Redis数据底层存储原理 - 掘金 (juejin.cn)
36:Redis的基本应用 ---- 《Redis深度历险》读书笔记 - 掘金 (juejin.cn)
欢迎关注…