分布式锁实现方式

分布式锁的实现方式有很多中,比较常用的有redis,zookeeper,数据库等,本文将介绍下常用的实现方案以及优缺点,首先分布式锁也和其他锁一样,有共享锁,有排他锁,共享锁的比较简单,本文将主要讨论排他锁的获取锁和释放锁过程

redis

redis实现分布式锁也有很多方法,我们可以自己实现,也可以直接使用现有的基于redis的组件,比如redission

redis命令实现

目前主流的使用reids实现分布式锁的方案中,以下是比较适合大部分业务场景的,即使用如下命令实现

1
2
加锁 
SET key 随机值 NX EX|PX
1
2
3
4
5
6
释放锁 
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end

以上分别为获取锁和释放锁的命令,针对以上命令,我们可能会有如下疑问

  1. 为什么使用nx命令
    nx的语意是只有在key不存在的情况下,才会设置成功,这个语意再加上上述set命令是原子操作,这样就确保了在同一时刻,只有一个请求能够设置成功key值,也就是只有一个请求能拿到锁
  2. 为什么使用EX或者PX
    EX或者PX的语意都是给key设置一个过期时间,我们知道,即然有了获取锁的过程,那么必然会有释放锁的过程,对于上述方案而言,释放锁其实就是删除key,那么加入程序在删除key的时候出问题了,即使是finally也没有执行到,那么就会造成锁释放不了的问题
    所以,我们要给key加一个过期时间,避免锁无法释放的问题
  3. key的值为什么要随机
    加入A和B同时要获取锁,结果A获得,B没获得,那么在A获得之后,会给key一个过期时间,比如是1s,但是A业务逻辑执行了2s,才开始释放锁,那么在多于执行的这一秒之类,加入B在尝试获取锁,是可以获取成功的,接下来A再去释放锁,也就是删除key,这就意味着可能会释放其他用户锁获取的锁。
    所以,我们在获取锁的时候,要给key指定一个随机值,这样在释放的时候将预先设施的随机值和redis里面的value比较,如果相同,那么就说明我们释放的是同一个用户获取的锁
  4. 释放锁为什么要用lua脚本
    上述第三点也解释到了,我们释放锁的过程要分为两部,获取key的值,然后和预先设置的随机值比较,那么这就不是原子操作了,将会带来新的问题,比如get完之后刚好key的过期时间到了,也就意味着if判断通过了,开始执行删除key操作了,那么这中间B可能获取到锁了,这样,A又会把B的锁释放了。
    综上,我们需要释放锁为原子操作,所以选择lua脚本
    以上命令中,ARGV[1]表示设置key时指定的随机值。KEYS[1]表示需要加锁的资源,针对以上命令,我们可能会有如下疑问

redisson

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
加锁代码
Future tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId) {
internalLockLeaseTime = unit.toMillis(leaseTime);
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_LONG,
// 检查是否key已经被占用,如果没有则设置超时时间和唯一标识,初始化value=1
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
// 如果锁重入,需要判断锁的key,参数都一样的情况下,value+1
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
//锁重入重新设置超时时间
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
// 返回剩余的过期时间
"return redis.call('pttl', KEYS[1]);",
Collections.singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}

以上代码为redisson的获取锁逻辑,为了原子性,也是lua实现,相比于我们set nx的方式,redisson有以下特点

  1. 使用watch dog实现自动延期,比如A获取到锁,并且设置过期时间20s,但是业务执行了30s,那么watch dog线程就会检查到A还持有锁,然后就会延长key的存活时间
  2. 可重入锁,在上述代码第二个判断加锁参数一致的情况下,value+1,实现可重入
  3. 释放锁的过程中,每次对value-1,如果为0,则说明锁释放,执行del命令

总结:在生产环境中,建议使用redisson,redisson的设计更加全面,而且提供了丰富的锁类型,比如公平锁,非公平锁,读写锁,闭锁,信号量等
不管是自己实现还是使用redisson,都会面临一个问题,那就是master-slave发生主备切换的时候,可能会发生A和B都获取到锁的情况,比如A在master上获取到锁了,这个时候master挂了,slave变为master,B再去获取锁的时候,是可以在新的master上获取到的

zookeeper

和redis实现分布式锁一样,zookeeper我们也可以自己使用zookeeper实现分布式锁,也可以直接使用现有的一些开源组件,比如cuartor,cuartor是zk的客户端实现,curator之于zk就像guava之于java。
由于自己代码实现比较复杂,且cuartor已经做了很好的封装,提供了完善可靠的锁机制,接下来我们将简单分析下cuartor基于ZooKeeper实现分布式锁的原理

  1. 创建一个目录mylock;
  2. A想获取锁就在mylock目录下创建一个临时顺序节点
  3. 获取mylock目录下所有的子节点,然后获取比自己小的兄弟节点,如果不存在,则说明当前线程顺序号最小,获得锁;
  4. 这时候B想获取锁,他会做同样的事情,在mylock目录下建一个零时顺序节点,并判断自己不是最小节点,如果是,获得锁,如果不是,获取锁失败,然后设置监听比自己次小的节点
  5. A处理完,删除自己的节点,释放锁,B监听到变更事件,判断自己是不是最小的节点,如果是则获得锁。

针对以上加锁流程,我们可能会产如下疑问

  1. 为什么是临时节点
    临时节点是为了在客户端出现宕机,且没有主动释放锁的时候,zk能够感知到客户端的异常,并且删除对应的节点,也就是释放锁
  2. 为什么是顺序节点
    节点的顺序是为了锁的阻塞特性,公平特性

优点:具备高可用、可重入、阻塞锁特性,可解决失效死锁问题。
缺点:因为需要频繁的创建和删除节点,性能上不如Redis方式。

数据库

基于数据库的实现方式比较简单粗暴,主要使用数据库的唯一索引来实现,即A和B同时插入同一条数据,由于唯一索引的限制,只能有一条插入成功,插入成功即拿到锁

  1. 锁开销比较大,需要操作数据库
  2. 阻塞策略需要自己实现,在B插入失败之后,如需阻塞,需要实现重试等逻辑
  3. 强依赖数据库,数据库的可用性和性能将直接影响分布式锁的可用性及性能