利用redis完成分布式锁、延迟队列、位图、布隆过滤器、限流等应用说明 -- Redis应用篇

分布式锁

目标:解决并发的问题
分布式锁本质上就是在 Redis 里面占一个“坑”,当别的进程也要来占时,发现坑位被占了,就只好放弃或者稍后再试。
使用 setnx(set if not exists) 指令,来实现占坑, del 指令释放坑位

redis 分布式锁演进

  • 直接加锁,释放锁。(存在的问题:释放异常了,造成锁一直存在,导致死锁)
  • 加锁,锁过期时间,释放锁。(存在的问题:加锁和锁过期时间之间失败了,导致死锁)
  • 引入三方分布式锁方式 (存在的问题:太复杂,麻烦,很乱)
  • redis 2.8后,redis使得 setnx 和expire 指令可以一起执行,彻底解决了分布式锁的乱象。
 set lock:codehole true ex 5 nx
 OK ... do something critical ... 
 del lock:codehole

锁过期问题
Redis 的分布式锁不能解决超时问题,如果在加锁和释放锁之间的逻辑执行的太长,超出了锁的超时限制,就会出现问题。
所以建议不要在逻辑执行时间较长的业务中使用分布式锁,注意控制好有效期

有一个更加安全的方案是为 set 指令的 value 参数设置为一个随机数,释放锁时先匹配
随机数是否一致,然后再删除 key。
但是匹配 value 和删除 key 不是一个原子操作,Redis 也
没有提供类似于 delifequals 这样的指令,这就需要使用 Lua 脚本来处理了,因为 Lua 脚本可
以保证连续多个指令的原子性执行。

锁可重入
可重入性是指线程在持有锁的情况下再次请求加锁,如果一个锁支持同一个线程的多次加
锁,那么这个锁就是可重入的。比如 Java 语言里有个 ReentrantLock 就是可重入锁。Redis 分
布式锁如果要支持可重入,需要对客户端的 set 方法进行包装,使用线程的 Threadlocal 变量
存储当前持有锁的计数。

延迟队列

向RocketMQ、RabbitMQ一样,redis也提供了简单的消息队列,适用于那些只有一组消费者的消息队列。
但没有 ack 保证,如果对消息的可靠性有着极致的追求,那么它就不适合使用。
使用: Redis 的 list(列表) 数据结构常用来作为异步消息队列使用,使用rpush/lpush操作入队列,
使用 lpop 和 rpop 来出队列。
客户端是通过队列的 pop 操作来获取消息,然后进行处理。处理完了再接着获取消息,
再进行处理。如此循环往复,这便是作为队列消费者的客户端的生命周期。

为了防止空pop问题:
如果队列空了,客户端就会陷入 pop 的死循环
** 方案一:** 可以通过sleep来处理,降低 调用redis的QPS
** 方案二(最佳):** 还是使用 “ blpop/brpop” ,两个指令的前缀字符 b 代表的是 blocking,也就是阻塞读。
阻塞读在队列没有数据的时候,会立即进入休眠状态,一旦数据到来,则立刻醒过来。消
息的延迟几乎为零。用 blpop/brpop 替代前面的 lpop/rpop,就完美解决了上面的问题。
不过需要注意线程一直阻塞在那里,Redis 的客户端连接就成了闲置连接,闲置过久,服务器一般
会主动断开连接,减少闲置资源占用。这个时候 blpop/brpop 会抛出异常来,所以需要注意捕获异常,还要重试

位图

位图其实就是就是 byte 数组。
应用:比如签到,用户每天是否签到相当于0/1,可以每个用户维护这一个byte数字
位图操作 getbit/setbit 等将 byte 数组看成「位数组」来处理
Redis 的位数组是自动扩展,如果设置了某个偏移位置超出了现有的内容范围,就会自
动将位数组进行零扩充。
弊端:范围查找时,指定的位范围必须是 8 的倍数,而不能任意指定。可能是因为1字节(b)=8bit
redis位图

HyperLogLog

适用于日志统计,如埋点数据,非精准去重计数等
HyperLogLog 的统计标准误差在 0.81%左右,基本上可以满足统计需求;

HyperLogLog 提供了三个指令 pfadd 和 pfcount以及 pfmerge(用于数据合并),
‘pf’ 的意义 HyperLogLog 这个数据结构的发明人 Philippe Flajolet 的首字母缩写

注意⚠️
使用HyperLogLog 需要占据一定 12k 的存储空间,所以它不适合统计单个用户相关的数据。
庆幸的是由于Redis对HyperLogLog进行了优化。HyperLogLog存储空间采用稀疏矩阵存储,空间占用很小,仅仅在计数慢慢变大,稀疏矩阵占用空间渐渐超过了阈值时才会一次性转变成稠密矩阵,才会占用 12k 的空间。

pf 的内存占用为什么是 12k?
我们在上面的算法中使用了 1024 个桶进行独立计数,不过在 Redis 的 HyperLogLog
实现中用到的是 16384 个桶,也就是 2^14,每个桶的 maxbits 需要 6 个 bits 来存储,最
大可以表示 maxbits=63,于是总共占用内存就是 2^14 * 6 / 8 = 12k 字节。

布隆过滤器

布隆过滤器是一个不怎么精确的 set 结构,在判断一个值是否存在的时候,有可能存在误判的情况。有个特点就是布隆过滤器告你不存在,那就一定不存在;如果存在那只能说大概率存在,也可能不存在。
应用:主要用来做一些数据去重的情况,如滤器已看过文章
Redis 官方提供的布隆过滤器到了 Redis 4.0才正式登场

bf.add 添加一个元素
bf.exists 单个元素是否存在
bf.madd 添加多个元素
bf.mexists 多个元素是否存在

注意⚠️
布隆过滤器的 initial_size 估计的过大,会浪费存储空间,估计的过小,就会影响准确
率,用户在使用之前一定要尽可能地精确估计好元素数量,还需要加上一定的冗余空间以避
免实际元素可能会意外高出估计值很多。
布隆过滤器的 error_rate 越小,需要的存储空间就越大,对于不需要过于精确的场合,
error_rate 设置稍大一点也无伤大雅。比如在新闻去重上而言,误判率高一点只会让小部分文
章不能让合适的人看到,文章的整体阅读量不会因为这点误判率就带来巨大的改变。
布隆过滤器原理
布隆过滤器

向布隆过滤器中添加 key 过程:
1、会使用多个 hash 函数对 key 进行 hash 算得一个整数索引值
2、然后对位数组长度进行取模运算得到一个位置,每个 hash 函数都会算得一个不同的位置。
3、再把位数组的这几个位置都置为 1 就完成了 add 操作。

判断key是否存在过程:
1、把key进行 hash 的几个位置都算出
2、看位数组中这几个位置是否都为 1,只要有一个位为 0,这个key 一定不存在。如果都是 1,极有可能存在。
3、为什么都为1,是可能不是一定?
因为 位被置为 1 可能是因为其它的 key 存在所致。如果这个位数组比较稀疏,这个概率就会
很大,如果这个位数组比较拥挤,这个概率就会降低。

当正确率下降时,需要考虑重建过滤器;重新分配一个 size 更大的过滤器,再将所有的历史元素批量 add 进 去。

合理公式 (拓展)
布隆过滤器有两个参数,第一个是预计元素的数量 n,第二个是错误率 f。公式根据这
两个输入得到两个输出,第一个输出是位数组的长度 l,也就是需要的存储空间大小 (bit),
第二个输出是 hash 函数的最佳数量 k。hash 函数的数量也会直接影响到错误率,最佳的数
量会有最低的错误率。
k=0.7*(l/n) # 约等于
f=0.6185^(l/n) # ^ 表示次方计算,也就是 math.pow
从公式中可以看出
1、位数组相对越长 (l/n),错误率 f 越低,这个和直观上理解是一致的
2、位数组相对越长 (l/n),hash 函数需要的最佳数量也越多,影响计算效率
Redis 深度历险:核心原理与应用实践 | 钱文品 著 3、当一个元素平均需要 1 个字节 (8bit) 的指纹空间时 (l/n=8),错误率大约为 2%
4、错误率为 10%,一个元素需要的平均指纹空间为 4.792 个 bit,大约为 5bit
5、错误率为 1%,一个元素需要的平均指纹空间为 9.585 个 bit,大约为 10bit
6、错误率为 0.1%,一个元素需要的平均指纹空间为 14.377 个 bit,大约为 15bit
网站工具:https://krisives.github.io/bloom-calculator/

简单限流

应用场景:指定时间内某些事件只允许发生N次
这个限流需求中存在一个滑动时间窗口,那么我们可以通过 zset 数据结构的 score 值来圈出这个时间窗口来。而窗口之外的数据都可以舍弃,score的力度根据实际场景而定,如1分内用相同的score。
限流

如图所示用一个 zset 结构记录用户的行为历史,每一个行为都会作为 zset 中的一个key 保存下来。同一个用户同一种行为用一个 zset 记录。
通过统计滑动窗口内的行为数量与阈值 max_count 进行比较就可以得出当前的行为是否允许。
zset 集合中只有 score 值非常重要,value 值没有特别的意义,只需要保证它是唯一的就可以了。

如果这个量很大,比如限定 60s 内操作不得超过 100w 次这样的参数,它是不适合做这样的限流的,因为会消耗大量的存储空间。

漏斗(漏桶)限流

漏斗限流(漏桶):我们有一个固定容量的桶,有水流进来,也有水流出去。对于流进来的水来说,我们无法预计一共有多少水会流进来,也无法预计水流的速度。但是对于流出去的水来说,这个桶可以固定水流出的的速率。而且,当桶满了之后,多余的水将会溢出。
桶的剩余空间就代表着当前行为可以持续进行的数量,漏嘴的流水速率代表着系统允许该行为的最大频率。

单机情况,可以考虑使用Google开源工具包Guava提供了限流工具类RateLimiter。那分布式系统该怎么办,那么可以使用redis的redis-cell模块。
redis-cell
Redis 4.0 提供了一个限流 Redis 模块,它叫 redis-cell。该模块也使用了漏斗算法,并
提供了原子的限流指令。有了这个模块,限流问题就非常简单了。
该模块只有 1 条指令 cl.throttle,它的参数和返回值都略显复杂
redis-cell

指令的意思是频率为每 60s 最多 30 次(漏水速率),漏斗的初始容量为 15,相当于可以连续回复 15 个帖子,然后才开始受漏水速率的影响。

>  cl.throttle laoqian:reply 15 30 60
1) (integer) 0 # 0 表示允许,1 表示拒绝
2) (integer) 15 # 漏桶容量 capacity
3) (integer) 14 # 漏桶剩余空间 left_quota
4) (integer) -1 # 如果拒绝了,需要多长时间后再试(漏桶有空间了,单位秒)
5) (integer) 2 # 表示多久后桶中的容量会存满(left_quota==capacity,单位秒)

在执行限流指令时,如果被拒绝了,就需要丢弃或重试。cl.throttle 指令考虑的非常周
到,连重试时间都帮你算好了,直接取返回结果数组的第四个值进行 sleep 即可,如果不想
阻塞线程,也可以异步定时任务来重试。

更多redis-cell内容可以详见https://github.com/brandur/redis-cell

其它主流限流算法:https://www.cnblogs.com/linjiqin/p/9707713.html

GeoHash

GeoHash是一种地址编码方法。他能够把二维的空间经纬度数据编码成一个字符串
应用场景:附近的餐厅、附近的共享单车等等

如果用关系型数据库来算附近的人,首先,你不可能通过遍历来计算所有的元素和目标元素的距离然后再进行排序,这个计
算量太大了,性能指标肯定无法满足。一般的方法都是通过矩形区域来限定元素的数量,然
后对区域内的元素进行全量距离计算再排序。这样可以明显减少计算量。但是数据库查询性能毕竟有限,如果「附近的人」查询请求非常多,在高并发场合,这可能并不是一个很好的方案

GeoHash

GeoHash 算法
业界比较通用的地理位置距离排序算法是 GeoHash 算法,Redis就是使用该算法实现位置距离查询。

geohash有以下几个特点:
1、geohash用一个字符串表示经度和纬度两个坐标。某些情况下无法在两列上同时应用索引 (例如MySQL 4之前的版本,Google App Engine的数据层等),利用geohash,只需在一列上应用索引即可。
2、geohash表示的并不是一个点,而是一个矩形区域。比如编码wx4g0ec19,它表示的是一个矩形区域。 使用者可以发布地址编码,既能表明自己位于北海公园附近,又不至于暴露自己的精确坐标,有助于隐私保护。
3、编码的前缀可以表示更大的区域。例如wx4g0ec1,它的前缀wx4g0e表示包含编码wx4g0ec1在内的更大范围。 这个特性可以用于附近地点搜索。首先根据用户当前坐标计算geohash(例如wx4g0ec1)然后取其前缀进行查询 (SELECT * FROM place WHERE geohash LIKE ‘wx4g0e%’),即可查询附近的所有地点。
Geohash比直接用经纬度的高效很多。
Geohash的原理
Geohash的最简单的解释就是:将一个经纬度信息,转换成一个可以排序,可以比较的字符串编码
更多关于GeoHash的实现及原理可以参考以下文章:https://blog.csdn.net/hong2511/article/details/81329361

redis中应用:

#向company添加ireader公司的经纬度
geoadd company 116.48105 39.996794 ireader  
 #计算公司juejin到ireader的距离,单位km;距离单位可以是 m、km、ml、ft,
分别代表米、千米、英里和尺。
geodist company juejin ireader km  

#获取ireader的经纬度
geopos company ireader  

# 范围 20 公里以内最多 3 个元素按距离正排,它不会排除自身
georadiusbymember company ireader 20 km count 3 asc  

# 范围 20 公里以内最多 3 个元素按距离倒排
georadiusbymember company ireader 20 km count 3 desc 

# 三个可选参数 withcoord withdist withhash 用来携带附加参数,withdist 很有用,它可以用来显示距离
georadiusbymember company ireader 20 km withcoord withdist withhash count 3 asc 

redis使用GeoHash注意事项:
在一个地图应用中,车的数据、餐馆的数据、人的数据可能会有百万千万条,如果使用Redis 的 Geo 数据结构,它们将全部放在一个 zset 集合中。在 Redis 的集群环境中,集合可能会从一个节点迁移到另一个节点,如果单个 key 的数据过大,会对集群的迁移工作造成较大的影响,在集群环境中单个 key 对应的数据量不宜超过 1M,否则会导致集群迁移出现卡顿现象,影响线上服务的正常运行。所以,这里建议 Geo 的数据使用单独的 Redis 实例部署,不使用集群环境。如果数据量过亿甚至更大,就需要对 Geo 数据进行拆分,按国家拆分、按省拆分,按市拆分,在人口特大城市甚至可以按区拆分。这样就可以显著降低单个 zset 集合的大小。

Scan

redis可以通过命令keys 进行查找,keys 用来列出所有满足特定正则字符串规则的 key。
两个缺点:
1、没有 offset、limit 参数,一次性吐出所有满足条件的 key;
2、keys 算法是遍历算法,复杂度是 O(n),如果实例中有千万级以上的 key,这个指令
就会导致 Redis 服务卡顿,所有读写 Redis 的其它的指令都会被延后甚至会超时报错,因为
Redis 是单线程程序,顺序执行所有指令,其它指令必须等到当前的 keys 指令执行完了才
可以继续。

2.8 版本中加入了大海捞针的指令——scan
特点:
1、复杂度虽然也是 O(n),但是它是通过游标分步进行的,不会阻塞线程;
2、提供 limit 参数,可以控制每次返回结果的最大条数,limit 只是一个 hint,返回的结果可多可少;
3、同 keys 一样,它也提供模式匹配功能;
4、服务器不需要为游标保存状态,游标的唯一状态就是 scan 返回给客户端的游标整数;
5、返回的结果可能会有重复,需要客户端去重复,这点非常重要;
6、遍历的过程中如果有数据修改,改动后的数据能不能遍历到是不确定的;
7、单次返回的结果是空的并不意味着遍历结束,而要看返回的游标值是否为零;
举例

scan {cursor}  match {key} count {limit}
scan 0 match key99* count 1000
结果:
1) "13976" # 游标值
2)    1) "key9911"
      2) "key9974"
     3) "key9994"

scan 参数提供了三个参数,第一个是 cursor 整数值,第二个是 key 的正则模式,第三个是遍历的 limit hint。 返回的cursor不为0,说明遍历没有结束

字典结构
在 Redis 中所有的 key 都存储在一个很大的字典中,这个字典的结构和 Java 中的HashMap 一样,是一维数组 + 二维链表结构,第一维数组的大小总是 2^n(n>=0),扩容一次数组大小空间加倍,也就是 n++。
字典结构

scan 遍历顺序
scan 的遍历顺序非常特别。它不是从第一维数组的第 0 位一直遍历到末尾,而是采用
了高位进位加法来遍历。之所以使用这样特殊的方式进行遍历,是考虑到字典的扩容和缩容
时避免槽位的遍历重复和遗漏。
普通加法和高位进位加法的区别
高位进位法从左边加,进位往右边移动,同普通加法正好相反。但是最终它们都会遍历
所有的槽位并且没有重复。

字典扩容
Java 中的 HashMap 有扩容的概念,当 loadFactor 达到阈值时,需要重新分配一个新的
2 倍大小的数组,然后将所有的元素全部 rehash 挂到新的数组下面。
渐进式 rehash
Java 的 HashMap 在扩容时会一次性将旧数组下挂接的元素全部转移到新数组下面。如
果 HashMap 中元素特别多,线程就会出现卡顿现象。Redis 为了解决这个问题,它采用渐

扩容采用进式 rehash
它会同时保留旧数组和新数组,然后在定时任务中以及后续对 hash 的指令操作中渐渐
地将旧数组中挂接的元素迁移到新数组上。这意味着要操作处于 rehash 中的字典,需要同
时访问新旧两个数组结构。如果在旧数组下面找不到元素,还需要去新数组下面去寻找。
scan 也需要考虑这个问题,对与 rehash 中的字典,它需要同时扫描新旧槽位,然后将
结果融合后返回给客户端。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值