@@ -10,53 +10,80 @@ head:
1010 content : 分布式锁,分布式锁入门,为什么需要分布式锁,锁粒度,安全释放,Fencing Token,秒杀超卖,库存扣减,分布式锁面试题
1111---
1212
13- 网上有很多分布式锁相关的文章,这里写了一个相对简洁易懂的版本。面向面试和日常工作场景,先把最常见的概念和边界讲清楚。
14-
15- 这篇文章我们先介绍一下分布式锁的基本概念。
13+ 这篇文章从同一个服务部署多个实例的场景出发,介绍本地锁的作用范围、分布式锁解决的问题,以及使用时需要注意的边界。
1614
1715## 为什么需要分布式锁?
1816
19- 在多线程环境中,如果多个线程同时访问并修改同一份共享资源(例如商品库存、外卖订单),且没有互斥、原子更新、乐观锁或唯一约束等保护,就可能出现数据不一致、重复处理、超卖等问题,影响程序的正确性和稳定性。
17+ ** 当多个进程需要互斥地操作同一份外部共享资源时,本地锁无法协调这些进程,分布式锁可以提供跨进程的互斥能力。**
18+
19+ 这里的“共享资源”可以是数据库中同一件商品的库存记录、Redis 中同一个缓存条目等。** 不同 JVM 通常不共享 Java 堆内存,但可以通过网络访问同一份外部数据。** 需要协调的是这些进程对外部数据的操作。
20+
21+ ### 本地锁为什么管不住多个服务实例?
2022
21- 举个例子,假设现在有 100 个用户参与某个限时秒杀活动,每位用户限购 1 件商品,且商品的数量只有 3 个。如果不对共享资源进行互斥访问,就可能出现以下情况:
23+ 假设订单服务通过“查询库存 → 判断是否有货 → 扣减库存 → 创建订单”处理购买请求。这里先假设库存查询是普通查询,扣减时也没有 ` count > 0 ` 条件或版本号校验。
2224
23- - 线程 1、2、3 等多个线程同时进入抢购方法,每个线程对应一个用户。
24- - 线程 1 和线程 2 分别代表两个不同用户,它们几乎同时读到库存还剩 1 件,于是都通过库存校验,继续创建订单、扣减库存。
25- - 线程 1 继续执行,将库存数量减少 1 个,然后返回成功。
26- - 线程 2 也继续执行,将库存数量减少 1 个,然后返回成功。
27- - 最终两个请求都成功,但库存只够卖 1 件,于是发生超卖。
25+ 只部署一个实例时,如果所有购买请求都使用同一个 ` synchronized ` 锁对象或 ` ReentrantLock ` 实例,并在锁内完成上述操作及事务提交,这些请求就能串行执行。锁内这段需要互斥执行的代码就是** 临界区** 。本地锁限制的是当前 JVM 中使用同一把锁的线程;锁内操作的数据也可以存放在数据库中。
2826
29- 这里的限购校验和库存扣减是两个不同的约束:限购主要解决同一用户重复购买的问题,库存扣减主要解决多个用户竞争同一份库存的问题 。
27+ 现在把订单服务部署成 A、B 两个实例,各运行在一个 JVM 中,连接同一个数据库。即使代码完全相同, ** A、B 中的本地锁也是两个独立的锁对象 ** ,互相感知不到对方是否持锁。即使用 ` static ` 修饰锁字段,也只是各自 JVM 内共享 。
3028
31- ![ 共享资源未互斥访问导致出现问题 ] ( https://oss.javaguide.cn/github/javaguide/distributed-system/distributed-lock/oversold-without-locking.png )
29+ 当数据库中商品 ` sku_id = 1001 ` 只剩 1 件库存时,就可能出现下面的执行顺序:
3230
33- 锁的思路是把某段临界区串行化:同一时刻只允许一个执行单元进入这段逻辑。它能降低并发冲突,但也会牺牲吞吐;如果能用数据库原子更新、唯一约束、乐观锁、CAS 或消息串行化解决,就不一定要上分布式锁。
31+ | 顺序 | 实例 A(JVM A) | 实例 B(JVM B) |
32+ | ---- | ------------------------------------------ | ------------------------------------------ |
33+ | 1 | 获取 A 内的本地锁,成功 | |
34+ | 2 | | 获取 B 内的本地锁,也成功 |
35+ | 3 | 查询库存为 1,通过校验 | |
36+ | 4 | | 查询库存也为 1,通过校验 |
37+ | 5 | 执行无条件扣减,创建订单并提交,释放本地锁 | |
38+ | 6 | | 执行无条件扣减,创建订单并提交,释放本地锁 |
3439
35- 比如防超卖不一定要用分布式锁:数据库条件更新 ` UPDATE stock SET count = count - 1 WHERE sku_id = ? AND count > 0 ` 可以保证库存不扣成负数;用户限购可以对 ` user_id + activity_id ` 建唯一索引;创建订单可以使用幂等键防重复提交。高并发场景还可以结合 Redis 预扣库存、MQ 异步落库和对账补偿 。
40+ 两个请求都成功了,但实际只有 1 件商品,于是发生超卖。这里本地锁仍然能约束各自 JVM 内的线程,却无法让另一个 JVM 中的请求等待 。
3641
37- 这里讨论的分布式锁,本质上是一种悲观互斥方案:先拿到锁,再进入临界区,拿不到锁就等待、失败或重试。
42+ ### 分布式锁如何让多个实例互斥?
3843
39- 悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候都可能出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说, ** 共享资源每次只给一个线程使用,其他线程阻塞,用完后再把资源转让给其他线程 ** 。
44+ 要让 A、B 竞争同一把锁,就需要把锁状态放到它们都能访问的外部系统中,例如 Redis、ZooKeeper 或数据库,并由这个系统原子地决定谁能获得锁 。
4045
41- 对于单机多线程来说,在 Java 中,我们通常使用 ` ReentrantLock ` 类、 ` synchronized ` 关键字这类 JDK 自带的 ** 本地锁 ** 来控制一个 JVM 进程内的多个线程对本地共享资源的访问。
46+ 以 Redis 锁为例,两个实例约定操作商品 ` 1001 ` 前,都先获取 ` lock:stock:1001 ` 这把锁。下面展示 A 先获得锁、且锁在业务执行期间始终有效的情况:
4247
43- 下面是我对本地锁画的一张示意图。
48+ ``` mermaid
49+ sequenceDiagram
50+ participant A as 订单实例 A(JVM A)
51+ participant B as 订单实例 B(JVM B)
52+ participant L as Redis(保存锁状态)
53+ participant D as 数据库(保存库存和订单)
54+ A->>L: 获取 lock:stock:1001
55+ L-->>A: 获取成功
56+ B->>L: 获取 lock:stock:1001
57+ L-->>B: 获取失败,本次不执行库存操作
58+ A->>D: 校验库存、扣减、创建订单并提交事务
59+ D-->>A: 提交成功,库存变为 0
60+ A->>L: 校验持有者身份后释放锁
61+ B->>L: 重试获取 lock:stock:1001
62+ L-->>B: 获取成功
63+ B->>D: 重新查询库存
64+ D-->>B: 库存为 0,不再创建订单
65+ B->>L: 校验持有者身份后释放锁
66+ ```
4467
45- ![ 本地锁 ] ( https://oss.javaguide.cn/github/javaguide/distributed-system/distributed-lock/jvm-local-lock.png )
68+ 图中的 Redis 保存的是 ** 锁状态 ** ,数据库保存的是 ** 业务数据 ** 。实例获取锁后,仍然由自己访问数据库。Redis 锁不会自动锁住数据库记录,所有需要互斥的访问方都必须遵守同一套加锁约定;绕过锁直接修改库存的请求不受它约束。
4669
47- 从图中可以看出,这些线程访问共享资源是互斥的,同一时刻只有一个线程可以获取到本地锁访问共享资源 。
70+ 锁要覆盖完整的临界区:从库存校验到扣减、创建订单及事务提交,而不能只锁住查询。拿不到锁的请求可以等待、失败或重试;后续拿到锁时,需要重新检查业务状态。锁过期和客户端故障会影响互斥保证,下一节会介绍这些边界 。
4871
49- 分布式系统下,不同的服务/客户端通常运行在独立的 JVM 进程上。如果多个 JVM 进程共享同一份资源,使用本地锁就没办法实现资源的互斥访问。这时就需要把锁的状态放到所有进程都能访问的外部系统中,也就是 ** 分布式锁 ** 。
72+ ### 多实例部署就一定需要分布式锁吗?
5073
51- 换到分布式协调的视角看,分布式锁其实是在回答一个问题:同一时刻谁是某个资源的唯一 owner?如果你还没搞清楚 Leader/Quorum、Lease 和 Fencing Token 之间的关系,建议先读 [ 分布式协调详解 ] ( ./protocol/centralized-and-decentralized.md ) ,再回来看 Redis、ZooKeeper、etcd 这些具体实现会更顺 。
74+ 不一定。 ** 需要跨进程互斥,不代表必须额外引入 Redis 等分布式锁服务。 ** 如果数据库本身就能保证业务约束,通常可以直接利用数据库的能力 。
5275
53- 举个例子:系统的订单服务一共部署了 3 份,都对外提供服务。为了防止超卖,需要保护的不是单独的“检查库存”,而是“校验库存 → 扣减库存 → 记录购买/创建订单”这段临界区;否则只锁查询、不锁扣减,仍然可能并发写错。由于订单服务位于不同的 JVM 进程中,本地锁在这种情况下就没办法正常工作。我们需要用到分布式锁,这样即使多个线程不在同一个 JVM 进程中,也能获取到同一把锁,进而实现共享资源的互斥访问。
76+ 例如,防止库存扣成负数,可以使用条件更新:
5477
55- 下面是我对分布式锁画的一张示意图。
78+ ``` sql
79+ UPDATE stock SET count = count - 1 WHERE sku_id = ? AND count > 0 ;
80+ ```
5681
57- ![ 分布式锁 ] ( https://oss.javaguide.cn/github/javaguide/distributed-system/distributed-lock/distributed-lock.png )
82+ 以 MySQL InnoDB 为例,并发更新同一行时会通过行锁协调。只有受影响行数为 1 才表示扣减成功,才继续创建订单;扣减和订单写入应放在同一个本地事务中,失败时一起回滚。这样,即使有多个服务实例,也不需要为这次库存扣减额外加 Redis 锁。
5883
59- 从图中可以看出,这些独立的进程中的线程访问共享资源是互斥的,同一时刻只有一个线程可以获取到分布式锁访问共享资源。
84+ 同理,每个用户在同一活动限购 1 件,可以用 ` user_id + activity_id ` 的唯一约束保证;重复提交订单可以用幂等键处理。这些约束要分别设计,不能只靠一把库存锁解决。
85+
86+ 当业务确实需要让多个进程中的某段逻辑串行执行,而数据库条件更新、唯一约束等手段又不适合时,再考虑分布式锁。它会增加网络请求和故障处理的复杂度,也会限制同一资源上的并发度。
6087
6188## 分布式锁应该具备哪些条件?
6289
@@ -90,11 +117,13 @@ Redis 锁更常用于高性能、短临界区、允许通过业务幂等兜底
90117
91118最后提醒一句:** 分布式锁不是分布式事务。锁只能控制临界区并发进入,不保证数据库提交一定成功,也不保证消息发送和订单写入原子一致。业务一致性仍要依赖本地事务、幂等、状态机、补偿任务等机制。**
92119
120+ 如果想进一步了解 Leader/Quorum、Lease 和 Fencing Token 之间的关系,可以阅读 [ 分布式协调详解] ( ./protocol/centralized-and-decentralized.md ) 。
121+
93122## 总结
94123
95124这篇文章我们主要介绍了:
96125
97- - 分布式锁的用途:分布式系统下,不同的服务/客户端通常运行在独立的 JVM 进程上。如果多个 JVM 进程共享同一份资源的话,使用本地锁就没办法实现资源的互斥访问了 。
126+ - 分布式锁的用途:协调多个进程对同一份外部共享资源的互斥访问。本地锁只能协调当前 JVM 内使用同一把锁的线程;如果数据库条件更新、唯一约束等已经能保证业务约束,就不必额外引入分布式锁服务 。
98127- 分布式锁应该具备的条件:互斥、高可用和防死锁、安全释放、可重入、高性能、获取语义明确、续约机制。更严格的场景还要配合 Fencing Token。
99128- 分布式锁的常见实现方式:关系型数据库比如 MySQL、分布式协调服务 ZooKeeper、Redis 这类高性能键值存储、etcd 这类分布式一致性键值存储。
100129
0 commit comments