Redis应用篇 ---- 《Redis深度历险》读书笔记

1 分布式锁

从1开始慢慢进阶

  1. 可以使用setnx来实现分布式锁,如果不存在占住,存在就失败。然后释放锁时del掉。
  2. 为了避免加锁之后中断导致del没有执行而产生的死锁,可以通过expire给锁设置过期时间
  3. 但是setnx和expire属于redis的两个指令,实际还是避免不了中断的影响的。因此Redis2.8提供一个指令set name value ex expireTime nx; 把setnx和expire整合到了一起,解决了这个问题。
  4. 如果业务逻辑执行时间超过了设置的超时时间,就会出现这种情况:A加锁->超时导致锁释放->B加锁->A执行完毕进行解锁->B还没执行完锁就被解了.不难看出这会出现并发上的问题,因此合理的设置过期时间也是很重要的,真要出现的话就只能人工介入了。
  5. 那将值设置成随机数,只有值匹配才能释放锁呢。但是这依然会有问题:一是如果设置了超时时间,那么依然解决不了A执行超时导致锁超时释放,然后B又拿到锁,AB并行的问题。二是如果不设置超时时间,那么匹配随机数和del元素在redis中并没有事务级别的指令,所以还是解决不了中断导致的锁无法释放的问题。(可以用lua脚本完成匹配和del的原子性,但是一般不会用,知道有这个东西就行了)
  6. 怎么做到锁重入呢?单凭借redis是无法完成的,可以借助ThreadLocal,记录加锁的次数,第一次加锁时间往redis里面设置锁并记录加锁次数为1记录到ThreadLocal中去,后面加锁时判断ThreadLock是否有这个次数记录,有的话说明是重入,直接加一更新就行了,如果没记录的话就尝试加锁setnx。释放锁时间如果重入次数变成0了,就可以remove掉ThreadLocal中的相关记录,然后用del删除redis加的锁。当然以上只是一个大概思路,具体的实现要根据需要考虑更多的问题,比如超时,还是中断导致的死锁问题之类的。
  7. 因为Redis主从同步是异步的,只保证最终一致性,因此当master节点挂掉后,salver节点晋升为新的master节点后可能出现数据丢失,导致锁失效,这种仅仅在主从发生切换时failover(故障切换时)产生,一般情况可以容忍。如果追求极致的安全,可以使用RedLock算法,RedLock使用多个节点,采用大多数机制,加锁时发送指令,过半节点成功才算成功,当然为此要付出代价:除了多节点带来的性能问题外,RedLock还要考虑重试已经时钟偏移等细节问题。

如果出现锁冲突导致加锁失败,可以通过以下几个方式进行处理:

  1. 直接抛出异常,通知用户重试,或者前端自己解析重试。
  2. sleep一会再重试。
  3. 将请求转移至消息队列,等一会再处理。可以用zset,因为这样可以利用score设置优先级。多线程轮询保证可用性,而不是一个线程挂了功能就没了。不过注意的是zset要根据zrem的返回结果确定消息所属,不能取出来就直接处理(因为取可能多个线程取到,但是rem却只会有一个线程成功),当然这会导致有的线程白取一次(也可以用lua脚本达到取值和rem的原子性)。另外要注意异常捕获免得异常导致线程中断。

2 延时队列

如果只有一个消费者(多个消费者就不适用了,因为要保证同时投递多个list),那么使用redis的list就能很轻松的实现延时队列的功能,但是毕竟redis不是专业的消息队列,没有很多的高级功能,而且没有ack保证(消费者明确表示消费成功,中间件才认为消息消费成功,否则都认为消费失败,重新投递),如果对消息队列的可靠性有着极致的追求,就不适合使用。

  1. 使用rpush与lpop结合 或者 lpush与rpop结合能很轻松实现异步消息队列。
  2. 使用pop循环获取消息的话,如果队列空的话,就会导致pop的空轮询,不应拉高客户端的CPU还拉高redis的QPS,客户端越多对redis的影响越大。此时可以使用sleep, 空查询时睡一下,这样既能降低客户端的CPU,又能降低redis的QPS。
  3. 但是使用sleep会导致消息的延迟增大,如果消费者只有一个客户端,那么延迟就是睡眠时间,如果有多个客户端,那么延迟时间会显著下降,但是依然是客观存在的。此时就可以使用blpop或者brpop,这是一个阻塞读指令,队列中没有消息时,就会进入休眠状态,一旦消息到来就立即进行醒过来,这样延迟几乎就为0了。
  4. 但是如果没有消息,那么blpop或者brpop所持有的链接就会成为空闲链接,时间达到超时时间了redis就会断开链接,此时blpop或者brpop就会抛异常,因此使用时需要捕获并重试。当然可以设置成永不超时,但是那会导致redis的链接过多。

3 位图

对于大量的boolean型数据存储(如一个员工一年的出勤记录),如果采用key/value的方式存储需要大量的空间。此时可以采用位图的方式存储,一个数据只占一个位,这样一个字节就能记录8个数据,大大节约了空间。
位图并不是一个特殊的数据结构,他的基础就是redis的基础数据类型:String(字符串),也就是byte数组(redis内存存储字符串串是以字符数组的形式存储)。可以使用set/get对位图整体进行操作,也可以用getbit/setbit对位图的某一位操作。
使用位图时,无需关注长度,因此redis的字符串(byte数组)是自动扩展的,如果访问的位超过现有范围,那么redis就会扩容并进行零补充。
以字符串"he"为例,‘h’的二进制形式是0b01101000,‘e’的二进制形式是0b01100101, 直接使用 set mybit he; 此时mybit中存储的值就是[01101000 01100101],这里有一个注意点:0b01101000中左边是高位,右边是低位,但是在redis中[01101000 01100101]左边是下标起始位0。
此时get mybit 得到的结果就是"he", 然后我们通过位操作:setbit mybit 12 1; setbit mybit 13 0;setbit mybit 14 0;setbit mybit 15 0; 再get mybit 得到的结果就是"hh". 当然可以用 getbit mybit 10这样获得下标是10的位的值,返回0/1.
redis提供了bitcount和bitpos来进行统计和查找。bitcount可以查找1的个数,bitpos用来查找0/1第一次出现的位置。他们都能通过[start,end]来指定查询范围,遗憾的是start和end针对的是字节,而不是位,也就是说如果指定[0,0],那么查找的是第一个字节对应的8位,而不是第一个位。使用方法就很很简单了:

  • bitcount name 查询1出现的次数
  • bitpos name 0查询第一个0的位置,这时返回的是位的下标而不是字节的下标了, bitpos name 1查询第一个1的位置., 指定范围的话就是 bitcount name start endbitpos name 0 start end(start和end指定字节的下标)。

但是如果要一次操作一段连续的字符,就可以使用bitfield来完成:

  • bitfield name get u4 0: 从第0位开始,返回4位,结果以无符号位(u)整数的形式展示
  • bitfield name get i3 2: 从第3位开始,返回3位,结果以有符号位(u)整数的形式展示因为redis中有符号整数最多64位,无符号位最多63位。因此最多也只能连续处理64位,再长就报错了。
  • bitfield name set u8 8 97: 从第8位开始,将接下来8个位替换成97
  • bitfield name incrby u4 2 1:从第3位开始,对接下里4位的无符号数进行+1。有加法就有溢出,可以用overflow设置溢出策略。
  • bitfield name overflow sat incrby u4 2 1;设置溢出策略为sat,但是overflow只对当前指令有用,之后就会回归默认策略wrap。 3中策略分别是 wrap:折返,就是无符号位丢弃溢出位,有符合位占据符号位,然后低位补0. fail:报错不执行 sat:饱和截断,就是达到最大值后就保持住,再加就不管了。

4 HyperLogLog

HyperLogLog是redis提供的一种用来进行非精确去重统计的数据结构。标准误差为0.81%.
可以用来统计大数据量下的去重粗略统计,比如热门网页的UV。
数据量越大,HyperLogLog的优势越明显,使用set进行去重记录,使用scard获取集合大小的方式数据量越大所需的内存空间越大。而HyperLogLog所需的内存空间最大为12K,在数据量较小时HyperLogLog采用稀疏矩阵存储,空间占用较小,随着数量的增加,稀疏矩阵占用的空间达到阈值就会转变为稠密矩阵,HyperLogLog的实现方式使得转变为稠密矩阵后固定占用12K的空间,这也是为什么不一开始小数据量时就使用稠密矩阵的原因。12K的来源是HyperLogLog内部分了16384(2的14次方)个桶,每个桶设计为6bit,所以就是1638468/1024=12K。
HyperLogLog仅仅进行计数,不支持读取内容。也就是说如果你既需要统计数量,又需要查看统计明细的话,HyperLogLog并不满足,还是老老实实使用set吧。
另外如果需要精确统计,不容许误差,HyperLogLog也不满足。
HyperLogLog的实现原理就不深究了,比较复杂,是数学知识中概率论与数理统计方面的应用。
HyperLogLog的常用指令有:

  • pfadd name value value不存在则增加计算,存在则不增加
  • pfcount name 获取计数,返回的结果不保证精确,但误差也不大
  • pfmerge name1 name2 name3 创建一个name3,他是name1和name2统计内容的合并

小知识:命令中的pf是HyperLogLog数据结构的发明人Philippe Flajolet的首字母。

5 布隆过滤器

如果现在有这样一个场景:内容推送,用户浏览时刷新内容时要求推送给用户未浏览过的内容。或者这样一个场景,爬虫爬取,当然爬过的URL就不能爬取了。而上面HyperLogLog仅仅提供了一种非精确去重计数统计的功能,所以只有pfadd和pfcount,没有pfexists, 因此并不能胜任这个工作。
难道我们用数据库吗?每次用户刷新时都对推送内容进行一次exist,这样的话如果高并发情况下得需要多么海量的性能来满足这个要求呀。也不能对内容排个序,然后按序推送,那这内容推送也太low了,没法个性化,也没法热点推送。
那用缓存,把用户浏览记录缓存到redis的set中(set可以去重),然后用simember指令查看是否存在。可以是可以,但是又回到了内存空间的问题上,大量的用户和浏览记录得需要多大的内存空间呀,如果记录还不允许过期,那随着时间的增长,所需的内存空间是可怕的。
此时就需要布隆过滤器(Bloom Filter)登场了,在达到去重目的的同时,相较于set能节省90%以上的空间。代价就是布隆过滤器是不那么精确,可以通过exists判断某个元素是存在,但是却无法查看集合内的元素内容明显。布隆过滤器的误差体现在如果判断元素存在,那么元素不一定存在,若是判断元素不存在那则不存在误差,元素一定是不存在的。 可以通过合理的参数设置控制误差率。

布隆过滤器的原理:
图片来源Redis深度历险
每个布隆过滤器对应一个大型的位数组,然后设置一定数目的无偏hash函数,无偏是指计算出的hash值相对均匀。 向布隆过滤器中添加key时,分别用设置的hash函数对key进行hash计算获得一个索引值然后取模获得对应的下标,然后下标对应的数组位设置成1。查找时同样分别用设置的hash函数获得对应的下标,只要有一位是0,那么就说明这个元素不存在,如果都为1,那么说明这个元素可能存在,不是一定的原因是相应的位是被其他元素设置的。位数组内的1的位越稀疏,存在的可能性就越大。
布隆过滤器可以设置一个初始大小,也就是预期放入的元素数量,实际数量超过这个数量时,误差率就会上升。还可以设置一个基准误差率,布隆过滤器会尽量保证误差率在这个范围内。并不是说预期容量设置的越大越好,设置的过大,会浪费存储空间,设计的过小会影响准确率。同样的error_rate设计的越小虽然会带来准确率上的提升,也会占据更多的存储空间。
布隆过滤器会根据初始大小以及基准误差率这两个参数来计算位数组的合理长度。 因为底层是数组的原因,所以如果元素数量出现增长导致实际数量大于定义的初始数量了,是不能修改的,只能重建一个新的布隆过滤器,将历史数据重新add一遍(这要求有一个地方记录有历史数据)。
虽然实际数量大于预期数量后误差短时间内不会急剧上升(超过的越多误差率上升的越快),但是在设计初始容量时依然要设计一定的冗余数量以便数量增长时有足够的时间进行布隆过滤器的重建以及数据迁移。
因此初始容量已经误差率都要根据业务场景慎重考虑。
预期容量n,错误率f,位数组长度m(其实也代表占据的内存空间大小,毕竟位数组一位就是1bit),以及hash函数的最佳数量k之间的关系是这样的:k=0.7*(m/n) f=0.6185^(m/n)
如果超过了初始容量,错误率函数是这样的:f=(1-0.5^t) ^k,t表示实际元素与预估元素的倍数。
在这里插入图片描述

图片来源《Redis深度历险》

如果知道已设计好f和m,要知道占据的空间大小,又不想算,可以搜一个叫bloom filter calculator的网站,可以直接获得结果。

官方的布隆过滤器redis4.0才正式登场, java客户端的话Jedis3.0才引入,使用时要注意版本。
redis中布隆过滤器的使用:

  • bf.reserve name error_rate initial_size:设置布隆过滤器的参数,error_rate是误差率,initial_size标识预期放入的元素数量,默认情况下error_rate是0.01, initial_size是100。
  • bf.add name value: 新增元素到布隆过滤器中
  • bf.exists name value :查看布隆过滤器中是否存在value,存在返回1,不存在返回0
  • bf.madd name value1 value2 value3: 批量新增元素到布隆过滤器中
  • bf.mexists name value1 value2 value3: 批量查看布隆过滤器中是否存在value,存在返回1,不存在返回0。会按序依次返回查询元素数目的结果

6 简单限流

要求用户的某个行为在指定时间内只能发生N次。这种限流场景需要一个滑动的时间窗口。
redis中的zset结构会很合适,用score来记录时间,可以通过zrangebyscore获得指定时间窗口内的记录。至于zset中的value,存什么就无所谓了,只要不重复就可以了,当然value内容可以设计的小一点,这样可以节省内存。
时间窗口之外的值也可以用zremrangeByScore进行删除节省内存。还可以设置一个过期时间,这样冷用户超过指定时间未操作,其zset也会被回收放出内存。
这样每次来时,就把动作放到zset中,然后再移除时间窗口之外的数据,留下的数据就是时间窗口内的,用zcard获得数量,没超过限额的话就能继续操作,超过了的话就限流。
因为几个指令都涉及同一个zset,因此使用pipeline可以显著提升效率。
但是这种限流只适合小数据量的限流,如果大数据量的限流比如指定时间限流100W这种就不适合了,因为太耗内存空间了。

7 漏斗限流(令牌限流)

漏斗限流是一种常用的限流手法,顾明思议,这种算法的灵感来源于漏斗。
首先想象一下漏斗
在这里插入图片描述
漏斗有一个最大出水速率,就好像我们的限流一样。当入水速率小于最大出水速率时,漏斗就像管道一样一边入水一边出水。但是又不同于管道的漏斗本人具有一定的容量,这意味着漏斗允许入水速率短时间大于出水速率,此时如果漏斗没装满,漏斗以最大出水速率出水,多出来的水会被存储起来。而漏斗装满的话就不再允许继续入水,必须等待漏斗重新腾出空间才行。这本身不就是一个很好的限流模型吗?漏斗的剩余空间代表当前行为可持续进行的数量,漏斗的出水速率代表着该行为的最大频率。
用代码就能实现漏斗的这个功能,但是代码无法做到分布式呀,所以还是得redis出马。但是用redis起码需要三步,新的行为到来时:第一步要知道现在漏斗的情况,第二步要对这个新行为进行运算得到相应的策略和数据,第三部把数据更新到redis中,这三步对redis来说无法保证原子性。
此时就该Redis-Cell出马了。Redis-Cell能够实现漏斗限流器。Redis-Cell的使用很简单,他只有一个命令:
cl.throttle name waitCapacity operationCount limitTime quote : name就是限流器的名称, operationCount和limitTime结合起来就是消费效率,就是在limitTime秒(limitTime的单位是秒)内允许最大operationCount次操作。waitCapacity是等待中未被处理的容量,waitCapacity +1是限流器的真正容量,加的那个1表示的就是正在流出的那个。quote表示本次命令要申请多大容量,可选的参数,不写的话默认是1,就是一个标准大小,如果写的和capacity一样大,那一下子就能把空限流器填满。
cl.throttle指令会返回 5个int值:第一值表示此次操作是否允许,0标识允许,1标识拒绝;第二个值是限流器的实际容量,而不是设置的那个等待容量;第三个值表示限流器的剩余容量,第四个值在命令是1拒绝时才又有,表示距离限流器腾出足够容纳此次命令的空间需要多久,单位是秒,意思就是你可以等多久再尝试申请一下(你可以选择sleep或者异步处理或者其他方法,都可以,当然也可以直接丢掉此次),如果是0允许的话,这个值就是-1;第五个值是如果没有新元素进入,限流器完全腾空还需要的时间,单位同样是秒。
除了漏斗限流器还有一种类似的限流器叫令牌限流器。同样可以用Redis-Cell实现。漏斗限流器和令牌限流器的区别仅仅在于对瞬时消息的处理。漏斗限流器面对瞬时消息依旧按照恒定的速率处理,令牌限流器则是生成令牌,瞬时消息只要能拿到令牌就进行处理。比如上面使用Redis-Cell定义的限流器,定义容量为5,在容量为空时瞬时来10个消息,那么会有5个消息拒绝,5个消息被允许,直接处理这些允许的消息的话就是令牌限流器,因为他们都有令牌了。而如果把这些允许的消息放到一个队列中,然后以一个恒定的速率(与限流器最大速率一致)去处理这个队列里面的消息,那么就是漏斗限流器(水流激增依然以恒定速率流出的漏斗)。也就是说空闲情况下面对瞬时消息漏斗限流器的处理效率依然绝对不大于最大限流,而令牌限流器则容忍空闲情况出现瞬时消息的场景下能短暂大于最大效率(波动范围多大可以根据设置容量大小进行一定的调整),但是也仅仅限于短暂时间,效率很快就会被限制回来(因为空闲令牌被消耗后,后面的流量就会新的令牌生产速率限制住,多余的被拒绝)。
在非瞬时消息下,漏斗限流器和令牌限流器并无区别,比如流入速率恒定小于流出速率时两种限流器都处于未限流状态,处理速率一致。流入速率恒定大于流出速率时令牌限流器生成令牌的速度和漏斗限流器处理的速度均为最大限流量,也是一致的速率。
漏斗限流器能保证限流,令牌限流器则能更好的处理处理突发消息,具体采用哪种就要根据实际的需要来了。

8 地理位置GeoHash

GeoHash是一种业界比较通用的地理位置距离算法。GeoHash算法将二维的经纬度数据映射到一维的整数上,这样所有的元素都将挂载到一条线上,距离相近的二维坐标在这条线上距离也会相近。此时如果需要找附近的坐标,就找线相邻的点就行了。然后这些点还能还原为原来的坐标值,虽然不会完全一致,但是精度越高误差也就越小。
具体的算法就是把地球看成一个二维平面,然后划成像棋盘一样的格子,方格越小,也就越精确。每个格子进行编码,越是靠近的格子编码越是相近。比如二刀法将一个方块分成4块,分别编码左上00,右上01,左下10,右下11这种(只是示例,具体的切法有很多,编码也不是这么随便,毕竟地球不是一个正方形)。经过这些编码,地图上的坐标就成为就变成了整数。
如果要找附近的人,就找自己这个坐标在线上一定范围的就行了。说起范围查询,那不就得zset出马了吗?score存坐标的编码,value存数据的key。RedisHahs底层就是zset的数据结构。
GeoHash的命令有这些:

  • geoadd mapname longitude latitude CoordinateName : 将一个坐标名为CoordinateName ,坐标为longitude,latitude的点加入到地图中去。可以批量,比如geoadd mapname longitude1 latitude1 CoordinateName1 longitude2 latitude2 CoordinateName2
  • geodist mapname CoordinateName1 CoordinateName2 km :返回CoordinateName1 与CoordinateName2 之间的距离,单位是km, 单位可以是m,km,ml(英里),ft(尺)
  • geopos mapname CoordinateName1 : 获取CoordinateName1的坐标,得出的结果肯定和输入的不一样,这就是算法导致的误差。也可以这样geopos mapname CoordinateName1 CoordinateName2一次获得多个点
  • geohash mapname CoordinateName1:返回CoordinateName1的坐标就算后的hash值,可以拿着这个hash值直接去http://geohash.org/wx4g52e1ce0,就能看到对应的坐标了
  • georadiusbymember mapname CoordinateName1 20 km count 3 asc: 返回距离CoordinateName1 20km以内的3个最近的CoordinateName ,asc改成desc就是最远的3个了。这些返回中也许会包括自己,返回只会按条件返回,不会管这个点其实就是你的查询条件,如果找最近的一个点的话,那么就会返回一个自己了。georadiusbymember有3个可选参数,withcoord withdist withhash。分别对应返回坐标点的坐标,返回带有距离查询点的距离,返回带有坐标点的hash。都加上就变成这样了:georadiusbymember mapname CoordinateName1 20 km withcoord withdist withhash count 3 asc
  • georadius mapname longitude latitude 20 km count 3 asc:直接用坐标点查,用法和georadiusbymember一样

令人遗憾的是,Redis没有相应的坐标点删除指令。
因为集群模式下一个key的数据可能会在多个节点上,集群变动时又可能导致节点数据的迁移,因此如果管理的是一份庞大的地图数据的话,建议建立专属的redis实例。如果更为庞大的话,还可以对地图数据按区域进行划分为多个Geo数据。

9 扫描遍历与筛选

如果要在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命令:

  1. scan命令同样至此正则匹配
  2. scan命令同样是遍历算法,所以时间复杂度也是O(n)
  3. 是可喜的是scan命令可以通过游标分步扫描,即使key的基数很大也不会阻塞线程。但是这个范围却不能指定,一次扫描多少由redis自己决定。也就是说游标的开始只能是0,每次扫描完scan会返回下次扫描从哪里开始。如果使用间断的游标或者负数,超范围的游标,redis本身不会受到影响,但是却无法保证返回结果正确,至于原因看了下面scan的遍历算法:高位进位算法就明白了。
  4. scan命令可以设置返回的建议条目数,仅仅是建议,结果集也许多一些也许少一些。
  5. 需要注意的是返回的结果可能重复,需要自己去重,这一点是由分步遍历带来的后果,因为扩容和缩容会导致位置改变。
  6. 遍历的结果如果有数据改动,改动后的数据能不能遍历到是不确定的,这也是因此分步遍历导致的,毕竟不知道改动的数据是在已遍历的区域还是未遍历的区域。
  7. 并不是执行一次就代表扫描结束了,扫描结果会返回下次扫描起始位置的游标,也就是扫描到哪里了,以便下一步继续从这一步扫描。当返回的下标为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的扫描方式:高位进位加法扫描。

9.1 高位进位加法扫描

把一个数转换成二进制,然后从低位开始加,往高位移动得到加算结果,这种加算是低位进位加法,也就是我们平常生活中用到的加算的计算机形式,结果就是 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,缩容则是直接把高位丢弃。
下图是一个高位遍历算法的遍历过程:
图片来源Redis深度历险
可以看到扩缩容后,下标在遍历顺序上是相邻的。如果扩容时遍历到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导致这些元素不会被遍历到。
高位遍历算法很好的解决了遍历期间的扩缩容问题,分段遍历时不必等遍历完才扩缩容,也不必在发生扩容时重新遍历。

9.2 redis的渐进式扩容

为了避免元素过多扩缩容时间长导致的redis卡顿,redis的扩容采用渐进式,就是同时保持新旧两个结构,查询时先去旧结构找,如果找不到就去新数据找。
scan除了遍历key之外,还可以用hscan遍历hash,sscan遍历set,zsacn遍历zset。hash,set底层都是数组+链表的字典方式。zset虽然是跳跃列表,但是也用了字段保存所有元素内容。

9.3 大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。

PS:
【JAVA核心知识】系列导航 [持续更新中…]
关联导航:Redis基础篇
关联导航:Redis原理篇
关联导航:Redis集群篇
关联导航:Redis的过期删除策略与淘汰策略
欢迎关注…

参考资料:
《Redis深度历险》

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

yue_hu

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值