本文已参与「新人创作礼」活动,一起开启掘金创作之路。
1 分布式锁
从1开始慢慢进阶
- 可以使用setnx来实现分布式锁,如果不存在占住,存在就失败。然后释放锁时del掉。
- 为了避免加锁之后中断导致del没有执行而产生的死锁,可以通过expire给锁设置过期时间
- 但是setnx和expire属于redis的两个指令,实际还是避免不了中断的影响的。因此Redis2.8提供一个指令
set name value ex expireTime nx;把setnx和expire整合到了一起,解决了这个问题。 - 如果业务逻辑执行时间超过了设置的超时时间,就会出现这种情况:A加锁->超时导致锁释放->B加锁->A执行完毕进行解锁->B还没执行完锁就被解了.不难看出这会出现并发上的问题,因此合理的设置过期时间也是很重要的,真要出现的话就只能人工介入了。
- 那将值设置成随机数,只有值匹配才能释放锁呢。但是这依然会有问题:一是如果设置了超时时间,那么依然解决不了A执行超时导致锁超时释放,然后B又拿到锁,AB并行的问题。二是如果不设置超时时间,那么匹配随机数和del元素在redis中并没有事务级别的指令,所以还是解决不了中断导致的锁无法释放的问题。(可以用lua脚本完成匹配和del的原子性,但是一般不会用,知道有这个东西就行了)
- 怎么做到锁重入呢?单凭借redis是无法完成的,可以借助ThreadLocal,记录加锁的次数,第一次加锁时间往redis里面设置锁并记录加锁次数为1记录到ThreadLocal中去,后面加锁时判断ThreadLock是否有这个次数记录,有的话说明是重入,直接加一更新就行了,如果没记录的话就尝试加锁setnx。释放锁时间如果重入次数变成0了,就可以remove掉ThreadLocal中的相关记录,然后用del删除redis加的锁。当然以上只是一个大概思路,具体的实现要根据需要考虑更多的问题,比如超时,还是中断导致的死锁问题之类的。
- 因为Redis主从同步是异步的,只保证最终一致性,因此当master节点挂掉后,salver节点晋升为新的master节点后可能出现数据丢失,导致锁失效,这种仅仅在主从发生切换时failover(故障切换时)产生,一般情况可以容忍。如果追求极致的安全,可以使用RedLock算法,RedLock使用多个节点,采用大多数机制,加锁时发送指令,过半节点成功才算成功,当然为此要付出代价:除了多节点带来的性能问题外,RedLock还要考虑重试已经时钟偏移等细节问题。
如果出现锁冲突导致加锁失败,可以通过以下几个方式进行处理:
- 直接抛出异常,通知用户重试,或者前端自己解析重试。
- sleep一会再重试。
- 将请求转移至消息队列,等一会再处理。可以用zset,因为这样可以利用score设置优先级。多线程轮询保证可用性,而不是一个线程挂了功能就没了。不过注意的是zset要根据zrem的返回结果确定消息所属,不能取出来就直接处理(因为取可能多个线程取到,但是rem却只会有一个线程成功),当然这会导致有的线程白取一次(也可以用lua脚本达到取值和rem的原子性)。另外要注意异常捕获免得异常导致线程中断。
2 延时队列
如果只有一个消费者(多个消费者就不适用了,因为要保证同时投递多个list),那么使用redis的list就能很轻松的实现延时队列的功能,但是毕竟redis不是专业的消息队列,没有很多的高级功能,而且没有ack保证(消费者明确表示消费成功,中间件才认为消息消费成功,否则都认为消费失败,重新投递),如果对消息队列的可靠性有着极致的追求,就不适合使用。
- 使用rpush与lpop结合 或者 lpush与rpop结合能很轻松实现异步消息队列。
- 使用pop循环获取消息的话,如果队列空的话,就会导致pop的空轮询,不应拉高客户端的CPU还拉高redis的QPS,客户端越多对redis的影响越大。此时可以使用sleep, 空查询时睡一下,这样既能降低客户端的CPU,又能降低redis的QPS。
- 但是使用sleep会导致消息的延迟增大,如果消费者只有一个客户端,那么延迟就是睡眠时间,如果有多个客户端,那么延迟时间会显著下降,但是依然是客观存在的。此时就可以使用blpop或者brpop,这是一个阻塞读指令,队列中没有消息时,就会进入休眠状态,一旦消息到来就立即进行醒过来,这样延迟几乎就为0了。
- 但是如果没有消息,那么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 end。bitpos 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个位替换成97bitfield 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 valuevalue不存在则增加计算,存在则不增加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判断某个元素是存在,但是却无法查看集合内的元素内容明显。布隆过滤器的误差体现在如果判断元素存在,那么元素不一定存在,若是判断元素不存在那则不存在误差,元素一定是不存在的。 可以通过合理的参数设置控制误差率。
布隆过滤器的原理:
每个布隆过滤器对应一个大型的位数组,然后设置一定数目的无偏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表示实际元素与预估元素的倍数。
如果知道已设计好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,不存在返回0bf.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 CoordinateName2geodist 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值直接去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 PubSub
可以用list实现消息队列,但是使用list无法实现消息的多播机制,PubSub就是Redis为了实现多播的一个模块。但是十分遗憾的是PubSub如果找不到消费者会直接丢弃消息,比如3个消费者,忽然有一个宕机,那属于这个消费者的消息都会丢掉,即使后面这个消费重启成功,消息也回不来了。而且PubSub没有持久化机制,一旦重启,所有消息全部清空,这些特性使得PubSub几乎没有使用场景。虽然Redis作者后面单独开了一个项目Disque来做消息多播,但是至今没有正式版上线。
而在Redis5.0,Redis新增了Stream数据结构,这个功能带来了持久化消息队列。也许PubSub和Disque慢慢的就会淡化掉,成为历史的尘埃,所以就不去仔细了解了。后面单独了解一下Stream。
开发成长之旅 [持续更新中...]
关联导航:35:Redis数据底层存储原理 - 掘金 (juejin.cn)
关联导航:35:Redis数据底层存储原理 - 掘金 (juejin.cn)
欢迎关注…
参考资料:
《Redis深度历险》