介绍几种常见的分布式锁写法

什么是分布式锁?

大家好,我是jack xu,今天跟大家聊一聊分布式锁。首先说下什么是分布式锁,当我们在进行下订单减库存,抢票,选课,抢红包这些业务场景时,如果在此处没有锁的控制,会导致很严重的问题。学过多线程的小伙们知道,为了防止多个线程同时执行同一段代码,我们可以用 synchronized 关键字或 JUC 里面的 ReentrantLock 类来控制,但是目前几乎任何一个系统都是部署多台机器的,单机部署的应用很少,synchronized 和 ReentrantLock 发挥不出任何作用,此时就需要一把全局的锁,来代替 JAVA 中的 synchronized 和 ReentrantLock。

分布式锁的实现方式流行的主要有三种,分别是基于缓存 Redis 的实现方式,基于 zk 临时顺序节点的实现以及基于数据库行锁的实现。我们先来说下用 Jedis 中的 setnx 命令来构建这把锁。

Jedis写法

使用 Redis 做分布式锁的思路是,在 redis 中设置一个值表示加了锁,然后释放锁的时候就把这个 key 删除。思路是很简单,但是在使用过程中要避免一些坑,我们先看下加锁的代码:

    /**
     * 尝试获取分布式锁
     *
     * @param jedis      Redis客户端
     * @param lockKey    锁
     * @param requestId  请求标识
     * @param expireTime 超期时间
     * @return 是否获取成功
     */
    public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
        // set支持多个参数 NX(not exist) XX(exist) EX(seconds) PX(million seconds)
        String result = jedis.set(lockKey, requestId, "NX", "EX", expireTime);
        if (LOCK_SUCCESS.equals(result)) {
            return true;
        }
        return false;
    }

这段代码很简单,主要说下这里用的命令是 SET key value [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL],而没有使用 SETNX+EXPIRE 的命令,原因是 SETNX+EXPIRE 是两条命令无法保证原子性,而 SET 是原子操作。那这里为什么要设置超时时间呢?原因是当一个客户端获得了锁在执行任务的过程中挂掉了,来不及显式地释放锁,这块资源将会永远被锁住,这将会导致死锁,所以必须设置一个超时时间

释放锁的代码如下:

    /**
     * 释放分布式锁
     *
     * @param jedis     Redis客户端
     * @param lockKey   锁
     * @param requestId 请求标识,当前工作线程线程的名称
     * @return 是否释放成功
     */
    public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
        if (RELEASE_SUCCESS.equals(result)) {
            return true;
        }
        return false;
    }

这里也有两点注意的地方,第一是解铃还须系铃人,怎么理解呢,就是 A 加的锁 B 不能去 del 掉吧,不然岂不是全乱套了,谁加的锁就谁去解,我们一般把 value 设为当前线程的 Id,Thread.currentThread().getId(),然后在删的时候判断下是不是当前线程。第二点是验证和释放锁是两个独立操作,不是原子性,这个怎么解决呢?使用 Lua 脚本,即 if redis.call(‘get’, KEYS[1]) == ARGV[1] then returnredis.call(‘del’, KEYS[1]) else return 0 end,它能给我们保证原子性。

Redisson写法

Redisson 是 Java 的 Redis 客户端之一,提供了一些 API 方便操作 Redis。但是 Redisson 这个客户端可有点厉害,我们先打开官网看下 https://github.com/redisson/redisson/wiki/目录
介绍几种常见的分布式锁写法_第1张图片
这个目录里面有很多的功能,Redisson 跟 Jedis 定位不同,它不是一个单纯的 Redis 客户端,而是基于 Redis 实现的分布式的服务,我们可以看到还有 JUC 包下面的类名,Redisson 帮我们搞了分布式的版本,比如 AtomicLong,直接用 RedissonAtomicLong 就行了。锁只是它的冰山一角,并且它对主从,哨兵,集群等模式都支持,当然了,单节点模式肯定是支持的。

在 Redisson 里面提供了更加简单的分布式锁的实现,我们来看下它的用法,相当的简单,两行代码搞定,比 Jedis 要简单的多,而且在 Jedis 里需要考虑的问题,它都已经帮我们封装好了。

介绍几种常见的分布式锁写法_第2张图片
我们来看下,这里获取锁有很多种的方式,有公平锁有读写锁,我们使用的是 redissonClient.getLock, 这是一个可重入锁。
介绍几种常见的分布式锁写法_第3张图片
现在我把程序启动一下

介绍几种常见的分布式锁写法_第4张图片
打开 Redis Desktop Manager 工具,看下到底它存的是什么。原来在加锁的时候,写入了一个 HASH 类型的值,key 是锁名称 jackxu,field 是线程的名称,而 value 是 1(即表示锁的重入次数)。

介绍几种常见的分布式锁写法_第5张图片
小伙伴可能觉得我在一派胡言,没关系,我们点进去看下它的源码是具体实现的。

介绍几种常见的分布式锁写法_第6张图片
点进 tryLock() 方法的 tryAcquire() 方法,再到->tryAcquireAsync() 再到->tryLockInnerAsync(),终于见到庐山真面目了,原来它最终也是通过 Lua 脚本来实现的。

介绍几种常见的分布式锁写法_第7张图片
现在我把这段Lua脚本拉出来分析一下,很简单。

// KEYS[1] 锁名称 updateAccount
// ARGV[1] key 过期时间 10000ms
// ARGV[2] 线程名称
// 锁名称不存在
if (redis.call('exists', KEYS[1]) == 0) then
// 创建一个 hash,key=锁名称,field=线程名,value=1
redis.call('hset', KEYS[1], ARGV[2], 1);
// 设置 hash 的过期时间
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
// 锁名称存在,判断是否当前线程持有的锁
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
// 如果是,value+1,代表重入次数+1
redis.call('hincrby', KEYS[1], ARGV[2], 1);
// 重新获得锁,需要重新设置 Key 的过期时间
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
// 锁存在,但是不是当前线程持有,返回过期时间(毫秒)
return redis.call('pttl', KEYS[1]);

unlock() 中的 unlockInnerAsync() 释放锁,同样也是通过 Lua 脚本实现。

// KEYS[1] 锁的名称 updateAccount
// KEYS[2] 频道名称 redisson_lock__channel:{updateAccount}
// ARGV[1] 释放锁的消息 0
// ARGV[2] 锁释放时间 10000
// ARGV[3] 线程名称
// 锁不存在(过期或者已经释放了)
if (redis.call('exists', KEYS[1]) == 0) then
// 发布锁已经释放的消息
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end;
// 锁存在,但是不是当前线程加的锁
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
return nil;
end;

// 锁存在,是当前线程加的锁
// 重入次数-1
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
// -1 后大于 0,说明这个线程持有这把锁还有其他的任务需要执行
if (counter > 0) then
// 重新设置锁的过期时间
redis.call('pexpire', KEYS[1], ARGV[2]);
return 0;
else
// -1 之后等于 0,现在可以删除锁了
redis.call('del', KEYS[1]);
// 删除之后发布释放锁的消息
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end;

// 其他情况返回 nil
return nil;

看完它的使用后,我们发现真的使用起来像 JDK 中的 ReentrantLock 一样丝滑。

介绍几种常见的分布式锁写法_第8张图片

RedLock

RedLock 的中文是直译过来的,就叫红锁。红锁并非是一个工具,而是 Redis 官方提出的一种分布式锁的算法。我们知道如果采用单机部署模式,会存在单点问题,只要 redis 故障了,加锁就不行了。如果采用 master-slave 模式,加锁的时候只对一个节点加锁,即便通过 sentinel 做了高可用,但是如果 master 节点故障了,发生主从切换,此时就会有可能出现锁丢失的问题。基于以上的考虑,其实 redis 的作者 Antirez 也考虑到这个问题,他提出了一个 RedLock 的算法。

我在这里画了一个图,图中这五个实例都是独自部署的,没有主从关系,它们就是5个 master 节点。

介绍几种常见的分布式锁写法_第9张图片

通过以下步骤获取一把锁:

  • 获取当前时间戳,单位是毫秒
  • 轮流尝试在每个 master 节点上创建锁,过期时间设置较短,一般就几十毫秒
  • 尝试在大多数节点上建立一个锁,比如5个节点就要求是3个节点(n / 2 +1)
  • 客户端计算建立好锁的时间,如果建立锁的时间小于超时时间,就算建立成功了
  • 要是锁建立失败了,那么就依次删除这个锁
  • 只要别人建立了一把分布式锁,你就得不断轮询去尝试获取锁

但是这样的这种算法还是颇具争议的,可能还会存在不少的问题,无法保证加锁的过程一定正确。Martin Kleppmann 针对这个算法提出了质疑,接着 antirez 又回复了 Martin Kleppmann 的质疑。一个是很有资历的分布式架构师,一个是 Redis 之父,这个就是著名的关于红锁的神仙打架事件。

最后 Redisson 官网上也给出了如何使用红锁 redlock,几行代码搞定,依然很丝滑,感兴趣的小伙伴可以看下。

介绍几种常见的分布式锁写法_第10张图片

Zookeeper写法

在介绍 zookeeper 实现分布式锁的机制之前,先粗略介绍一下 zk 是什么东西:
zk 是一种提供配置管理、分布式协同以及命名的中心化服务。它的模型是这样的:包含一系列的节点,叫做znode,就好像文件系统一样每个 znode 表示一个目录,然后 znode 有一些特性,我们可以把它们分为四类:

  • 持久化节点(zk断开节点还在)
  • 持久化顺序节点(如果是第一个创建的子节点,那么生成的子节点为/lock/node-0000000000,下一个节点则为/lock/node-0000000001,依次类推)
  • 临时节点(客户端断开后节点就删除了)
  • 临时顺序节点

zookeeper分布式锁恰恰应用了临时顺序节点,下面我们就用图解的方式来看下是怎么实现的。

获取锁

首先,在 Zookeeper 当中创建一个持久节点 ParentLock。当第一个客户端想要获得锁时,需要在ParentLock这个节点下面创建一个临时顺序节点 Lock1。
介绍几种常见的分布式锁写法_第11张图片
之后,Client1 查找 ParentLock 下面所有的临时顺序节点并排序,判断自己所创建的节点 Lock1 是不是顺序最靠前的一个。如果是第一个节点,则成功获得锁。
介绍几种常见的分布式锁写法_第12张图片
这时候,如果再有一个客户端 Client2 前来获取锁,则在 ParentLock 下再创建一个临时顺序节点Lock2。
介绍几种常见的分布式锁写法_第13张图片
Client2 查找 ParentLock 下面所有的临时顺序节点并排序,判断自己所创建的节点 Lock2 是不是顺序最靠前的一个,结果发现节点 Lock2 并不是最小的。

于是,Client2 向排序仅比它靠前的节点 Lock1 注册 Watcher,用于监听 Lock1 节点是否存在。这意味着 Client2 抢锁失败,进入了等待状态。
介绍几种常见的分布式锁写法_第14张图片
这时候,如果又有一个客户端 Client3 前来获取锁,则在ParentLock下载再创建一个临时顺序节点Lock3。
介绍几种常见的分布式锁写法_第15张图片
Client3 查找 ParentLock 下面所有的临时顺序节点并排序,判断自己所创建的节点 Lock3 是不是顺序最靠前的一个,结果同样发现节点 Lock3 并不是最小的。

于是,Client3 向排序仅比它靠前的节点 Lock2 注册 Watcher,用于监听 Lock2 节点是否存在。这意味着 Client3 同样抢锁失败,进入了等待状态。
介绍几种常见的分布式锁写法_第16张图片
这样一来,Client1 得到了锁,Client2 监听了 Lock1,Client3 监听了 Lock2。这恰恰形成了一个等待队列,很像是 Java 当中 ReentrantLock 所依赖的 AQS(AbstractQueuedSynchronizer)。

释放锁

释放锁分为两种情况:

1.任务完成,客户端显示释放

当任务完成时,Client1 会显示调用删除节点 Lock1 的指令。
介绍几种常见的分布式锁写法_第17张图片
2.任务执行过程中,客户端崩溃

获得锁的 Client1 在任务执行过程中,如果 Duang 的一声崩溃,则会断开与 Zookeeper 服务端的链接。根据临时节点的特性,相关联的节点 Lock1 会随之自动删除。
介绍几种常见的分布式锁写法_第18张图片

由于 Client2 一直监听着 Lock1 的存在状态,当 Lock1 节点被删除,Client2 会立刻收到通知。这时候 Client2 会再次查询 ParentLock 下面的所有节点,确认自己创建的节点 Lock2 是不是目前最小的节点。如果是最小,则 Client2 顺理成章获得了锁。
介绍几种常见的分布式锁写法_第19张图片
同理,如果 Client2 也因为任务完成或者节点崩溃而删除了节点 Lock2,那么 Client3 就会接到通知。
介绍几种常见的分布式锁写法_第20张图片
最终,Client3 成功得到了锁。
介绍几种常见的分布式锁写法_第21张图片

Curator

在 Apache 的开源框架 Apache Curator 中,包含了对 Zookeeper 分布式锁的实现。
https://github.com/apache/curator/

它的使用方式也很简单,如下所示:
介绍几种常见的分布式锁写法_第22张图片
我们看了下依然丝滑,源码我就不分析了,感兴趣的可以看我同事的博客 Curator的ZK分布式锁实现原理 。

总结

zookeeper 天生设计定位就是分布式协调,强一致性,锁很健壮。如果获取不到锁,只需要添加一个监听器就可以了,不用一直轮询,性能消耗较小。缺点: 在高请求高并发下,系统疯狂的加锁释放锁,最后 zk 承受不住这么大的压力可能会存在宕机的风险。

在这里简单的提一下,zk 锁性能比 redis 低的原因:zk 中的角色分为 leader,flower,每次写请求只能请求 leader,leader 会把写请求广播到所有 flower,如果 flower 都成功才会提交给 leader,其实这里相当于一个 2PC 的过程。在加锁的时候是一个写请求,当写请求很多时,zk 会有很大的压力,最后导致服务器响应很慢。

redis 锁实现简单,理解逻辑简单,性能好,可以支撑高并发的获取、释放锁操作。缺点: Redis 容易单点故障,集群部署,并不是强一致性的,锁的不够健壮; key 的过期时间设置多少不明确,只能根据实际情况调整;需要自己不断去尝试获取锁,比较消耗性能。

最后不管 redis 还是 zookeeper,它们都应满足分布式锁的特性:

  • 具备可重入特性(已经获得锁的线程在执行的过程中不需要再次获得锁)
  • 异常或者超时自动删除,避免死锁
  • 互斥(在分布式环境下同一时刻只能被单个线程获取)
  • 分布式环境下高性能、高可用、容错机制

各有千秋,具体业务场景具体使用。最后原创不易,如果你觉得写的不错,请点一个赞!

你可能感兴趣的:(Zookeeper,Redis)