锁与分布式锁

在多线程环境中,如果多个线程同时访问共享资源 (例如商品库存、外卖订单),会发生数据竞争,可能会导致出现脏数据或者系统问题,威胁到程序的正常运行。

举个例子,假设现在有 100 个用户参与某个限时秒杀活动,每位用户限购 1 件商品,且商品的数量只有 3 个。如果不对共享资源进行互斥访问,就可能出现以下情况:

  • 线程 1、2、3 等多个线程同时进入抢购方法,每一个线程对应一个用户。
  • 线程 1 查询用户已经抢购的数量,发现当前用户尚未抢购且商品库存还有 1 个,因此认为可以继续执行抢购流程。
  • 线程 2 也执行查询用户已经抢购的数量,发现当前用户尚未抢购且商品库存还有 1 个,因此认为可以继续执行抢购流程。
  • 线程 1 继续执行,将库存数量减少 1 个,然后返回成功。
  • 线程 2 继续执行,将库存数量减少 1 个,然后返回成功。
  • 此时就发生了超卖问题,导致商品被多卖了一份。

锁的出现

为了保证共享资源被安全地访问,我们需要使用互斥操作对共享资源进行保护,即同一时刻只允许一个线程访问共享资源,其他线程需要等待当前线程释放后才能访问。这样可以避免数据竞争和脏数据问题,保证程序的正确性和稳定性。

在计算机领域,锁可以理解为针对某项资源使用权限的管理,它通常用来 控制共享资源,比如一个进程内有多个线程竞争一个数据的使用权限,解决方式之一就是加锁,更确切的说则是 “悲观锁”。

悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题 (比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会被阻塞,直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程

对于单体应用来说,在 Java 中,我们通常会使用 ReentrantLock 类、synchronized 关键字这类 JDK 本身就拥有的方式来控制一个 JVM 进程内的多个线程对共享资源的访问。
image-20250822182442644
从图中可以看出,这些线程访问共享资源是互斥的,同一时刻只有一个线程可以获取到本地锁访问共享资源

分布式锁的出现

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

举个例子:系统的订单服务一共部署了 3 份,都对外提供服务。用户下订单之前需要检查库存,为了防止超卖,这里需要加锁以实现对检查库存操作的同步访问。由于订单服务位于 不同的 JVM 进程 中,本地锁在这种情况下就没办法正常工作了。我们需要用到分布式锁,这样的话,即使多个线程不在同一个 JVM 进程中也能获取到同一把锁,进而实现共享资源的互斥访问。
image-20250822183716824
从图中可以看出,这些独立的进程中的线程访问共享资源是互斥的,同一时刻只有一个线程可以获取到分布式锁访问共享资源

分布式锁设计注意事项

设计分布式锁需要具备哪些条件?

1、互斥:同一时刻保证只有一个线程能够获得锁,去访问共享资源。

2、高可用:要保证即使出现异常的情况也要对锁进行释放,确保不会影响其他线程对共享资源的访问。

3、可重入:一个节点获取了锁之后,还可以再次获取到锁,并且不止一次。

4、高性能:获取和释放锁的操作应该快速完成,并且尽量不要对整个系统的性能造成太大的影响。

5、对称性:确保加锁和解锁的是同一个线程,避免出现锁的 “误释放”。

分布式锁有哪些实现方案?

主流的分布式锁实现方案主要有 3 种:

  • 基于关系型数据库 MySQL 实现分布式锁
  • 基于分布式存储系统 Redis 或 Etcd 实现分布式锁
  • 基于分布式协调服务 ZooKeeper 实现分布式锁

鉴于种种原因,实际在生产过程中使用 Redis 或 ZooKeeper 实现分布式锁居多。

分布式锁实现

基于 Redis 循序渐进实现分布式锁

最简单的分布式锁

要实现一个分布式锁,得满足锁的基本特性:即一个线程拿到锁,另一个线程就拿不到,就不能往下走,这就是锁的互斥性。

在 Redis 当中,提供了一个 SETNX 的命令来帮助我们实现一个简单的分布式锁。

1
SETNX key value

Redis 官方对于该命令的解释是:Set the string value of a key only when the key doesn't exist 只有当 key 不存在的时候,才会设置 key 的值 (加锁)。这样就可以帮助我们天然的实现分布式场景下的互斥性。

但是为了保证对称性,在解锁的时候我们则需要先判断这个锁是否是当前线程加的锁,从而避免释放掉别人的锁。此时就包括两个步骤:判断、释放锁。但是为了避免多线程环境下的并发安全问题,我们则需要使用 Lua 脚本来保证这两条命令的原子性。

释放锁的 Lua 脚本示例:

1
2
3
4
5
6
// 释放锁时, 先比较锁对应的 value 值是否相等, 避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end

至于为什么不把 SETNX 命令放到 Lua 脚本当中,这是因为 Redis 执行操作命令是单线程执行的,天然就是原子性操作。

这是实现分布式锁的最简易版本,会存在大量的问题。比如:如果一个线程拿到锁之后就挂了,此时就无法释放锁了,该怎么办?

带有过期时间的分布式锁

为了解决上述出现的问题,我们就需要给这个锁设置一个过期时间。Redis 当中也存在了设置过期时间对应的 SET 命令。

1
SET key value [NX|XX] [GET] [EX seconds|PX milliseconds|EXAT unix-time-seconds|PXAT unix-time-milliseconds|KEEPTTL]

具体解释每一个命令如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
1. SET key value: 设置 key 与 value
2. []: 代表可选操作
3. NX|XX: 二选一属性
- NX(Not Exists): 只有当 key 不存在的时候才会设置 key 的值, 相当于上面的 `SETNX` 命令
- XX(Exists): 只有当 key 存在的时候才会设置 key 的值, 不存在则忽略该操作
4. GET: 执行 `SET` 操作的同时返回原来的旧值, 如果 key 不存在, 返回 nil
- 示例:
1. 执行: SET name "Alice"
2. 返回: OK
3. 执行: SET name "Bob" GET
4. 返回: "Alice"
5. 过期时间参数, 只能选一个
- EX seconds: 设置过期时间为 xx 秒(整数)
- PX milliseconds: 设置过期时间为 xx 毫秒(整数)
- EXAT unix-time-seconds: 设置过期的 Unix 时间戳(秒级), 到指定时间后过期
- PXAT unix-time-milliseconds: 设置过期的 Unix 时间戳(毫秒级), 到指定时间后过期
- KEEPTTL: 保留 key 原有的过期时间(如果 key 已存在且有过期时间, 则不改变其过期时间; 如果 key 不存在, 则设置为永久)

针对于 SET 命令实现可过期的分布式锁,具体的命令示例:

1
2
# 参数的顺序不会影响命令
SET lockKey uniqueValue EX 3 NX 或 SET lockKey uniqueValue NX EX 3 # 3s 后过期

这种方式实现的分布式锁还是会存在问题:假设线程拿到锁后任务还没有执行完成,锁就已经过期了,这个时候该怎么办?

锁的优雅续期

此时我们应该想:如果能够自动判断锁的过期时间,在业务还没执行完的时候进行锁的自动续期,这该多是一件美事!!!

针对于这种情况,Java 已经有了现成的解决方案:Redisson。Redisson 是一个基于 Redis 的 Java 高级客户端,底层基于 Netty 实现。Redisson 提供了如分布式锁、分布式限流、分布式集合等高级特性。

Redisson 中的分布式锁自带自动续期机制,使用起来非常简单。其提供了一个专门用来监控和续期锁的 Watch Dog( 看门狗),如果操作共享资源的线程还未执行完成的话,Watch Dog 会不断地延长锁的过期时间,进而保证锁不会因为超时而被释放。
image-20250822195810515
关于 Watch Dog 看门狗机制,这里不做过多阐述,后续会单独写一篇文章进行详细说明。

通过续期方式实现的分布式锁还是会存在一定的问题:假设线程拿到锁在执行业务方法的时候挂了,这时候 Watch Dog 就会不断地给锁进行续期,该怎么解决?有些场景还需要使用到可重入的分布式锁,又该怎么解决?

可重入的分布式锁

针对于上述第一个问题,我们可以这样操作:

  • 把看门狗线程设置为守护线程,守护线程的生命周期依赖于其他线程,一旦拿到锁的线程在执行业务的时候挂了,那么看门狗作为守护线程也是会停止的。
  • 我们也需要给看门狗设置一个时间 (默认30s 续期一次),避免无限续期。

下面来讨论可重入分布式锁的实现。

可重入锁指的是 在一个线程中可以多次获取同一把锁。比如一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程就需要多次来获取同一个锁,这就是可重入锁。

Java 中的 synchronizedReentrantLock 都属于可重入锁。

那我们该怎么实现一个可重入锁呢?在解答这个问题之前,需要先看看 synchronizedReentrantLock 是怎么实现可重入的。

synchronized 给每个对象都关联了一个锁监视器,监视器中有个字段是锁计数器。锁重入一次,计数器加一次,锁释放一次,计数器减一次,当计数器为 0 的时候,就是锁释放完毕的时候。

ReentrantLock 基于 AQS 进行实现。AQS 中存在一个 state 字段,state 字段就充当了锁计数器,锁重入一次,state 加一次,锁释放一次,state 减一次,当 state 为 0 的时候,就是锁释放完毕的时候。

那么对于可重入的分布式锁,实际项目中,我们不需要自己手动实现,推荐使用我们上面提到的 Redisson ,其内置了多种类型的锁。比如可重入锁 (Reentrant Lock)、自旋锁 (Spin Lock)、公平锁 (Fair Lock)、多重锁 (MultiLock)、 红锁 (RedLock)、 读写锁 (ReadWriteLock)。

而如果让我们自行实现可重入的分布式锁,则也需要根据锁计数器来实现,主要的实现方案有以下两种。

第一种方案

使用 Redis 的哈希结构,以要锁的东西为 key,以当前的线程 Id 为 field,以重入次数为 value,这个 value 充当的角色就是锁计数器。

当然,这种方案也是 Redisson 可重入锁的实现方案,使用的是 HSETNX 命令。

但是由于集群环境下可能会出现线程 Id 重复的现象,我们最好是使用当前的线程 Id 再拼接一个 UUID 来保证唯一性。

第二种方案

不使用 Redisson 提供好的可重入分布式锁,还是使用 Redis 中的 String 结构的 SET NX EX 命令来实现分布式锁,但是在服务内部维护一个 ConcurrentHashMap ,以 map 的 value 作为锁计数器,来实现锁的可重入性。

Redisson 中的发布订阅

如果一个线程抢到锁去执行业务去了,那么其他没抢到锁的线程就直接返回失败吗?

当然不是。没抢到锁的线程不会直接返回失败信息,可以进行重试机制。分布式锁也可以通过不断自旋来尝试重新抢锁,但是 Redisson 底层不是这么实现的。

Redisson 基于 Redis 的发布订阅机制,让没有抢到锁的线程进行订阅,然后阻塞等待。

抢到锁的线程执行完任务后会发布一条消息来通知订阅的所有线程,唤醒它们进行重新抢锁。

没抢到锁的线程继续订阅,抢到锁的线程继续执行业务,执行完业务后继续发布消息,如此循环往复。不过需要注意的是要添加一个超时时间来进行控制,避免抢不到锁的线程无限等待下去。

Redis 集群环境下的锁丢失问题

如果 Redis 是一主多从的集群模式,当执行分布式锁的 Redis 命令写入到主节点后,主节点正在或是即将同步数据给从节点的某一刻,Redis 的主节点突然宕机了,那么就会触发 Redis 的重新选主操作,选择某一个从节点转化为主节点。

但是新的主节点是没有分布式锁的数据的,那么其他线程还是能够加锁成功的,由于开始的主节点宕机没有及时同步数据到从节点,这就造成了第一次加的锁造成了丢失,这又该如何应对?

Redisson 的联锁 (MultiLock)

针对于以上锁丢失问题,Redisson 提供了 联锁 机制来解决。联锁要求 Redis 部署多主多从的集群环境。

每次加锁都必须给所有主节点都加上锁才算加锁成功。

这样即便某个主节点还没来得及同步数据就宕机,那么其他几个主节点也是有锁的数据的,新线程再想加锁, 由于无法给所有主节点都加锁,还是会加锁失败的。这样就解决了一主多从模式下锁丢失的问题。

但是真的完美了吗?

在给所有主节点加锁的过程中,如果某个主节点网络延迟很大,加锁很慢,或者说某个主节点宕机了,一直加锁失败,由于联锁的机制,那就会导致整体加锁失败。

此时我们又该如何应对?

Redisson 的红锁 (RedLock)

此时就需要请出 Redisson 的又一个锁机制了:RedLock 红锁。RedLock 也要求 Redis 部署多主多从的集群环境,但是加锁的时候不需要全部的主节点都加锁成功,而是只需要 半数以上的主节点加锁成功即可

当某个线程加锁半数以上节点都成功了,那其他线程就不可能再做到半数以上加锁成功,这就满足了互斥性。

同时 RedLock 对加锁时间也有很严格的要求,如果某个节点一定时间内加不上锁就不等了,反正只要半数以上成功就行,放弃一两个比较慢的主节点是无所谓的。

但是但是但是,到这里就万无一失了吗?

显然并不是,RedLock 还存在一些其他的问题:

  • RedLock 对时间要求很严格,但是不同节点的系统时钟也可能不一致,从而导致问题。
  • Java 的 GC 过程中是会暂停线程的,从而导致看门狗线程无法对锁进行续期,也可能导致锁的超时过期。
  • RedLock 自身也存在一定的问题,且需要搭建复杂的多主多从集群模式,维护起来非常困难,还要保证多个节点之间的数据一致性,因此 RedLock 的使用场景并不广泛。

总结

综上所述,实现分布式锁的最佳实践有两种方式:

  • 基于 Redis 的 SET NX EX 实现简单的不可重入的分布式锁。
  • 直接使用 Redisson 提供好的分布式锁机制。