1. 分布式锁介绍

1.1 锁是干嘛的?

使用锁的场景,最先想到的就是 Java 中使用 synchronized、ReentrantLock、原子类如 AtomicInteger 等方式来实现线程安全。而所谓的线程安全,归根结底就是保证共享区或者共享变量的安全。

线程安全有三大特性:原子性、可见性、有序性,那么这三个特性是如何保证线程安全的?

  • 原子性:对共享资源的加锁或修改是原子的,不可拆分的,那么加锁或修改本身就要么成功要么失败,不会有中间过程的误判;
  • 可见性:对共享资源的任何修改,所有线程都可感知,不会在不同线程下决策不同;
  • 有序性:有序性的前提是多线程,单线程下的指令重排不会改变串行结果,但多线程下的指令重排下对共享区域的修改会相互干扰,所以保证多线程的有序性也是必须的;

:::info 加锁是手段,保证共享资源的安全才是目的,单点下 Java 是通过原子性、可见性、有序性来实现的。 :::

1.2 分布式锁需要考虑什么?

既然加锁的目的是保证共享资源的安全,并且在单点的情况下 Java 是通过三大特性来实现共享资源的安全,那么在分布式的场景下,锁要如何保证共享资源的安全呢?

首先考虑一个问题:共享资源是什么,存放在哪里?

  • 在单点的情况下:
    • 共享资源可以是一个实例变量、一段对资源进行操作的代码段、或者数据库的资源;
    • 这些资源的共享范围是在同一个 JVM 进程下的不同线程间共享;
    • 或者说,所有的共享资源都共存于同一个 JVM 进程中,共享资源状态同步的范围也只是在线程的工作内存和主内存之间;
    • 或者说,共享资源的最终状态在主内存,而其变化状态发生在 JVM 进程下的多线程的各自工作内存中;
  • 在分布式系统的情况下:
    • 共享资源可能是某个商品的总库存、某个优惠券批次的总数量;
    • 这些资源的共享范围,就扩大到了多台机器下的多个 JVM 进程中的多个线程间;
    • 因此,分布式下的共享资源的安全保证,不再仅仅是在线程之间,也在 JVM 进程之间;
    • 此时,共享资源的最终状态一定要依赖于 JVM 进程外的第三方,比如数据库、任意形式的服务器等;

:::info 分布式系统下整个服务集群是一个大容器,状态的同步范围在集群服务所有的线程之间,只是这些线程的交互不再只是通过单机的缓存一致性协议(如 MESI 协议等),而是扩大到了端到端的通信即网络交互,而共享资源的直接宿主也在第三方譬如其他服务、数据库等。 :::

其次考虑一个问题:不同情况下的锁能否保证共享资源的原子性、有序性、可见性?

  • 在单点的情况下:
    • 共享资源的最终状态保存在单点 JVM 进程的主内存中,所以锁依靠 JVM 中的一些手段,比如 synchronized、Lock、原子类就可以保证共享资源的三大特性;
  • 在分布式系统的而情况下:
    • 分布式锁的生存范围是和集群节点平级的,必须独立于各个节点之外,比如借助 redis、zookeeper 等实现;
    • 可见性:共享资源的获取本身就是服务与服务间的通信,可见性的粒度也应该在服务,只要共享资源发生改变,任何一个服务都可以查询到;
    • 有序性:在分布式锁的前提下,不同服务之间对于共享资源的变更也变成了时间上是串行的,自然满足有序性;
    • 原子性:如果能保证分布式锁的加锁、释放等操作是原子性的,那么分布式锁所保护的资源(或操作)就是一个原子的;

最后再考虑一个问题:如果分布式锁可以保证共享资源的安全,在分布式系统下还会出现其他问题吗?

  • 由于分布式锁依赖于第三方,比如 redis、zookeeper 等中间件,加锁、释放、锁续期等也是在进程与 redis 之间通信,那么如果 reids 服务不可用了怎么办?或者锁过期而持有锁的任务还没有完成怎么办?

分布式锁常见的几种解决方案 - 图1 :::info 可见,分布式锁不仅要维护共享资源的安全,还要维护锁自身在不同进程下的安全。 :::

上面分析了分布式锁的目的,以及分布式锁要实现共享资源安全需要解决的问题,下面就此讨论基于 redis 的几种分布式锁的解决方案,分析其中的缺点并进行改进,来进一步学习分布式锁的知识点。

2. 基于 Redis 的分布式锁方案

用 Redis 实现分布式锁的几种方案,都是用 SETNX 命令(设置 key 等于某 value)。只是高阶方案传的参数个数不一样,以及考虑了异常情况。

SETNX 是 set If not exist 的简写。意思就是当 key 不存在时,设置 key 的值,存在时,什么都不做。

在 Redis 命令行中的命令:

  1. set <key> <value> NX
  2. OK
  3. #返回OK,表示设置成功,重复执行该命令,返回 nil 表示设置失败

2.1 方案一:SETNX

先用 Redis 的 SETNX 命令实现最简单的分布式锁
分布式锁常见的几种解决方案 - 图2

  • 多个并发线程都去 Redis 中申请锁,也就是执行 setnx 命令,假设线程 A 执行成功,说明当前线程 A 获得了。
  • 其他线程执行 setnx 命令都会是失败的,所以需要等待线程 A 释放锁。
  • 线程 A 执行完自己的业务后,删除锁。
  • 其他线程继续抢占锁,也就是执行 setnx 命令。因为线程 A 已经删除了锁,所以又有其他线程可以抢占到锁了。

代码示例如下,Java 中 setnx 命令对应的代码为 setIfAbsent

  • setIfAbsent 方法的第一个参数代表 key,第二个参数代表值。
  • 这个程序存在递归调用,锁被占用时需要等待休眠一段时间再去抢占锁,不然在拿到锁之前可能就会导致栈空间溢出
    1. public List<TypeEntity> getTypeEntityListByRedisDistributedLock() {
    2. // 1.先抢占锁
    3. Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "123");
    4. if(lock) {
    5. // 2.抢占成功,执行业务
    6. List<TypeEntity> typeEntityListFromDb = getDataFromDB();
    7. // 3.解锁
    8. redisTemplate.delete("lock");
    9. return typeEntityListFromDb;
    10. } else {
    11. // 4.休眠一段时间
    12. sleep(100);
    13. // 5.抢占失败,等待锁释放
    14. return getTypeEntityListByRedisDistributedLock();
    15. }
    16. }

方案一缺点:
如果服务进程执行 setnx 抢占锁成功,但是业务代码出现异常或服务进程挂掉了,没有执行删除锁的逻辑,就会造成死锁

解决办法:
给锁设置自动过期时间,过一段时间后,自动删除锁,这样其他线程就能获取到锁了。

2.2 方案二:SETNX + EXPIRE

先用 setnx 来抢锁,如果抢到之后,再用 expire 给锁设置一个过期时间,防止锁忘记了释放。
分布式锁常见的几种解决方案 - 图3

代码示例:

  • 自动清理 redis key:redisTemplate.expire("lock", 10, TimeUnit.SECONDS);
    1. public List<TypeEntity> getTypeEntityListByRedisDistributedLock() {
    2. // 1.先抢占锁
    3. Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "123");
    4. if(lock) {
    5. // 2.在 10s 以后,自动清理 lock
    6. redisTemplate.expire("lock", 10, TimeUnit.SECONDS);
    7. // 3.抢占成功,执行业务
    8. List<TypeEntity> typeEntityListFromDb = getDataFromDB();
    9. // 4.解锁
    10. redisTemplate.delete("lock");
    11. return typeEntityListFromDb;
    12. } else {
    13. // 4.休眠一段时间
    14. sleep(100);
    15. // 5.抢占失败,等待锁释放
    16. return getTypeEntityListByRedisDistributedLock();
    17. }
    18. }

方案二缺点:
这个方案看似利用设置锁的自动过期时间,解决了因为服务异常或者服务宕机造成无法删除锁而造成的锁未释放的问题,但实际上并非入此。
抢占锁和设置过期时间实际上是分两个步骤执行的,并不是原子操作,如果执行完 setnx 加锁,正要执行 expire 设置过期时间时,服务发生了异常(进程挂了或服务重启了),则锁的过期时间根本就没有设置成功,这个锁仍然无法自动删除,别的线程永远获取不到这个锁。

解决方法:
将两步放在一步中执行:占锁+设置锁过期时间,要么都执行成功,要么都不执行。

2.3 方案三:SET 的扩展命令(SET EX PX NX)

SET key value[EX seconds][PX milliseconds][NX|XX] 这个指令是原子性的,可以实现占锁+设置锁过期时间一步执行:

  • NX:表示 key 不存在的时候,才能 set 成功,也即保证只有第一个客户端请求才能获得锁,而其他客户端请求只能等其释放锁,才能获取。
  • EX seconds:设定 key 的过期时间,时间单位是秒。
  • PX milliseconds:设定 key 的过期时间,单位为毫秒
  • XX:仅当 key 存在时设置值

每运行一次 ttl <key> 命令,就可以看到 key 的过期时间就会减少。最后会变为 -2(已过期)。

  1. 127.0.0.1:6379> set shawn 10000 EX 10 NX
  2. OK
  3. 127.0.0.1:6379> ttl shawn
  4. (integer) 7
  5. 127.0.0.1:6379> ttl shawn
  6. (integer) 3
  7. 127.0.0.1:6379> ttl shawn
  8. (integer) -2

分布式锁常见的几种解决方案 - 图4

代码示例:

  • 设置 lock 的值等于 123,过期时间为 10 秒。如果 10 秒 以后,lock 还存在,则清理 lock。
    1. public List<TypeEntity> getTypeEntityListByRedisDistributedLock() {
    2. // 1.先抢占锁
    3. Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "123", 10, TimeUnit.SECONDS);
    4. if(lock) {
    5. // 2.在 10s 以后,自动清理 lock
    6. redisTemplate.expire("lock", 10, TimeUnit.SECONDS);
    7. // 3.抢占成功,执行业务
    8. List<TypeEntity> typeEntityListFromDb = getDataFromDB();
    9. // 4.解锁
    10. redisTemplate.delete("lock");
    11. return typeEntityListFromDb;
    12. } else {
    13. // 4.休眠一段时间
    14. sleep(100);
    15. // 5.抢占失败,等待锁释放
    16. return getTypeEntityListByRedisDistributedLock();
    17. }
    18. }

方案三的缺点:

  • 问题一:锁过期释放了,业务还没执行完
    • 假设线程 a 获取锁成功,一直在执行临界区的代码。但是 100s 过去后,它还没执行完。但是,这时候锁已经过期了,此时线程 b 又请求过来。显然线程 b 就可以获得锁成功,也开始执行临界区的代码。那么问题就来了,临界区的业务代码都不是严格串行执行的啦。
  • 问题二:锁被别的线程误删
    • 假设线程 a 执行完后,去释放锁。但是它不知道当前的锁可能是线程 b 持有的(线程 a 去释放锁时,有可能过期时间已经到了,此时线程 b 进来占有了锁)。那线程 a 就把线程 b 的锁释放掉了,但是线程 b 临界区业务代码可能都还没执行完呢。

解决办法:
既然锁可能被别的线程误删,那可以给 value 值设置一个标记当前线程唯一的随机数,在删除的时候,校验一下就 OK 了。

2.4 方案四:SET EX PX NX + 校验唯一随机值,再删除

设置锁的过期时间时,还需要设置唯一编号。
主动删除锁的时候,需要判断锁的编号是否和设置的一致,如果一致,则认为是自己设置的锁,可以进行主动删除。
分布式锁常见的几种解决方案 - 图5

代码示例:

  1. public List<TypeEntity> getTypeEntityListByRedisDistributedLock() {
  2. // 1.生成唯一 id
  3. String uuid = UUID.randomUUID().toString();
  4. // 2. 抢占锁
  5. Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 10, TimeUnit.SECONDS);
  6. if(lock) {
  7. System.out.println("抢占成功:" + uuid);
  8. // 3.抢占成功,执行业务
  9. List<TypeEntity> typeEntityListFromDb = getDataFromDB();
  10. // 4.获取当前锁的值
  11. String lockValue = redisTemplate.opsForValue().get("lock");
  12. // 5.如果锁的值和设置的值相等,则清理自己的锁
  13. if(uuid.equals(lockValue)) {
  14. System.out.println("清理锁:" + lockValue);
  15. redisTemplate.delete("lock");
  16. }
  17. return typeEntityListFromDb;
  18. } else {
  19. System.out.println("抢占失败,等待锁释放");
  20. // 4.休眠一段时间
  21. sleep(100);
  22. // 5.抢占失败,等待锁释放
  23. return getTypeEntityListByRedisDistributedLock();
  24. }
  25. }

方案四的缺点:
看似解决了上一个方案的问题,但是判断是不是当前线程加的锁释放锁不是一个原子操作,如果 redisTemplate.delete("lock") 释放锁的时候,可能由于业务执行时间过长导致锁已经过期,这把锁已经不属于当前客户端,会解除他人加的锁。

解决办法:
使用脚本将查询锁和删除锁这两步作为原子指令操作。

2.5 方案五:SET EX PX NX + 用脚本查锁删除锁

使用 lua 脚本代替 JAVA 程序来进行查锁、删除锁,实现两步的原子操作。
分布式锁常见的几种解决方案 - 图6

代码示例:

  • Redis 专属的 lua 脚本

    • 先获取 KEYS[1] 的 value,判断 KEYS[1] 的 value 是否和 ARGV[1] 的值相等,如果相等,则删除 KEYS[1]。
      1. if redis.call("get",KEYS[1]) == ARGV[1]
      2. then
      3. return redis.call("del",KEYS[1])
      4. else
      5. return 0
      6. end
  • 在 Java 项目中执行 lua 脚本

    • redisTemplate.execute 方法执行脚本:KEYS[1] 对应“lock”,ARGV[1] 对应 “uuid”,含义就是如果 lock 的 value 等于 uuid 则删除 lock。

      1. public List<TypeEntity> getTypeEntityListByRedisDistributedLock() {
      2. // 1.生成唯一 id
      3. String uuid = UUID.randomUUID().toString();
      4. // 2. 抢占锁
      5. Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 10, TimeUnit.SECONDS);
      6. if(lock) {
      7. System.out.println("抢占成功:" + uuid);
      8. // 3.抢占成功,执行业务
      9. List<TypeEntity> typeEntityListFromDb = getDataFromDB();
      10. // 4.获取当前锁的值
      11. String lockValue = redisTemplate.opsForValue().get("lock");
      12. // 5.如果锁的值和设置的值相等,则清理自己的锁
      13. if(uuid.equals(lockValue)) {
      14. System.out.println("清理锁:" + lockValue);
      15. redisTemplate.delete("lock");
      16. }
      17. // 脚本解锁
      18. String script = "if redis.call('get',KEYS[1]) == ARGV[1] "
      19. +"then return redis.call('del',KEYS[1]) else return 0 end";
      20. redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class),
      21. Arrays.asList("lock"), uuid);
      22. return typeEntityListFromDb;
      23. } else {
      24. System.out.println("抢占失败,等待锁释放");
      25. // 4.休眠一段时间
      26. sleep(100);
      27. // 5.抢占失败,等待锁释放
      28. return getTypeEntityListByRedisDistributedLock();
      29. }
      30. }

以上方案来自《Redis 分布式锁|从青铜到钻石的五种演进方案》,深入浅出的讲解了几种方案的缺陷,然后通过不断的改进,学习到了系统中哪些地方可能存在异常情况,以及该如何更好的进行处理。

2.6 方案六:Redisson 框架

上面的方案五还是可能存在业务还没执行完,锁过期释放的问题,这样其他的线程可能会拿到属于自己的锁修改了共享资源,与前面的业务产生冲突。

解决办法:
给获得锁的线程,开启一个定时守护线程,每隔一段时间检查锁是否还存在,存在则对锁的过期时间延长,防止锁过期提前释放。

开源框架 Redisson 帮我们解决了这个问题,Redisson 底层原理:
分布式锁常见的几种解决方案 - 图7
只要线程一加锁成功,就会启动一个 watch dog 看门狗,它是一个后台线程,会每隔10秒检查一下,如果线程1还持有锁,那么就会不断的延长锁 key 的生存时间。因此,Redisson就是使用 watch dog 解决了锁过期释放,业务没执行完问题。

2.7 方案七:多机实现的分布式锁 Redlock + Redisson

前面六种方案都只是基于单机版的讨论,还不是很完美。其实 Redis 一般都是集群部署的:
分布式锁常见的几种解决方案 - 图8
如果线程一在 Redis 的 master 节点上拿到了锁,但是加锁的 key 还没同步到 slave 节点。恰好这时,master 节点发生故障,一个 slave 节点就会升级为 master 节点。线程二就可以获取同个 key 的锁啦,但线程一也已经拿到锁了,锁的安全性就没了。

为了解决这个问题,Redis 作者 antirez 提出一种高级的分布式锁算法:Redlock。Redlock 核心思想是这样的: :::info 搞多个 Redis master 部署,以保证它们不会同时宕掉。并且这些 master 节点是完全相互独立的,相互之间不存在数据同步。同时,需要确保在这多个 master 实例上,是与在 Redis 单实例,使用相同方法来获取和释放锁。 ::: 分布式锁常见的几种解决方案 - 图9

RedLock 的实现步骤如下:

  1. 获取当前时间,以毫秒为单位。
  2. 按顺序向 5 个 master 节点请求加锁。客户端设置网络连接和响应超时时间,并且超时时间要小于锁的失效时间。(假设锁自动失效时间为10秒,则超时时间一般在5-50毫秒之间,我们就假设超时时间是50ms吧)。如果超时,跳过该 master 节点,尽快去尝试下一个 master 节点。
  3. 客户端使用当前时间减去开始获取锁时间(即步骤1记录的时间),得到获取锁使用的时间。当且仅当超过一半(N/2+1,这里是5/2+1=3个节点)的 Redis master 节点都获得锁,并且使用的时间小于锁失效时间时,锁才算获取成功。(如上图,10s> 30ms+40ms+50ms+4m0s+50ms)
  4. 如果取到了锁,key 的真正有效时间就变啦,需要减去获取锁所使用的时间。
  5. 如果获取锁失败(没有在至少N/2+1个 master 实例取到锁,有或者获取锁时间已经超过了有效时间),客户端要在所有的 master 节点上解锁(即便有些 master 节点根本就没有加锁成功,也需要解锁,以防止有些漏网之鱼)。

简化后的步骤就是: :::info

  • 按顺序向5个 master 节点请求加锁
  • 根据设置的超时时间来判断,是不是要跳过该 master 节点。
  • 如果大于等于3个节点加锁成功,并且使用的时间小于锁的有效期,即可认定加锁成功啦。
  • 如果获取锁失败,解锁! :::

Redisson 实现了 RedLock 版本的锁,可以参考《Redlock:Redis分布式锁最牛逼的实现》

参考: https://mp.weixin.qq.com/s/BlDsXWOcqpudORSiyI05Lg https://mp.weixin.qq.com/s/enKKnPy0t-J5b7vZvuUeAA https://mp.weixin.qq.com/s/l9lcFqfXVI30qJi1r2A5-A