Redis系列之进阶篇(上)
前言
上一期我们学习了Redis常用的数据结构和一些基本用法,今天我们来学点Redis的高级技术。
由于本章节篇幅过长,所以分为上下两次讲解。
这篇文章主要内容是:
分布式锁
延时队列
位图
-
HyperLogLog
本文所学知识点过多,请做好实践。
1. 分布式锁
分布式应用进行逻辑处理时经常会出现并发问题。
两个用户同时给一个账号转账,就会出现并发问题。因为获取和转账这两个操作不是原子操作。==(原子操作是指几个操作要么全部执行,要么全部不执行,中间不会出现任何线程切换)==
这时就需要用分布式锁来限制程序的并发执行。Redis的分布式锁使用非常广泛,也经常在面试中会被问到。
1.1 简单使用
分布式锁本质上就是在Redis里加把锁,当别的用户(线程)也要来加锁时,发现这里已经被锁过了,就只能放弃或稍后再试。
加锁一般使用setnx(set if no exists)指令,只允许被一个线程加锁。先来的先加锁,当不需要了,再调用del指令”解锁“。
> setnx lockname true # 加锁,随意名称lockname,随意赋值true
> del lockname # 解锁
但是这种使用会有一个问题,若逻辑执行出现异常,可能会导致del指令没有被调用,这个锁就永远留着了,出现死锁问题。
所以在拿到锁的时候,给锁加个过期时间,防止出现死锁。
> setnx lockname true # 加锁
> expire lockname 5 # 过期时间:5秒后锁自动释放
但是这样还是会出现问题,若在 setnx 和 expire 指令之间服务器挂掉了,就会导致expire指令无法执行,也会出现死锁。
这种问题的根源也是因为 setnx 和 expire 这两条指令不是原子指令。如若两个指令可以一起执行就不会出现问题。有些同学可能会想到用redis的事务来解决,但这里不行,expire指令是要依靠setnx的执行结果的,若setnx没有抢到锁,expire不应该执行。
> multi # 开启事务
> exec # 提交事务
> discard # 取消事务
> watch name # 监控一个(多个)key,若事务执行间,key值被改变,则事务被打断
> unwatch # 取消监控
为了解决这种问题,Redis开源社区提供出了很多分布式锁的library,实现很复杂,需要花费大量精力才能弄懂。
在Redis2.8版本,作者加入了set指令的扩展参数,使得setnx和expire指令可以一起执行。
set lockname true ex 5 nx # nx 不存在则加锁 ex 过期时间
# setnx和expire组合在一起的原子指令
1.2 超时问题
Redis的分布式锁不能解决超时问题,如果在加锁和释放锁之间的逻辑执行过长以致于超出了锁的超时限制,就会出现问题。因为当前线程的逻辑还没走完,锁就过期了,同时第二个线程又持有了锁,导致当前线程代码不能严格串行执行。
为了避免超时问题,Redis分布式锁尽量不使用于较长时间的任务。
1.3 锁的释放
Redis中并没有提供释放锁的行为只能由加锁的线程来执行,所以为了确保当前线程持有的锁不被其他线程释放,可以将set指令的值,设置为随机数,释放锁时,先匹配下随机值是否一致,再释放锁。
但匹配随机值和释放锁并不是一个原子操作,Redis并没有提供类似的指令,这时需要用到Lua脚本来处理,因为Lua脚本能保证连续多个指令的原子性执行。
# delifequals
# 检测value是否相同
if redis.cell("get",KEYS[1]) == ARGV[1] then
# 释放锁
return redis.call("del",KEYS[1])
else
return 0
end
1.4 可重入性
可重入性是指线程在持有锁的情况下再次请求加锁,若一个锁支持同一个线程的多次加锁,那么这个锁就是可重入的。Redis分布式锁如果要支持可重入式,需要对客户端的set方法进行包装,使用线程的ThreadLocal变量存储当前持有锁的计数。
可重入锁不推荐使用,它加重了客户端的复杂性,编写业务方法时注意在逻辑结构上进行调整完全可以不适用可重入锁。
1.5 锁冲突
当客户端在处理请求时若加锁没加成功,一般有以下3种策略来处理加锁失败。
- 直接抛出异常,通知用户稍后重试
这种方式当出现异常,把异常返回给前端,让用户来决定是返回还是重试。
- sleep一会,然后再重试
sleep会阻塞当前的消息处理线程,会导致队列的后续处理出现延迟。
- 将请求转移到延迟队列,过一会再试
这种方式比较适合异步消息处理,将当前冲突的请求扔到另一个队列延后处理以避开冲突。
2. 延时队列
相比于Rabbitmq和Kafka使用的烦琐程度,Redis的使用显得非常轻松简单。但需要注意==Redis的消息队列不是专业的消息队列,它没有非常多的高级特性,也没有ack保证,如果对消息的可靠性有着极高的要求,那么它就不适合使用。==
2.1 异步消息队列
Redis的list(列表)数据结构常用来作为异步消息队列使用,用rpush和lpush操作入队列,用lpop和rpop操作出队列。
它可以支持多个生产者和多个消费者并发进出消息,每个消费者拿到的消息都是不同的列表元素。
2.2 队列死循环
客户端通过队列的pop操作来获取消息进行处理,但是当队列空了,客户端就会陷入pop的死循环,不停的pop来获取消息。这种空轮询不但拉高了客户端的CPU消耗,Redis的QPS(每秒查询率)也会被拉高。
通常使用sleep来解决问题,让线程休眠一会。不但CPU消耗能降下来,QPS也会降下来。
Thread.sleep(1000); # java sleep 1s
这种方法虽然解决了死循环的问题,但还是会遗留下新的小问题,睡眠会导致消息的延迟增大。有什么办法能显著降低延迟?
使用blpop和brpop。这两个指令的前缀字符b代表的是blocking,也就是阻塞读。阻塞读在队列没有数据的时候,会立刻进入休眠状态,一旦数据到来,则立刻醒过来。消息的延迟几乎为零。用blpop和brpop替代前面的lpop/rpop,就完美解决上述问题。
但是这个时候又有新的问题出现了...空闲连接的问题。
如果线程一直阻塞在那里,Redis的客户端连接就成了闲置连接,闲置过久,服务器一般会主动断开连接,减少闲置资源占用。这时blpop/brpop会抛出异常,所以编写消费者时要小心,如果捕获到异常,还要重试。
2.3 延迟队列的实现
延迟队列可以通过Redis的zset(有序列表)来实现。我们可以将消息序列化为一个字符串作为zset的value,这个消息的到期处理时间作为score,然后用多线程轮询zset获取到期的任务进行处理。多线程是为了保障可用性,万一挂了一个线程还有其他线程可以继续处理。==多线程需要考虑并发争抢任务,确保一个任务不会被多次执行。==
延时队列核心指令
# 把延时任务添加到zset,value存msg信息, score存执行这个任务的时间
> zset msgs 当前时间+延时时间 msg
# 截取score为当前时间的 第一条 数据
> zrangebyscore msgs 0 当前时间 0 1
2.4 Redis作为消息队列为什么不能保证100%的可靠性?
Redis来做消息队列采用发布-订阅模式。这种模式是一对多的关系,即一条消息会被多个消费者消费。不能保证每个消费者都能接收到消息,没有ACK机制,无法要求消费者收到消息后进行ACK确认。如果消息丢失、Redis宕机部分数据没有持久化甚至网络抖动都可能带来数据的丢失。
3. 位图
在平日开发中,会有一些boolean类型需要存取,如用户一年的签到记录,签了是1,没签是0,要记录365天。若用key/value,每个用户要记录365个,当用户上亿,需要的存储空间是惊人的。==此例仅供参考==
为了解决类似问题,Redis提供了位图数据结构,这样每天的签到记录只占据一个位,365天就是365位,46个字节就可以完全容下,这大大节省了存储空间。位图的最小单位是bit,每个bit只能是0或1.
位图不是特殊的数据结构,它的内容就是普通的字符串,也就是byte数组。可以==使用普通的get/set直接获取整个位图的内容,也可以使用位图操作getbit/setbit等将byte数组看作“位数组”来处理==。
3.1 简单使用
Redis的位数组是自动扩展的,若设置了某个下标位置超过了现有内容范围的,就会将那些没有的自动零扩充。
下图分别为字符h、e转换为byte数组 和 位数组的显示。==注意位数组的顺序和字符的位顺序是相反的。==
==如若对应位的子节是不可打印字符,会显示该字符的十六进制形式。==
【零存整取】
使用setbit进行存储,get进行整取。
> setbit str 1 1
> setbit str 2 1
> setbit str 4 1
> setbit str 9 1
> setbit str 10 1
> setbit str 13 1
> setbit str 15 1
> get str
【零存零取】
使用单个位设置位值,使用单个位操作获取具体位值。
> setbit str 1 1
> setbit str 2 1
> setbit str 4 1
> getbit str 1
> getbit str 2
> getbit str 4
> getbit str 5
【整存零取】
使用字符串操作批量设置位值,使用单个位操作获取具体位值。
> set str h
> getbit str 1
> getbit str 2
> getbit str 4
> getbit str 5
【统计和查找】
bitcount用来统计指定范围内1的个数,bitpos用来查找指定范围内出现的第一个0或1
> set str hello
> bitcount str
> bitcount str 0 0 # 第一个字符中 1 的位数
> bitcount str 0 1 # 前两个字符中 1 的位数
> bitpos str 0 # 第一个 0 位
> bitpos str 1 # 第一个 1 为
> bitpos str 1 1 1 # 从第二个字符查找第一个1位
> bitpos str 1 2 2 # 从第三个字符查找,第一个1位
3.2 魔术指令bitfield
前面设置指定位值都是单个设置(setbit)和获取(getbit)的,如若一次操作多位,就必须用管道来处理。不过在Redis3.2版本后新增功能强大的指令bitfield,这个指令可以一次进行多个位操作。
bitfield有三个子指令,分别为get/set/incrby.它们都可以对指定位片段进行处理,但是最多可以处理64个连续的位,如若超过64位,则需要使用多个子指令,bitfield可以一次执行多个子指令。
有符号数就是指获取的位数组中第一个位是符号位,剩下的才是值。若第一个位是1则表示是负数,无符号位表示非负数。
【get】
> set str hello
> bitfield str get u4 0 # 从第一个位开始取4个位,结果是无符号数(u)
> bitfield str get u3 2 # 从第三个位开始取3个位,结果是无符号数(u)
> bitfield str get i4 0 # 从第一个位开始取4个位,结果是有符号数(i)
【set】
a的ASCII码是97
> bitfield set u8 8 97 # 从第9个位开始,将接下来的8个位用无符号数97替换
【incrby】
用于对指定范围的位进行自增操作。可能会出现溢出,默认策略是折返,如若是8位无符号数255加一就会溢出,全部变成零。
如若是8位有符号数127,如若溢出就会变为-128.
> bitfield str incrby u4 2 1 # 从第3个位开始,对接下来4个无符号位 +1
对于溢出,bitfield指令提供了溢出策略子指令overflow,默认是折返(wrap),还可以选择失败(fail),以及饱和截断(sat)。overflow指令只会影响接下来的第一个指令,这个指令执行完后溢出策略会变成默认值折返wrap)。
【失败不执行(fail)】
> bitfield str overflow fail incrby u4 2 1
【饱和截断(sat)】
超过了范围就停留在最大或最小值。
> bitfield str overflow sat incrby u4 2 1
4. HyperLogLog
Redis提供HyperLogLog数据结构来解决一系列统计问题,例如网站UV。HyperLogLog提供不精确的去重记数方案,标准误差是0.81%。HyperLogLog是Redis的高级数据结构。
4.1 使用方法
HyperLogLog提供了两个指令pfadd(增加计数)和pfcount(获取计数)。
> pfadd uv keben
> pfadd uv xiaocai
> pfadd uv gulaoshi zhouhui wangli
> pfcount uv
4.2 扩展知识
HyperLogLog这个数据结构的发明人是Philippe Flajolet,pf是他的名字的缩写。
HyperLogLog除了提供pfadd和pfcount之外,还提供了pfmerge,用于将多个pf计数值累计加在一起,形成一个新的pf值。
4.2.1 注意
HyperLogLog需要占据12KB的存储空间,不适用于统计单个用户相关的数据。不过Redis对HyperLogLog的存储进行了优化,在计数比较小时,它的存储空间采用稀疏矩阵存储,空间占用小,仅仅在计数慢慢变大,稀疏矩阵占用空间渐渐超过了阈值时,才会一次性转变为稠密矩阵,才会占用12KB的空间。
4.3 大概原理
HyperLogLog的使用非常简单,但实现原理较为复杂。这里就讲下它的大概原理。
HyperLogLog通过概率算法不直接存储数据集合本身,通过一定的概率统计方法预估基数值。这种方式可以大大节省内存,同时保证误差控制在一定范围内。