分布式锁、队列和位图

目录

1.分布式锁

1.1 超时问题

1.2 可重入锁

1.3 RedLock算法

2.消息队列

2.1 队列为空?

2.2 优先队列

3.位图

3.1 基本使用

3.2 统计和查找

3.3 bitfield


1.分布式锁

分布式锁本质上要实现的目标是在Redis里面占一个坑,当别的进程也要占的时候,发现已经被占了,就只能放弃或者稍后重试。占坑一般使用setnx(set if not exists)指令,只允许被一个客户端占坑。先来先占,用完了再使用del指令释放。

MyRedis:0>setnx lock-job true
1
MyRedis:0>setnx lock-job true
0
MyRedis:0>del lock-job
1

但是有个问题是,如果逻辑执行到中间出现问题,可能会导致执行del指令没有被调用,这样就会陷入死锁,锁永远得不到释放。于是我们拿到锁之后需要加上过期时间,比如5s,这样即使中间出现异常也可以保证5s之后锁会自动释放。但是这样的话也有问题,如果在setnx和expire之间服务器进程突然挂掉了,就会导致expire得不到执行,也会造成死锁。这种问题的根源就在于setnx和expire是两条指令而不是原子指令。为了解决这个问题,Redis 2.8版本以后加入了set指令的扩展参数,使得setnx 和expire指令成为可以一起执行的原子指令:

MyRedis:0>set lock-job true ex 5 nx
OK
MyRedis:0>get lock-job
NULL
MyRedis:0>set lock-job true ex 50 nx
OK
MyRedis:0>ttl lock-job
43

1.1 超时问题

Redis的分布式锁无法解决超时问题,如果在加锁和释放锁之间的逻辑执行的太长,以至于超出了锁的超时限制,就会出现问题。因为这时候锁过期了,第二个线程重新持有了这把锁,但是紧接着第一个线程执行完了业务逻辑,就把锁释放了。为了避免这个问题,Redis分布式锁不要用于较长时间的任务。

1.2 可重入锁

可冲入性是指线程在持有锁的情况下再次请求加锁,如果一个锁支持同一个线程的多次加锁,那么这个锁就是可重入的。比如Java语言里有个ReentrantLock就是可冲入锁。Redis分布式锁如果要支持可重入,需要对客户端的set方法进行包装,使用线程的ThreadLocal变量存储当前持有锁的计数。但是一般不推荐使用可重入锁,它加重了客户端的复杂性,在编写业务方法时注意在逻辑结构上进行调整完全可以不使用可重入锁。

1.3 RedLock算法

Redis的一条指令就可以完成加锁操作,不过在集群环境下,这种方式是有缺陷的,它不是绝对的安全。比如在Sentinel的集群中,主节点挂掉时,从节点会取而代之,客户端上却并没有明显感知。原先一个客户端在主节点申请了一把锁,但是这把锁还没有来得及同步到从节点,主节点就突然挂掉了。然后从节点变为主节点,这个新的主节点没有这个锁,所以当另外一个客户端过来请求加锁时,立即就批准了。这样就导致了两个客户端同时持有一把锁,不安全性就产生了。

不过这种不安全性也仅仅是在主从发生failover的情况下才会产生,而且持续的时间极短,业务系统多数情况下是可以容忍的。

为了解决这个问题,Antirez发明了Redlock算法,它的流程很复杂,但是很多开源的library做了良好的封装,用户可以拿来即用,比如redlock-py。示例代码如下:

import redlock

addrs = [{
    "host": "localhost",
    "port": 6379,
    "db": 0
}, {
    "host": "localhost",
    "port": 6479,
    "db": 0
}, {"host": "localhost",
    "port": 6579,
    "db": 0
    }]
dlm = redlock.RedLock(addrs)
success = dlm.lock('user-martin', 5000)
if success:
    print('lock success')
    dlm.unlock('user-martin')
else:
    print('lock failed')

为了使用RedLock,需要提供多个Redis实例,这些实例之前相互独立没有主从关系。同很多分布式算法一样,redlock也使用“大多数机制”。

加锁时,它会向过半节点发送set(key,value,nx=True,ex=xxx)指令,只要过半节点set成功,那就认为加锁成功。释放锁时,需要向所有节点发送del指令。因为要向多个节点进行读写,意味着相比单实例Redis性能下降很多。代码上需要额外引入red lock的library,运维上也需要特殊对待,这些都是需要考虑的成本,使用前需要斟酌清楚。

关于ReadLock算法可以参考如下的文章:

https://redis.io/topics/distlock

2.消息队列

Redis的消息队列不是专业的消息队列,它没有非常多的高级特性,没有ack保证,如果对消息的可靠性有着极致的追求,那么它就不适合使用。Redis的list(列表)数据结构常用来作为异步消息队列使用,使用rpush/lpush操作入队列,使用lpop和rpop来出队列。

MyRedis:0>rpush notify-queue apple banana pear
3
MyRedis:0>llen notify-queue
3
MyRedis:0>lpop notify-queue
apple
MyRedis:0>lpop notify-queue
banana
MyRedis:0>lpop notify-queue
pear
MyRedis:0>llen notify-queue
0
MyRedis:0>lpop notify-queue
NULL

上面是rpush和lpop结合使用的例子,还可以使用lpush和rpop结合,效果是一样。

2.1 队列为空?

客户端通过队列的pop操作来获取信息,然后进行处理,处理完了再接着获取消息,再进行处理。如此循环往复,如果队列空了,客户端就会陷入pop的死循环,不停地pop,没有数据,接着再pop,还是没有数据。这种空轮询会拉高客户端的CPU,redis的QPS也会被拉高。通常情况下,我们可以使用sleep来解决这个问题,让线程睡1s左右,就可以了。

用上面睡眠的解决办法存在一个小问题,那就是睡眠会导致消息的延迟增大。我们可以使用阻塞指令,blpop/brpop。阻塞读blpop在队列没有数据的时候,就会立即进入休眠状态,一旦数据到来,就会立即醒过来,消息的延迟几乎为0。因此,实际当中我们用blpop/brpop替代前面的lpop/rpop。

这里有个坑就是,如果线程一直阻塞在那里,Redis的客户端就会成了闲置连接,闲置太久,服务器一般会主动断开连接,减少闲置资源的占用,这个时候blpop和brpop就会抛出异常。所以在编写客户端消费者的时候要注意捕获异常,进行重试。

2.2 优先队列

优先队列可以通过Redis的zset(有序列表)来实现。我们将消息序列化成一个字符串作为zset的value,这个消息的权重为score,然后用多个线程去轮询zset,获取权重较大的消息进行处理。多线程是为了保障可用性,万一挂了一个线程还有其他线程可以继续处理。因为有多个线程,所以需要考虑并发争抢任务,确保任务不能被多次执行。实现代码如下:

import time
import uuid

import redis
from pandas import json

r = redis.Redis(host='localhost', password='1234567')


def delay(msg):
    msg.id = str(uuid.uuid4())
    value = json.dumps(msg)
    retry_ts = time.time() + 5
    r.zadd('priority-queue', retry_ts, value)


def loop():
    while True:
        # 最多取1条
        values = r.zrangebyscore('priority-queue', 0, time.time(), start=0, num=1)
        if not values:
            time.sleep(1)  #队列为空 休息1s
            continue
        value = values[0] #拿到消息
        success = r.zrem('priority-queue', value) #从消息队列中移除该消息
        if success:
            msg = json.loads(value)    #因为有多线程并发问题,最终只有一个进程可以抢到消息
            print(msg)

Redis的zrem方法是多线程多进程争抢任务的关键,它的返回值决定了当前实例有没有抢到任务,因为loop方法可能被多个线程和多个进程调用,同一个任务可能被多个进程线程抢到,通过zrem来决定唯一的属主。

3.位图

在我们的平时开发过程中,会有一些bool型数据需要存取,比如用户一年的签到记录,签了为1,没签是0,要记录365天。如果使用普通的key/value,每个用户要记录365个,当用户上亿的时候,需要的存储空间是惊人的。为了解决这个问题,Redis提供了位图数据结构,这样每天的签到记录只占据一个位,365天就是365个位,46个字节就可以容纳下,这样就节约了存储空间。

位图不是特殊的数据结构,它的内容其实就是普通的字符串,也就是byte数组。我们可以使用普通的get/set直接获取和设置整个位图的内容,也可以使用位图操作getbit/setbit等将byte数组看成“位数组”来处理。

3.1 基本使用

Redis的位数组是自动扩展的,如果设置了某个偏移位置超出了现有的内容范围,就会自动将位数组进行零扩充。下面我们使用位操作将字符串设置为hello(不直接使用set命令),首先我们得到hello的ASCII码:

print(bin(ord('h')))
print(bin(ord('e')))
print(bin(ord('l')))
print(bin(ord('l')))
print(bin(ord('o')))

输出结果如下:
0b1101000
0b1100101
0b1101100
0b1101100
0b1101111

接下来,我们设置第一个字符,也就是位数组的前8位,我们只需要设置值为1的位。h字符只有1/2/4位需要设置,e字符9/10/13/15位需要设置。值得注意的是位数组的顺序和字符的位顺序是相反的:

MyRedis:0>setbit s 1 1
0
MyRedis:0>setbit s 2 1
0
MyRedis:0>setbit s 4 1
0
MyRedis:0>setbit s 9 1
1
MyRedis:0>setbit s 10 1
1
MyRedis:0>setbit s 13 1
0
MyRedis:0>setbit s 15 1
0
MyRedis:0>get s
he
MyRedis:0>getbit s 1
1
MyRedis:0>getbit s 3
0

我们也可以直接设置:

MyRedis:0>set w he
OK
MyRedis:0>getbit w 15
1
MyRedis:0>getbit w 13
1
MyRedis:0>getbit w 14
0

当然,如果对应位的字节是不可打印字符,就会显示该字符的16进制形式:

MyRedis:0>setbit x 0 1
1
MyRedis:0>setbit x 1 1
1
MyRedis:0>setbit x 2 2
ERR bit is not an integer or out of range
MyRedis:0>get x
\xc0

3.2 统计和查找

Redis提供了位图统计指令bitcount和位图查找指令bitpos,bitcount用来统计指定位置范围内1的个数,bitpos用来查找指定范围内出现的第一个0或1。比如我们可以通过指定范围参数[start,end],使用bitcount统计用户一共签到了多少天,bitpos指令查找用户从哪一天开始第一次签到。

但是这里有一点需要注意的是,start和end参数是字节索引,也就是说指定的位范围必须是8的倍数,而不能任意指定。这样,我们无法直接计算某个月内用户签到了多少天,而必须要将这个月所覆盖的字节全部取出来然后在内存里进行统计。接下来,我们以统计hello字符为例:

MyRedis:0>set w hello
OK
MyRedis:0>bitcount w
21
MyRedis:0>bitcount w 0 0  #第一个字符中1的位数
3
MyRedis:0>bitcount w 0 1  #前两个字符中1的位数
7
MyRedis:0>bitpos w 0      #第一个0位
0
MyRedis:0>bitpos w 1      #第一个1位
1
MyRedis:0>bitpos w 1 1 1  #第二个字符算起,第一个1位
9
MyRedis:0>bitpos w 1 2 2  #第三个字符算起,第一个1位
17

3.3 bitfield

Redis的3.2版本以后新增了一个功能强大的指令bitfield,该指令可以帮助我们一次进行多个位的操作。bitfield有三个子指令,分别是get/set/incrby,它们都可以对指定位片段进行读写,但是最多只能处理64个连续的位,如果超过64位,就得使用多个子指令,bitfield可以一次执行多个子指令。我们对照下面的图,继续看一个简单的实例:

MyRedis:0>set w hello
OK
MyRedis:0>bitfield w get u4 0   //从第一位开始获取4个位,结果为无符号数(u)
1) 6
MyRedis:0>bitfield w get u3 2   //从第三位开始获取3个位,结果为无符号数(u)
1) 5
MyRedis:0>bitfield w get i4 0   //从第一位开始获取4个位,结果为有符号数(i)
1) 6
MyRedis:0>bitfield w get i3 2   //从第三位开始获取3个位,结果为有符号数(i)
1) -3

所谓有符号数是指获取的位数组中第一个位是符号位,剩下的才是值。如果第一位是1,那就是负数。无符号数表示非负数,没有符号位,获取的位数组全部都是值。有符号数最多可以获得64位,无符号数最多只能获得63位,因为Redis协议中的integer是有符号数,最大64位,不能传递64位无符号值。如果超出限制,Redis就会报错。

我们也可以一次执行多个子指令:

MyRedis:0>bitfield w get u4 0 get u3 2 get i4 0 get i3 2
1) 6
2) 5
3) 6
4) -3

set子指令将第二个字符e改成a,a的ASCII码是97。

MyRedis:0>bitfield w set u8 8 97 #从第8个位开始,将接下来的8个位用无符号数97替换
1) 101
MyRedis:0>get w
hallo

再看第三个子指令incrby,它用来对指定范围的位进行自增操作。既然提到自增,就有可能出现溢出。如果增加了正数,会出现上溢,如果增加的是负数,就会出现下溢出。如果出现了溢出,就将溢出的符号位丢掉。如果8位无符号数255,加1后就会溢出,会全部变零。如果是8位有符号数127,加1后就会溢出编程-128。

MyRedis:0>set w hello
OK
MyRedis:0>bitfield w incrby u4 2 1   #从第三位开始,对接下来的4位无符号数+1操作
1) 11
MyRedis:0>bitfield w incrby u4 2 1
1) 12
MyRedis:0>bitfield w incrby u4 2 1
1) 13
MyRedis:0>bitfield w incrby u4 2 2
1) 15
MyRedis:0>bitfield w incrby u4 2 1   #溢出
1) 0

bitfield指令提供了溢出策略子指令overflow,用户可以选择溢出行为,默认是折返(wrap),还可以选择失败(fail)报错不执行以及饱和截断(stat),超出了范围就停留在最大最小值。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值