Skip to content

Commit e13c12e

Browse files
committed
docs(distributed-system): rework distributed lock motivation with multi-instance scenario
1 parent b039a06 commit e13c12e

1 file changed

Lines changed: 56 additions & 27 deletions

File tree

docs/distributed-system/distributed-lock.md

Lines changed: 56 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)