CDEFGAB 1010110

挖了太多坑,一点点填回来

使用redis实现分布式锁

lock, redis,

redis除了是很强大的存储/缓存工具之外,还可以实现分布式锁。

下面我们来看看到底怎么样用redis来实现分布式锁。

为什么要使用锁?

第一个是正确性,这个众人皆知。就像Java里的synchronize,就是用来保证多线程并发场景下,程序的正确性。在redis的场合下,并发访问的单位,不再是线程,而是进程。

举个例子,一个文件系统,为了提高性能,部署了三台文件服务器。当服务器A在修改文件A的时候,其他服务器就不能对文件A进行修改,否则A的修改就会被覆盖掉。

锁还有第二个用处——效率。比如应用A有一个耗时的统计任务,每天凌晨两点,定时执行,这时我们给应用A部署了三台机器,如果不加锁,那么每天凌晨两点一到,这三台机器就都会去执行这个很耗时的统计任务,而实际上,我们最后只需要一份统计结果。

这时候,就可以在定时任务开始前,先去获取锁,获取到锁的,执行统计任务,获取不到的,就直接结束。

分布式锁和本地锁的区别是什么?

单机,并发的单位是线程,分布式,并发的单位是多进程。并发单位的等级上去了,锁的等级自然也得上去。以前锁是进程自己的,进程下的线程都看这个锁的眼色行事,谁拿到锁,谁才可以放行。进程外面还有别的进程,你要跟别人合作,就不能光看着自己了,得有一个大家都看得到的,光明正大的地方,来放这把锁。

获取锁

要怎么在redis里获取一把锁呢?貌似很简单,执行set命令就好了,还是上面文件系统的例子,比如你想修改文件id是9527的文件,那就往redis里,添加一个key为file:9527,value为任意字符串的值即可:

1
set file:9527 ${random_value}

set成功了,就说明获取到锁。

这样可以吗?很明显不行,set方法默认是会覆盖的,也就是说,就算file:9527已经有值了,set还是可以成功,这样锁就起不到互斥的作用。

那在set之前,先用get判断一下,如果是null,再去set?也不行,原因很简单,get和set都在客户端执行,不具有原子性。

要实现原子性,唯一的办法,就是只给redis发送一条命令,来完成获取锁的动作。

于是就有了下面这条命令:

1
set file:9527 ${random_value} NX

NX = If Not Existed 如果不存在,才执行set。

完美了吗?非也,这个值没有设置过期时间,如果后面获得锁的客户端,因为挂掉了,或者其他原因,没有释放锁,那其他进程也都获取不到锁了,结果就是死锁。

所以有了终极版的获取锁命令:

1
set file:9527 ${random_value} NX EX ${timeout}

使用EX参数,可以设置过期时间,单位是秒,另一个参数PX,也可以设置过期时间,单位是毫秒。

释放锁

好,最后再来看看释放锁。

有人说,释放锁,简单,直接del:

1
del file:9527

有问题吗?当然有,这会把别人的锁给释放掉。

举个例子:

1
2
3
4
5
6
A拿到了锁,过期时间5s
5s过去了,A还没释放锁,也许是发生了GC,也许是某个耗时操作
锁过期了,B抢到了锁
A缓过神来了,以为锁还是自己的,执行del file:9527
C抢到了锁,也进来了
B看看屋里的C,有看看刚出门的A,对着A吼了一句:尼玛,你干嘛把我的锁释放了

所以,为了防止把别人的锁释放了,必须检查一下,当前的value是不是自己设置进去的value,如果不是,就说明锁不是自己的了,不能释放。

显然,这个过程,如果放在客户端做,就又不满足原子性了,只能整在一起,一次性让redis server执行完。

这下redis可没有一条命令,可以做这么多事情的,好在redis提供了lua脚本的调用方式,只需使用eval命令调用以下脚本即可:

1
2
3
4
5
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

其实还有问题

了解完如何释放锁,再加上之前的获取锁,我们似乎已经可以用redis来实现分布式锁了。

但是,一如既往,问自己一句,完美了吗?没有漏洞了?嗯,很明显不是,上面讲的算法,都有一个前提:只有一台redis实例。

而生产环境里,我们是不可能只部署一个实例的,至少,我们也是主从的架构。redis的数据同步,不是强一致性的,毕竟作为一个缓存,要保证读写性能。

如果A往Master放入了一把锁,然后再数据同步到Slave之前,Master crash,Slave被提拔为Master,这时候Master上面就没有锁了,这样其他进程也可以拿到锁,违法了锁的互斥性。

如何解决这个问题?

Redlock算法

针对Redis集群架构,redis的作者antirez提出了Redlock算法,来实现集群架构下的分布式锁。

Redlock算法并不复杂,我们先简单描述一下,假设我们Redis分片下,有三个Master的节点,这三个Master,又各自有一个Slave。

好,现在客户端想获取一把分布式锁:

1
2
3
4
5
6
记下开始获取锁的时间 startTime
按照A->B->C的顺序,依次向这三台Master发送获取锁的命令。客户端在等待每台Master回响应时,都有超时时间 timeout。举个例子,客户端向A发送获取锁的命令,在等了 timeout 时间之后,都没收到响应,就会认为获取锁失败,继续尝试获取下一把锁
如果获取到超过半数的锁,也就是 3/2+1 = 2把锁,这时候还没完,要记下当前时间 endTime
计算拿到这些锁花费的时间 costTime = endTime - startTime,如果costTime小于锁的过期时间 expireTime,则认为获取锁成功
如果获取不到超过一半的锁,或者拿到超过一半的锁时,计算出 costTime >= expireTime,这两种情况下,都视为获取锁失败
如果获取锁失败,需要向全部Master节点,都发生释放锁的命令,也就是那段Lua脚本

当然这个Redlock算法也并不是万能的,也会有缺陷,我也在思考在哪些场景下会有这样的问题。但是在目前绝大情况下来说,Redlock已经足够用了。