Redis深度历险:核心原理和技术实现(基础及应用篇)
Posted 光光-Leo
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Redis深度历险:核心原理和技术实现(基础及应用篇)相关的知识,希望对你有一定的参考价值。
目录
欢迎关注微信公众号“江湖喵的修炼秘籍”
一.Redis
Redis是什么?
Redis全称为Remote DictionaryService(远程字典服务),是一个存储中间件。
基础数据结构
Redis 有 5 种基础数据结构,分别为:string (字符串)、list (列表)、set (集合)、hash (哈希) 和 zset (有序集合)。
Redis中数据是按照键值对的方式进行存储的,key都是string类型,value可以是上述5种类型。
key命名规范:a:b:c
string (字符串)
Redis 的字符串是动态字符串,是可以修改的字符串,内部结构实现上类似于 Java 的ArrayList,采用预分配冗余空间的方式来减少内存的频繁分配。
扩容时,如果字符串大小小于1M,是基于现有空间成倍扩容;如果字符串大小大于1M,每次扩容时只增加1M,字符串最大长度是521M。
字符串是由多个字节组成,每个字节又是由 8 个 bit 组成,如此便可以将一个字符串看成很多 bit 的组合,这便是 bitmap「位图」数据结构.
简单命令:
添加: set key1 value1
取值: get key1
list (列表)
Redis 的列表相当于 Java 语言里面的 LinkedList,注意它是链表而不是数组。这意味着list 的插入和删除操作非常快,时间复杂度为 O(1),但是索引定位很慢,时间复杂度为O(n),这点让人非常意外。
当列表弹出了最后一个元素之后,该数据结构自动被删除,内存被回收。
Redis 的列表结构常用来做异步队列使用。将需要延后处理的任务结构体序列化成字符串塞进 Redis 的列表,另一个线程从这个列表中轮询数据进行处理。
简单命令:
添加:rpush key1 value1 value2 value3
按队列操作(先进先出):lpop key1 ->返回value1
按栈操作(后进先出): rpop key1 ->返回value3
hash (字典)
Redis 的字典相当于 Java 语言里面的 HashMap,内部实现结构上同Java 的 HashMap 也是一致的。
简单命令:
添加 : hset key1 java "think in java"
hset key1 c "c language"
获取: hget key1 java ->返回think in java
set (集合)
Redis 的集合相当于 Java 语言里面的 HashSet,它内部的键值对是无序的唯一的。它的内部实现相当于一个特殊的字典,字典中所有的 value 都是一个值 NULL。
简单命令:
添加:sadd key1 value1
查询:smembers key1 ->返回value1
zset (有序列表)
zset 类似于 Java 的 SortedSet 和 HashMap 的结合体,一方面它是一个 set,保证了内部value 的唯一性,另一方面它可以给每个 value 赋予一个 score,代表这个 value 的排序权重。它的内部实现用的是一种叫「跳跃列表」的数据结构。
zset 中最后一个 value 被移除后,数据结构自动删除,内存被回收。
简单命令:
添加:
(integer) 1
(integer) 1
查询:返回0-1范围的值
1) "value2"
本篇先只做简单介绍,后续将在”源码篇“详述
容器型数据结构的通用规则
list/set/hash/zset 这四种数据结构是容器型数据结构,它们共享下面两条通用规则:
1、create if not exists
如果容器不存在,那就创建一个,再进行操作。比如 rpush 操作刚开始是没有列表的,Redis 就会自动创建一个,然后再 rpush 进去新元素。
2、drop if no elements
如果容器里元素没有了,那么立即删除元素,释放内存。这意味着 lpop 操作到最后一个元素,列表就消失了。
Redis有什么用途呢?后边内容讲的就是Redis的应用场景
二.千帆竞发 —— 分布式锁
分布式锁是用来解决并发问题的,本质上就是“抢占坑位”,这一点从分布式锁的指令上就可以看出来。
分布式锁使用指令:setnx ,是set if not exists的缩写,如果不存在就设置一个值并返回成功(1),如果已经存在值就返回失败(0)。
示例如下,通过setnx加锁,del删除锁
(integer) 1
(integer) 0
(integer) 1
(integer) 1
如果需要加过期时间,则使用 set ..ex..nx 指令
OK
(nil)
#5s后自动释放了锁
OK
(integer) 0
需要注意的是,使用过期时间后不要在执行时间比较长的事务上使用Redis锁,如果事务的执行时间超过了过期时间,同样会引发并发问题。
如果需要使用Redis的分布式锁实现可重入锁,可配合JAVA的threadLocal进行实现。
三.缓兵之计 —— 延时队列
异步消息队列
上文已经提到,Redis的list结构可以用来做消息队列,使用push/lpush操作入队列,使用 lpop 和 rpop 来出队列。比如我们需要在集群环境下做一下异步导出等任务的管理,合理控制系统资源的使用率,使用Redis的list作为消息队列是一个可以考虑的方案,可以将Redis队列结合JAVA的线程池来共同管理任务。
以异步导出为例,假设系统集群有三台机器,用户端一共提交了1000个导出任务,任务压到了redis中,每台机器负责管理导出线程的线程池核心线程数是30,最大线程数是30,排队物理数量上限是0。
首先有个定时任务每5s执行一次,先使用ThreadPoolExecutor的相关API判断线程池中正在执行的线程数,未达到上限时使用lpop取出一个任务加入线程池中,直到达到核心线程数的数量。
思考一下上述方案有没有问题?
答案是有。使用上述方案存在可靠性问题,使用lpop命令后,数据就会从list中被清除,如果这个时候服务器发生了宕机或重启,任务没有执行,那么任务就丢失了。
怎么解决呢?可以设置超时时间 任务每完成一页的数据导出后就更新一次 如果超过指定的时间(超时时间)任务没有完成 更新时间也没有变更 则判定为执行失败 重新加入到队列中排队
延迟队列
再考虑另外一种业务场景,我们再12306进行抢票时,如果只是提交订单但是不支付,系统会提示15分钟后将自动取消订单。这种场景我们可以考虑使用Redis的延迟队列实现。
Redis可以zset(有序列表可以实现)延迟队列。
zset提供了score ,我们可以用订单ID作为value,到期时间的时间戳作为score,将订单添加到Redis中
比如:订单ID为54321的订单 2020-09-12 17:45:00提交 但是未支付,系统允许的支付截止时间是2020-09-12 18:00:00 ,订单提交是我们进行如下操作
> zadd order:submit:unpay 1599904800 54321
(integer) 1
系统通过定时任务判断超出截止日期的单据进行取消
zrangebyscore order:submit:unpay 0 1599904801
1) "54321"
处理完成后移除元素
> zrem order:submit:unpay 54321
1
四.节衣缩食 —— 位图
位图不是特殊的数据结构,它的内容其实就是普通的字符串,也就是 byte 数组,我们可以使用普通的 get/set 直接获取和设置整个位图的内容,也可以使用位图操作 getbit/setbit等将 byte 数组看成「位数组」来处理。位图适合存储一些布尔型的数据(打开、统计在线用户、活跃用户等)。
比如我们需要统计每个用户的打卡记录,我们可以针对每个用户每天的打卡状态都用一个key/value进行存储,当用户数量很大时,数据量也是很庞大的。
如果使用位图,我们可以把位图当成一个byte数组,登陆存1 未登陆存0 365天只需要365个比特位 46个字节就可以存储。
我们可以将用户的注册时间作为起始的偏移量0,然后根据系统时间和注册时间就可以获取到每天的偏移量,进行值的设置。
比如某用户在注册的前四天及第8、9、10、11天打卡 那指令如下:
0
0
0
0
0
0
0
0
判断第4天有没有打卡
1
获取累计打卡数
8
获取字符串字节数
(integer) 2
除此之外还支持范围查询 不过范围查询不是按照偏移量,而是字节数,闭区间,如果需要精确的获取一定偏移量的范围,只能先按字节获取,然后对数据进行二次分析。
bitcount key [start end]
比如获取第一周的打卡次数
"\\xF1"
表示16进制的F1 转换为2进制为11110001 每一位表示一天的打卡情况 1为打开 0为未打卡 前七天打开天数为4
获取前10天的打卡次数
"\\xF1\\xE0"
表示10进制F1E0 对应的2进制为11110001 11100000 同理 前10天打开天数为7天
五.四两拨千斤 —— HyperLogLog
HyperLogLog主要有两个指令,pfadd和pfcount,前者是增加计数,后者是返回数量。
使用方案如下:
(integer) true
(integer) true
(integer) true
(integer) false
(integer) 3
从执行结果可以看到pfadd和sadd方法很相似 都会去重。
HyperLogLog 是Redis用来提供统计功能的,提供不精确的去重计数方案,虽然不精确但是也不是非常不精确,标准误差是 0.81%。
适用于一些数据量大的场景,比如统计每个页面的UV,当然使用set也可以实现,但假如系统的访问量很大,页面很多,每个页面要存储的set数据的将占据很大的空间,而UV这种数据当数量级比较大时我们也不需要一个准确值,0.81%的误差是完全可以接受的。
当然,优势也是明显的,HyperLogLog无论如何只占用12K的内存,而如果用set 存储1000W个IP 需要占用150M左右的内存,当真是四两拨千斤。
六.峰峦叠嶂 —— 布隆过滤器
布隆过滤器是一个可以实现去重同时相对与set又能节省90%的内存空间的数据结构,不足时有一定的误判几率,但是仍能保证去重,误判范围是使用contains方法判断某个值是否存在时,如果布隆过滤器中已经存在,肯定会返回已存在,如果不存在,有一定几率返回已存在。
应用场景也比较广泛,比如广告推荐 视频推荐 文章推荐等推荐功能、数据的去重功能等,数据量大,又能容忍一定的误判。
同时 布隆过滤器也是避免缓存击穿的有种有效手段,对于一些不存在的key,如果用户恶意请求,那么请求每次都会击穿Redis缓存,请求直接打到DB上,我们可以将不存在的key加入到布隆过滤器中,提供一次拦截。
简单命令:
新增
bf.add codehole user1
bf.add codehole user2
bf.add codehole user3
判断是否存在
bf.exists codehole user3
注意:布隆过滤器的 initial_size 估计的过大,会浪费存储空间,估计的过小,就会影响准确率,用户在使用之前一定要尽可能地精确估计好元素数量,还需要加上一定的冗余空间以避免实际元素可能会意外高出估计值很多。
七.断尾求生 —— 简单限流
有些场景中,由于各种原因,我们往往需要限制用户在一定时间范围内的操作次数,比如限制用户在5秒内刷新次数不得超过2次,也就是需要限制用户的请求次数,也就是限流。
限流的重点主要在于一个滑动的时间窗口,比如这10秒可以是第1秒-第10秒 也可以是第2秒到第11秒。
结合在前文延迟队列中涉及的内容,是不是使用zset的score也可以实现?
同样是存储用户操作的时间戳,具体如下:
其中key的格式是 请求url的ID:用户ID value是一个无意义的值(这里也用时间戳) score为时间戳 使用毫秒时间戳
step1:用户2020-09-12 12:00:00 刷新一次
时间窗口为2020-09-12 11:55:00 (15998829000000) - 2020-09-12 12:00:00 (1599883200000)
移除时间窗口之前的记录:
> zremrangeByScore url_1:user_1 0 15998829000000
0
判断当前行为数量:
0
已存在的行为数量0<=2 允许操作 新增行为
> zadd url_1:user_1 1599883200000 1599883200000
(integer) 1
Step2:用户2020-09-12 12:00:02 刷新一次
时间窗口为2020-09-12 11:57:00 (1599883020000) - 2020-09-12 12:00:02 (1599883320000)
移除时间窗口之前的记录:
> zremrangeByScore url_1:user_1 0 1599883020000
0
判断当前行为数量:
1
已存在的行为数量1<2 允许操作 新增行为
> zadd url_1:user_1 1599883320000 1599883320000
(integer) 1
Step3:用户2020-09-12 12:00:03 刷新一次
时间窗口为2020-09-12 11:58:00 (1599883080000) - 2020-09-12 12:00:03 (1599883380000)
移除时间窗口之前的记录:
> zremrangeByScore url_1:user_1 0 1599883080000
0
判断当前行为数量:
2
已存在的行为数量2=2 不允许继续操作 可以直接提示用户
Step4:用户2020-09-12 12:00:06 刷新一次
时间窗口为2020-09-12 12:01:00 (1599883260000) - 2020-09-12 12:00:06 (1599883560000)
移除时间窗口之前的记录:
> zremrangeByScore url_1:user_1 0 1599883260000
1
判断当前行为数量:
1
已存在的行为数量1<2 允许继续操作
> zadd url_1:user_1 1599883560000 1599883560000
(integer) 1
这样我们就限制了用户在一定时间范围内的请求次数,实现方式也比较暴力,就是超出范围就拒绝,所以是断尾求生。
八.一毛不拔 —— 漏斗限流
在第七节中,限流的方式比较暴力,直接拒绝。如果在一些场景中,用户请求不能被拒绝,而是因为受限于系统资源的原因,无法同时处理大量请求,超出处理能力范围的请求需要允许一段时间后重试。
Redis4.0提供的redis-cell模块,支持漏斗限流。
该模块只有一个指令:
> cl.throttle url_1:user_1 15 30 60
参数分别是:
1.key
2.漏斗初始容量 即一开始不受漏斗限制 可以连续进行15次请求 之后会受漏斗速率影响
3/4 漏斗速率 30 60表示 60s内最多允许30次操作
可以把漏斗限流当成是一个令牌,每次处理前先使用该命令获取令牌,该命令的返回结果如下
1) (integer) 0 # 0 表示允许,1 表示拒绝
2) (integer) 15 # 漏斗容量 capacity
3) (integer) 14 #漏斗剩余空间 left_quota
4) (integer) 10 # 如果拒绝了,需要多长时间后再试(漏斗有空间了,单位秒)
5) (integer) 20 # 多长时间后,漏斗完全空出来(left_quota==capacity,单位秒)
在执行限流指令时,如果被拒绝了,就需要丢弃或重试。cl.throttle 指令考虑的非常周到,连重试时间都帮你算好了,直接取返回结果数组的第四个值进行 sleep 即可,如果不想阻塞线程,也可以异步定时任务来重试。
总结
到这里我们可以再回顾下Redis可以做什么?
1.Redis最简单的功能是提供缓存功能,相对于关系型数据库来说 读写速度快
2.Redis可以提供分布式锁功能
3.可以分别使用Redis的list和zset结果实现简单的异步消息队列和延迟队列
4.bitmap(位图)提供了提供了一种存储机制 对于存储打卡、登陆等可以使用布尔型(0/1)数据的场景 可以节省大量的存储空间
5.HLL功能 在节省大量存储空间的基础上(每个key仅需12K) 提供一种有一定误差(最高0.81%)的去重的统计功能 比如统计UV等
6.提供布隆过滤器 提供去重功能 相对set可以节省90%的存储空间 有一定误差
7.使用zset可以实现简单限流,也可使用redis-cell模块提供的漏斗限流
8.除此之外 redis还提供了地理位置 GEO 模块,使用GeoHash可以实现附件的人、附近的店等类似功能
9.redis提供了scan功能,支持基于正则的模糊查询
以上是关于Redis深度历险:核心原理和技术实现(基础及应用篇)的主要内容,如果未能解决你的问题,请参考以下文章