本文将介绍redis高频使用的一个场景——「利用Redis实现分布式锁」

想必大家都知道,在遇到并发问题时,我们通常会使用锁来解决并发问题。

这时,有同学可能说:“这个我会,不就用synchronized、Lock这些实现吗?”

对,你说得不错。但是你只说对了一半,在「传统单机部署」的情况下,可以使用Java并发处理相关的API(如ReentrantLcok或synchronized)进行互斥控制。

但是在「分布式系统」中,由于分布式系统「多线程」「多进程」并且「分布在不同机器」上,这将使原单机并发控制锁策略失效,为了解决这个问题就需要一种「跨JVM的互斥机制」来控制共享资源的访问,这就得靠分布式锁啦。

看透锁本质

在我看来:所有的锁本身都可以用一个变量来表示。

比如:在「单机上运行」的多线程程序来说。取一个变量,变量为0时,表示没有线程获取锁;变量为1时,表示已经有线程获取锁。

加锁:线程调用加锁操作,检查变量是否为0,如果为0,表示没线程获取锁,将变量设置为1,表示获取锁;如果不是0,表示其他线程已经暂用锁,获取锁失败。

解说:同理。

如何正确使用redis分布式锁(Redis应用篇众星追月)(1)

而分布式环境下,同样可以以变量形式理解分布式锁。

但是,和线程在单机上操作锁不同的是,在分布式场景下,「锁变量需要由一个共享存储系统来维护」,只有这样,多个客户端才可以通过访问共享存储系统来访问锁变量。相应的,「加锁和释放锁的操作就变成了读取、判断和设置共享存储系统中的锁变量值」

「可见,满足分布式锁的要求」

上面我们提到了可以使用一个锁变量来表示锁,其实你也可以理解为「占位」。只不过分布式锁需要把这个坑位拿出来放于「共享」的地方,每个都从「共享处来检查坑位」

占位一般是使用 setnx(set if not exists) 指令,只允许被一个客户端占位。先来先占, 用完了,再调用 del 指令释放茅坑。

//加锁 > setnx lock_key 1 OK //业务逻辑 >(其他操作) //释放锁 > del lock_key

但是有个问题,如果逻辑执行到中间出现异常了,可能会导致 del 指令没有被调用,这样就会「陷入死锁」,锁永远得不到释放。

于是我们在拿到锁之后,再给锁加上一个过期时间,这样即使中间出现异常也可以保证指定时间之后锁会自动释放。

//加锁 > setnx lock_key 1 OK > expire lock_key 5 //业务逻辑 >(其他操作) //释放锁 > del lock_key

但是以上逻辑还有问题。如果在 setnx 和 expire 之间服务器进程突然挂掉了,可能是因为机器掉电或者是被人为杀掉的,就会导致 expire 得不到执行,也会造成死锁。

这种问题的根源就在于 setnx 和 expire 是两条指令而不是原子指令。你也许会想到使用事务什么的执行,但是这里不行,因为如果 setnx 没抢到锁,expire 是不应该执行的。

Redis 2.8 版本中作者加入了 set 指令的扩展参数,使得 setnx 和expire 指令可以一起执行,彻底解决了分布式锁的乱象。

set key value [EX seconds | PX milliseconds] [NX]

除了上述基本常规的问题,还有这些「你可能没考虑到的问题」

超时问题

Redis 的分布式锁不能解决超时问题,如果在加锁和释放锁间的业务逻辑执行时间太长,以至于超出了锁的超时限制,就会出现问题(也就是锁过期了,你的业务逻辑还没执行完)。

因为这时候锁过期了,第二个客户端B重新持有了这把锁,但是紧接着客户端A执行完了业务逻辑,就把锁给释放了,客户端C就会在客户端B逻辑执行完之间拿到了锁。为了避免这个问题,Redis 分布式锁不要用于较长时间的任务。

如何正确使用redis分布式锁(Redis应用篇众星追月)(2)

为了应对这个问题,我们需要能区分来自不同客户端的锁操作,具体咋做呢 ? 针对于这个问题,我们可以想办法把命令略加点小技巧。可以在锁变量的值上想想办法。

在使用SETNX命令进行加锁的方法中,我们通过把锁变量值设置为1或0,表示是否加锁成功。1和0只有两种状态,无法表示究竟是哪个客户端进行的锁操作。

所以,我们在加锁操作时,可以「让每个客户端给锁变量设置一个唯一值」,这里的唯一值就可以用来标识当前操作的客户端。

在释放锁操作时,客户端需要判断,当前「锁变量的值是否和自己的唯一标识相等」,只有在相等的情况下,才能释放锁。这样一来,就不会出现误释放锁的问题了。

于是,我们的命令可以这样写:

//加锁,unique_value作为客户端唯—性的标识 SET lock_key unique_value NX PX 5000

其中,unique_value 是客户端的唯一标识,可以用一个随机生成的字符串来表示,PX 5000则表示 lock_key会在5s后过期,以免客户端在这期间发生异常而无法释放锁。

因为在加锁操作中,每个客户端都使用了一个唯一标识,所以在「释放锁操作」时,我们需要「判断锁变量的值」,是否等于执行释放锁操作的客户端的唯一标识,如下所示,可以使用Lua脚本来保证原子性:

//释放锁比较unique_value是否相等,避免误释放 if redis.call("get" ,KEYS[1])== ARGV[1] then return redis.call("del" , KEYS[1]) else return 0 end

可重入性

可重入性是指线程在持有锁的情况下再次请求加锁,如果一个锁支持同一个线程的多次加锁,那么这个锁就是可重入的。比如 Java 语言里有个 ReentrantLock 就是可重入锁。

Redis 分布式锁如果要支持可重入,可以对客户端的 set 方法进行包装,使用线程的 Threadlocal 变量存储当前持有锁的计数。

此处就不过多介绍,大抵不会问,有兴趣可以自己上网查阅看书。

课外补充

上述内容,是个基于单个Redis节点实现分布式锁。

当我们要实现「高可靠的分布式锁」时,就不能只依赖单个的命令操作了,我们需要按照一定的步骤和规则进行加解锁操作,否则,就可能会出现锁无法工作的情况。“一定的步骤和规则”是指啥呢?其实就是分布式锁的算法。

这里简单介绍Redlock算法的执行步骤。Redlock算法的实现需要有N个独立的Redis实例。接下来,我们可以分成3步来完成加锁操作。

1、客户端获取当前时间

2、客户端按照顺序在每个Master实例中尝试获得锁。在获得锁的过程中,为每一个锁操作设置一个快速失败时间(如果想要获得一个10秒的锁,那么每一个锁操作的失败时间设为5-50ms)。

这样可以避免客户端与一个已经故障的Master通信占用太长时间,通过快速失败的方式尽快的与集群中的其他节点完成锁操作。

3、客户端计算出与master获得锁操作过程中消耗的时间,「当且仅当Client获得锁消耗的时间小于锁的存活时间,并且在一半以上的master节点中获得锁」。才认为client成功地获得了锁。

4、如果已经获得了锁,「Client执行任务的时间窗口是锁的存活时间减去获得锁消耗的时间。」

5、如果Client获得锁的数量不足一半以上,或获得锁的时间超时,那么认为获得锁失败。客户端「需要尝试在所有的master节点中释放锁, 即使在第二步中没有成功获得该Master节点中的锁,仍要进行释放操作。」

,