分布式锁

为什么需要分布式锁?

  • 在多线程环境中,如果多个线程同时访问共享资源(例如商品库存、外卖订单),会发生数据竞争,可能会导致出现脏数据或者系统问题,威胁到程序的正常运行。
  • 为了实现共享资源的互斥访问,锁是一个比较通用的解决方案,尤其是悲观锁。
  • 共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。

本地锁

  • 本地锁的示意图如下:
    image-20240811173144815

在分布式系统下,不同的服务/客户端通常运行在独立的 JVM 进程上。如果多个 JVM 进程共享同一份资源,使用本地锁就无法实现资源的互斥访问,于是分布式锁就诞生了。

分布式锁

image-20240811173152055

分布式锁应该具备哪些条件?

  • 互斥:任意一个时刻,锁只能被一个线程持有。
  • 高可用:锁服务是高可用的,当一个锁服务出现问题,能够自动切换到另外一个锁服务。即使客户端的释放锁的代码逻辑出现问题,锁最终一定还是会被释放,不会影响其他线程对共享资源的访问。这一般是通过超时机制实现的。
  • 可重入:一个节点获取了锁之后,还可以再次获取锁。

一个好的分布式锁还需要满足以下条件:

  • 高性能:获取和释放锁的操作应该快速完成,并且不应该对整个系统的性能造成过大影响。
  • 非阻塞:如果获取不到锁,不能无限期等待,避免对系统正常运行造成影响。

分布式锁的常见实现方式有哪些?

  • 基于关系型数据库比如 MySQL 实现分布式锁。
    • 关系型数据库的方式一般是通过唯一索引或者排他锁实现。不过,这种方式一般不会使用,因为存在性能差、不具备锁失效机制等问题。
  • 基于分布式协调服务 ZooKeeper 实现分布式锁。
  • 基于分布式键值存储系统比如 Redis、Etcd 实现分布式锁。

如何基于 Redis 实现一个最简易的分布式锁?

  • 不论是本地锁还是分布式锁,核心都在于“互斥”。
  • 在 Redis 中,SETNX 命令可以帮助我们实现互斥。SETNXSET if Not eXists (对应 Java 中的 setIfAbsent 方法),如果 key 不存在的话,才会设置 key 的值。如果 key 已经存在,SETNX 不会做任何操作。
  • 释放锁时,直接通过 DEL 命令删除对应的 key 即可。
    • 为了防止误删其他锁,建议使用 Lua 脚本通过 key 对应的 value(唯一值)来判断。
    • 选用 Lua 脚本是为了保证解锁操作的原子性。因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,从而保证了锁释放操作的原子性。

这种方式虽然简单高效,但也存在一些问题。例如,如果应用程序遇到一些问题如释放锁的逻辑突然挂掉,可能会导致锁无法被释放,进而造成共享资源无法再被其他线程/进程访问。

为什么要给锁设置一个过期时间?

  • 为了避免锁无法被释放,可以给这个 key(也就是锁)设置一个过期时间。
  • SET lockKey uniqueValue EX 3 NX 解释:
    • lockKey:加锁的锁名。
    • uniqueValue:能够唯一标识锁的随机字符串。
    • NX:只有当 lockKey 对应的 key 值不存在的时候才能 SET 成功。
    • EX:过期时间设置(以秒为单位),EX 3 标示这个锁有一个 3 秒的自动过期时间。与 EX 对应的是 PX(以毫秒为单位),这两个都是过期时间设置。

一定要保证设置指定 key 的值和过期时间是一个原子操作!!! 否则,依然可能会出现锁无法被释放的问题。

  • 如果操作共享资源的时间大于过期时间,就会出现锁提前过期的问题,进而导致分布式锁直接失效。如果锁的超时时间设置过长,又会影响到性能。
    • 如果操作共享资源的操作还未完成,锁过期时间能够自己续期就好了!

如何实现锁的优雅续期?

  • Redisson 是一个开源的 Java 语言 Redis 客户端,提供了很多开箱即用的功能,不仅包括多种分布式锁的实现。Redisson 还支持 Redis 单机、Redis Sentinel、Redis Cluster 等多种部署架构。
  • Redisson 中的分布式锁自带自动续期机制,使用起来非常简单,原理也很简单,其提供了一个专门用来监控和续期锁的 Watch Dog(看门狗)。如果操作共享资源的线程还未执行完成,Watch Dog 会不断地延长锁的过期时间,进而保证锁不会因为超时而被释放。
  • 其实是通过调用 Lua 脚本实现的续期,这样做主要是为了保证续期操作的原子性。
  • 只有未指定锁超时时间,才会使用到 Watch Dog 自动续期机制。
  • 如果使用 Redis 来实现分布式锁,推荐使用 Redisson。

如何实现可重入锁?

  • 不可重入的分布式锁基本可以满足绝大部分业务场景,但一些特殊场景可能需要使用可重入的分布式锁。
  • 可重入分布式锁的实现核心思路是线程在获取锁时判断是否为自己的锁,如果是的话,就不用再重新获取了。为此,可以为每个锁关联一个可重入计数器和一个占有它的线程。当可重入计数器大于 0 时,判断占有该锁的线程和请求获取锁的线程是否为同一个。
  • 推荐使用我们前面提到的 Redisson,其内置了多种类型的锁比如可重入锁(Reentrant Lock)、自旋锁(Spin Lock)、公平锁(Fair Lock)、多重锁(MultiLock)、 红锁(RedLock)、 读写锁(ReadWriteLock)。

Redis 如何解决集群情况下分布式锁的可靠性?

  • Redlock 算法解决了这个问题。
  • Redlock 算法的思想是让客户端向 Redis 集群中的多个独立的 Redis 实例依次请求申请加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么就认为客户端成功地获得分布式锁,否则加锁失败。
  • 即使部分 Redis 节点出现问题,只要保证 Redis 集群中有半数以上的 Redis 节点可用,分布式锁服务就是正常的。Redlock 直接操作 Redis 节点,而不是通过 Redis 集群操作,这样可以避免 Redis 集群主从切换导致的锁丢失问题。

基于 ZooKeeper 实现分布式锁

  • Redis 实现分布式锁性能较高,ZooKeeper 实现分布式锁可靠性更高。
  • ZooKeeper 分布式锁是基于 临时顺序节点 和 Watcher(事件监听器) 实现的。

获取锁:

  • 首先,我们要有一个持久节点 /locks,客户端获取锁时会在 /locks 下创建临时顺序节点。
  • 假设客户端 1 创建了 /locks/lock1 节点,创建成功后,会判断 lock1 是否是 /locks 下最小的子节点。
  • 如果 lock1 是最小的子节点,则获取锁成功。否则,获取锁失败。
  • 如果获取锁失败,则说明有其他客户端已经成功获取锁。客户端 1 不会不停地循环尝试加锁,而是在前一个节点比如 /locks/lock0 上注册一个事件监听器。监听器的作用是当前一个节点释放锁之后通知客户端 1(避免无效自旋),这样客户端 1 就加锁成功了。

释放锁:

  • 成功获取锁的客户端在执行完业务流程后,会将对应的子节点删除。

  • 成功获取锁的客户端在出现故障后,对应的子节点由于是临时顺序节点,也会被自动删除,避免了锁无法被释放的问题。

  • 事件监听器监听的就是子节点删除事件,子节点删除意味着锁被释放。

    image-20240811173206709

  • 推荐使用 Curator 实现 ZooKeeper 分布式锁。Curator 是 Netflix 公司开源的一套 ZooKeeper Java 客户端框架,相比于 ZooKeeper 自带的客户端 zookeeper,Curator 的封装更加完善,各种 API 都可以比较方便地使用。

    • Curator 主要实现了以下四种锁:
      • InterProcessMutex:分布式可重入排它锁。
      • InterProcessSemaphoreMutex:分布式不可重入排它锁。
      • InterProcessReadWriteLock:分布式读写锁。
      • InterProcessMultiLock:将多个锁作为单个实体管理的容器,获取锁时获取所有锁,释放锁也会释放所有锁资源(忽略释放失败的锁)。

为什么要用临时顺序节点?

  • 每个数据节点在 ZooKeeper 中被称为 znode,它是 ZooKeeper 中数据的最小单元。
  • 我们通常将 znode 分为 4 大类:
    • 持久(PERSISTENT)节点:一旦创建就一直存在即使 ZooKeeper 集群宕机,直到将其删除。
    • 临时(EPHEMERAL)节点:临时节点的生命周期是与 客户端会话(session) 绑定的,会话消失则节点消失 。并且,临时节点只能做叶子节点,不能创建子节点。
    • 持久顺序(PERSISTENT_SEQUENTIAL)节点:除了具有持久(PERSISTENT)节点的特性外,子节点的名称还具有顺序性。比如 /node1/app0000000001/node1/app0000000002
    • 临时顺序(EPHEMERAL_SEQUENTIAL)节点:除了具备临时(EPHEMERAL)节点的特性外,子节点的名称还具有顺序性。

临时节点相比持久节点,最主要的是对会话失效的情况处理不一样,临时节点会话消失则对应的节点消失。如果客户端发生异常导致没来得及释放锁,临时节点自动被删除,不会发生死锁问题。

假设不使用顺序节点,所有尝试获取锁的客户端都会对持有锁的子节点加监听器。当该锁被释放后,势必会造成所有尝试获取锁的客户端争夺锁,这样对性能不友好。使用顺序节点后,只需监听前一个节点即可,对性能更友好。

为什么要设置对前一个节点的监听?

  • 获取锁失败的客户端不会不停地循环尝试加锁,而是在前一个节点注册一个事件监听器。
  • 当前一个节点对应的客户端释放锁后(前一个节点被删除后,监听的是删除事件),通知获取锁失败的客户端(唤醒等待的线程,Java 中的 wait/notifyAll),让它尝试去获取锁,然后就能成功获取锁了。

如何实现可重入锁?

  • 第一次获取锁时,lockDatanull。获取锁成功后,会将当前线程和对应的 lockData 放到 threadData 中。
  • 如果已经获取过一次锁,再次获取锁时,直接在 if (lockData != null) 这里被拦下,然后执行 lockData.lockCount.incrementAndGet(); 将加锁次数加 1。整个可重入锁的实现逻辑非常简单,直接在客户端判断当前线程是否已经获取锁,如果是,直接将加锁次数加 1 即可。