锁一篇搞定:乐观锁、悲观锁、CAS、数据库锁与分布式锁【从零开始的 Web 后端学习】
面向零基础,咱们从"两个请求同时改一条数据"开始,把 Java 锁、CAS、数据库锁和 Redis 分布式锁一次串起来。
文中的 Java 示例只使用标准 JDK;数据库与 Redis 示例是可落地的代码骨架,未在本文环境中执行,实际结果请以你的 JDK、数据库、驱动和 Redis 版本为准。全文不虚构性能数字。

上面这张桌面图先把本文的五个关键词放在一起:乐观锁、悲观锁、CAS、数据库锁和分布式锁。后面咱们会把它们分别放回正确的运行边界里。
一、ok,咱们先想一个问题:为什么需要锁?
商品库存只剩 1 件,用户 A 和用户 B 几乎同时点击"购买"。后端代码看起来很正常:
java
if (stock > 0) {
stock = stock - 1;
save(stock);
}
但两个线程可能这样交叉执行:
text
线程 A:读取 stock = 1
线程 B:读取 stock = 1
线程 A:判断 1 > 0,准备写回 0
线程 B:判断 1 > 0,准备写回 0
线程 A:写回 0,购买成功
线程 B:写回 0,购买也成功
最后库存显示为 0,订单却可能有两笔。不是代码不会减一,而是"读取、判断、写回"这几个动作被两个执行者交叉执行了。
再换一个例子:两个人同时编辑同一篇文章。
- A 看到标题是"Java 入门";
- B 也看到标题是"Java 入门";
- A 改成"Java 并发入门"并保存;
- B 改成"Java 面试入门"并保存。
如果没有冲突检测,B 的保存可能覆盖 A 的修改。咱们真正想要的通常不是"最后一个保存的人赢",而是:发现数据已经变过,就让后来的操作重新确认。
所以锁要解决的核心问题是:
多个执行者同时访问同一份共享数据时,怎样避免数据被错误地交叉修改?
这里的共享数据可以是:
- Java 堆里的对象、集合、计数器;
- 数据库里的行、页、表;
- Redis 里的库存、订单状态、业务标记;
- 多台服务共同使用的某个业务资源。
很多人学锁会懵,不是名词真的多,而是把几个不同层次的东西搅在一起了:
synchronized和ReentrantLock:主要解决同一个 JVM 里的线程并发;- CAS:一种"比较并交换"的原子更新手段;
- 数据库锁:由数据库事务和存储引擎协调并发;
- 分布式锁:让多台机器上的多个进程围绕同一资源协调;
- 乐观锁、悲观锁:是处理冲突时的两种思路,不等于某一个固定 API。
咱们先清场,再逐个击破。
二、先清场:这些锁到底在什么地方工作?
2.1 A vs B:乐观锁和悲观锁
| 对比项 | 悲观锁 | 乐观锁 |
|---|---|---|
| 核心想法 | 先假设会冲突,先把资源锁住 | 先假设冲突少,提交时再检查 |
| 常见动作 | 加锁、等待、持有、释放 | 读取版本,提交时比较版本 |
| 冲突发生时 | 后来的执行者等待或失败 | 后来的执行者更新失败、重试或提示 |
| 是否一定阻塞别人 | 通常会,具体看锁模式 | 通常不会在读取阶段阻塞 |
| 常见实现 | synchronized、ReentrantLock、SELECT ... FOR UPDATE |
版本号、CAS、条件更新 |
| 适合感觉 | 临界区短、冲突代价高、需要串行 | 读多写少、冲突可恢复 |
2.2 A vs B:CAS 和互斥锁
| 对比项 | CAS | 互斥锁 |
|---|---|---|
| 基本动作 | 比较旧值,匹配才写新值 | 先获取锁,再执行临界区 |
| 线程获取不到资源时 | 常见做法是失败后重试或返回失败 | 可以等待、超时或放弃 |
| 典型 API | compareAndSet(old, new) |
lock() / unlock() |
| 适合范围 | 小粒度、单变量或少量状态 | 多步骤组成的临界区 |
| 主要风险 | ABA、自旋消耗、重试风暴 | 死锁、忘记释放、锁粒度过大 |
2.3 A vs B:本地锁、数据库锁、分布式锁
| 对比项 | Java 本地锁 | 数据库锁 | 分布式锁 |
|---|---|---|---|
| 保护范围 | 当前 JVM 进程 | 数据库事务中的数据 | 多台机器共同可见的资源 |
| 其他机器能否感知 | 不能 | 能通过数据库访问感知 | 能通过 Redis、ZooKeeper、etcd 等感知 |
| 常见实现 | synchronized、ReentrantLock |
行锁、锁定读、FOR UPDATE |
Redis SET NX PX、租约 |
| 主要成本 | 线程排队 | 事务、连接、锁等待 | 网络往返、租约和故障处理 |
| 典型使用 | 单实例缓存刷新 | 库存扣减、订单状态落库 | 多实例定时任务、幂等处理 |
2.4 A vs B:数据库悲观锁和数据库乐观锁
| 对比项 | SELECT ... FOR UPDATE |
版本号/条件更新 |
|---|---|---|
| 读取时 | 读取并锁住符合条件的记录 | 普通读取,不主动占用写锁 |
| 提交时 | 在同一事务内更新 | WHERE id = ? AND version = ? |
| 冲突处理 | 后来的事务等待或按超时策略处理 | 受影响行数为 0,说明版本已经变化 |
| 事务要求 | 必须放在事务中,并注意提交时机 | 更新语句承担冲突检测,但仍要结合事务设计 |
| 常见问题 | 长事务导致锁等待 | 忘记检查受影响行数,误以为更新成功 |
清场之后,先记住这一句话:
乐观/悲观是处理冲突的策略;CAS 是原子更新手段;Java 锁、数据库锁、分布式锁是不同运行边界下的实现工具。
三、一句话定义:锁到底是什么?
锁是一种并发协调机制:它把对共享资源的访问规则明确下来,让多个执行者按照互斥、可见、可控的方式读写数据,从而避免竞态条件和状态破坏。
这里有三个关键词:
- 互斥:同一时刻,特定临界区不要被多个线程同时执行;
- 可见:一个线程写入的结果,其他线程能在规定的内存模型或存储规则下看到;
- 可控:冲突发生时,要有等待、失败、重试、回滚等处理方式。
锁不是"加上就万事大吉"。锁住哪个对象、锁多久、异常时是否释放、跨不跨机器,这些才是实战里的关键。
四、先看全貌:一次库存扣减会经过哪些锁?
text
用户请求 A 用户请求 B
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Web 线程 A │ │ Web 线程 B │
└──────┬───────┘ └──────┬───────┘
│ │
├── 同一个 JVM? ──────────────────┤
│ │
▼ ▼
synchronized / ReentrantLock synchronized / ReentrantLock
│ │
└──────────────┬───────────────────┘
▼
数据库事务边界
│
┌───────────┴───────────┐
▼ ▼
SELECT ... FOR UPDATE version 条件更新
│ │
└───────────┬───────────┘
▼
COMMIT / ROLLBACK
│
▼
多实例场景再考虑
Redis / etcd 分布式协调

这张图解决的是"锁到底在哪一层生效"的问题:线程锁只在当前进程里协调,数据库锁由数据库事务协调,分布式锁才面向多个进程或多个实例。
注意:这几层不是必须全部叠加。实际项目要根据共享资源的位置选择合适的协调方式。
五、线程安全基础:什么是竞态条件?
5.1 共享资源和临界区
如果多个线程都能访问某个数据,这个数据就是共享资源。访问共享资源的那段代码,就是临界区。
java
int stock = 1;
// 读取、判断、写回是一个完整业务动作
if (stock > 0) {
stock = stock - 1;
}
问题在于,stock = stock - 1 并不是一个不可分割的机器动作。它至少包含:
text
读取 stock
计算 stock - 1
把结果写回 stock
两个线程交错执行,就可能出现丢失更新。
5.2 volatile 解决不了 i++
volatile 主要解决可见性和一定的有序性问题,但它不把复合操作变成原子操作。
java
private volatile int count;
public void increment() {
count++; // 仍然可能丢失更新
}
count++ 可以拆成:
text
读取 count
count + 1
写回 count
两个线程都读到 10,最后都写回 11,结果就少加了一次。
5.3 为什么要把边界讲清楚?
因为"线程安全"不是一句魔法咒语:
- 只在同一个 JVM 里共享对象,可以考虑 Java 本地锁;
- 多个服务实例都访问数据库,要考虑数据库事务和数据库锁;
- 多个实例要争抢同一个任务,可以考虑分布式协调;
- 一个字段的原子递增,可能只需要原子类或数据库条件更新。
六、悲观锁:先假设会冲突,先把资源占住
6.1 悲观锁的思路
悲观锁的出发点是:我认为冲突可能发生,所以在进入临界区之前先占用资源。
生活中的例子就是卫生间门锁:一个人进去之后,其他人先等待,直到里面的人出来并释放门锁。
text
获取锁
│
├── 获取成功 → 执行临界区 → 释放锁
│
└── 获取失败 → 等待、超时或放弃
6.2 synchronized 示例
下面这个示例是一个标准 JDK 示例,保存为 SynchronizedCounterDemo.java 即可尝试编译运行。本文未执行。
java
public class SynchronizedCounterDemo {
private static class Counter {
private int value;
public synchronized void increment() {
value++;
}
public synchronized int getValue() {
return value;
}
}
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread[] threads = new Thread[4];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 10_000; j++) {
counter.increment();
}
}, "worker-" + i);
threads[i].start();
}
for (Thread thread : threads) {
thread.join();
}
System.out.println("counter = " + counter.getValue());
}
}
这里的 synchronized 保护的是同一个 Counter 对象实例。两个不同对象之间没有自动协调关系。
6.3 ReentrantLock 示例
ReentrantLock 更像一把需要手动操作的锁:
java
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockDemo {
private static class SafeCounter {
private final ReentrantLock lock = new ReentrantLock();
private int value;
public void increment() {
lock.lock();
try {
value++;
} finally {
lock.unlock();
}
}
public int getValue() {
lock.lock();
try {
return value;
} finally {
lock.unlock();
}
}
}
public static void main(String[] args) throws InterruptedException {
SafeCounter counter = new SafeCounter();
Thread first = new Thread(counter::increment);
Thread second = new Thread(counter::increment);
first.start();
second.start();
first.join();
second.join();
System.out.println(counter.getValue());
}
}
finally 不是装饰,而是必须。只要忘了释放,异常路径就可能让后续线程一直等待。
6.4 tryLock:不想无限等待怎么办?
java
if (lock.tryLock()) {
try {
// 执行临界区
} finally {
lock.unlock();
}
} else {
// 获取失败:返回稍后重试、进入队列或降级处理
}
如果使用带超时的 tryLock,还要处理等待超时和线程中断。实际业务不能只写"拿不到就一直等"。
6.5 悲观锁的优点和代价
优点:
- 多步骤临界区更直观;
- 冲突多时,可以减少反复重试;
- 业务代码容易表达"同一时间只能一个人处理"。
代价:
- 线程可能等待;
- 锁范围太大,会降低并发度;
- 多把锁顺序不一致时,可能形成死锁;
- 长时间持锁会放大故障影响。
七、乐观锁:先做事,提交时再核对
7.1 乐观锁的思路
乐观锁的出发点是:冲突不是每次都会发生,那就先读取、先计算,提交时检查数据有没有被别人改过。
最常见的做法是增加一个 version 版本号:
text
第一次读取:id = 1,stock = 10,version = 7
请求 A:带 version = 7 更新,成功后 version = 8
请求 B:仍然带 version = 7 更新,条件不成立,更新 0 行

这张图解决的是"悲观锁为什么让后来的请求等待,乐观锁为什么在提交时发现版本冲突"的理解问题。
7.2 数据库版本号 SQL
表结构可以这样设计:
sql
CREATE TABLE product_stock (
id BIGINT PRIMARY KEY,
product_name VARCHAR(100) NOT NULL,
stock INT NOT NULL,
version BIGINT NOT NULL DEFAULT 0
);
读取数据:
sql
SELECT id, product_name, stock, version
FROM product_stock
WHERE id = 1;
带版本条件更新:
sql
UPDATE product_stock
SET stock = stock - 1,
version = version + 1
WHERE id = 1
AND stock > 0
AND version = 7;
这里最重要的是检查影响行数:
- 返回
1:本次条件匹配并完成更新; - 返回
0:可能是版本冲突、库存不足、记录不存在或其他条件不满足。
SQL 执行成功,不等于业务更新成功。0 行必须进入冲突处理分支。
7.3 乐观锁更新的 Java 伪代码
java
int affectedRows = productMapper.decreaseStock(
productId,
expectedVersion
);
if (affectedRows != 1) {
// 重新查询、提示重试、有限次数重试,或进入补偿流程
throw new ConcurrentUpdateException("库存版本已变化");
}
重试也要有边界:
- 重试次数有限;
- 每次重试重新读取最新版本;
- 必要时加入退避;
- 高冲突时进入队列或直接提示用户。
无限重试会把一次冲突放大成重试风暴。
7.4 乐观锁不是"不加任何保护"
乐观锁仍然需要保护,只是保护方式不是先占住资源,而是提交时进行条件检查。
下面两条 SQL 的含义完全不同:
sql
-- 有冲突检测
UPDATE product_stock
SET stock = stock - 1, version = version + 1
WHERE id = ? AND version = ?;
sql
-- 没有版本条件,可能覆盖别人的更新
UPDATE product_stock
SET stock = stock - 1, version = version + 1
WHERE id = ?;
第二条即使修改了 version,也没有用旧版本判断"我读到的数据是不是最新的"。
八、CAS:Compare-And-Set 到底是什么?
8.1 CAS 的三个参数
CAS 可以理解成:
text
如果当前值 == 预期值:
原子地改成新值
返回 true
否则:
不修改
返回 false
Java 的 AtomicInteger 提供了这个能力:
java
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicIntegerDemo {
public static void main(String[] args) {
AtomicInteger stock = new AtomicInteger(1);
boolean success = stock.compareAndSet(1, 0);
System.out.println("success = " + success);
System.out.println("stock = " + stock.get());
}
}
注意:compareAndSet 返回 false 后,程序要自己决定是重读、重试还是返回失败。
8.2 CAS 循环
java
import java.util.concurrent.atomic.AtomicInteger;
public class CasLoopDemo {
private static final AtomicInteger counter = new AtomicInteger();
public static void increment() {
while (true) {
int oldValue = counter.get();
int newValue = oldValue + 1;
if (counter.compareAndSet(oldValue, newValue)) {
return;
}
}
}
public static void main(String[] args) {
increment();
increment();
System.out.println(counter.get());
}
}
CAS 循环的特点是:失败后不一定阻塞等待,而是重新读取并尝试。但竞争激烈时,线程可能反复失败并消耗 CPU。
8.3 ABA 问题
假设线程 A 读取到值 A,线程 B 把它改成 B,之后又改回 A。线程 A 再比较时发现"还是 A",却不知道中间已经发生过变化,这就是 ABA 问题。
如果业务关心"中间有没有变化",就不能只比较值,还要比较版本或时间戳。Java 中可以了解 AtomicStampedReference 这类带版本戳的工具。
8.4 CAS 不等于永远更快
CAS 适合小粒度状态更新,但不是所有业务的替代品:
- 多个字段要一起修改时,单个 CAS 不够表达整体一致性;
- 业务逻辑很长时,反复自旋的成本可能变高;
- 需要等待、超时、条件队列时,显式锁更直观;
- 跨 JVM、跨机器时,内存 CAS 保护不了远端资源。
九、数据库悲观锁:SELECT ... FOR UPDATE
9.1 数据库锁的协调者是谁?
Java 本地锁由 JVM 协调;数据库锁由数据库和存储引擎协调。只要多个请求通过同一个数据库访问同一条记录,数据库才有机会让它们排队。
以常见的 MySQL InnoDB 语法为例:
sql
START TRANSACTION;
SELECT id, stock
FROM product_stock
WHERE id = 1
FOR UPDATE;
-- 应用层检查 stock 是否足够
UPDATE product_stock
SET stock = stock - 1
WHERE id = 1;
COMMIT;
另一个事务如果也对同一条记录执行锁定读,通常会等待前一个事务提交或回滚。精确的锁范围仍然取决于数据库、存储引擎、索引、隔离级别和查询条件。
9.2 FOR UPDATE 必须和事务绑定
下面这种写法容易让人误会:
text
连接 1:SELECT ... FOR UPDATE
连接 1:语句结束
连接 2:修改同一条记录
如果锁没有被一个持续的事务持有,后续业务动作就没有得到预期保护。使用 JDBC 时通常要显式关闭自动提交,在同一个连接上完成锁定读、业务更新和提交。
9.3 JDBC 事务骨架
java
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public class JdbcPessimisticLockDemo {
public static void decreaseStock(Connection connection,
long productId) throws SQLException {
boolean oldAutoCommit = connection.getAutoCommit();
connection.setAutoCommit(false);
try {
int stock;
String selectSql = """
SELECT stock
FROM product_stock
WHERE id = ?
FOR UPDATE
""";
try (PreparedStatement ps = connection.prepareStatement(selectSql)) {
ps.setLong(1, productId);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) {
throw new SQLException("商品不存在");
}
stock = rs.getInt("stock");
}
}
if (stock <= 0) {
throw new SQLException("库存不足");
}
String updateSql = """
UPDATE product_stock
SET stock = stock - 1
WHERE id = ?
""";
try (PreparedStatement ps = connection.prepareStatement(updateSql)) {
ps.setLong(1, productId);
int affectedRows = ps.executeUpdate();
if (affectedRows != 1) {
throw new SQLException("库存更新失败");
}
}
connection.commit();
} catch (Exception exception) {
connection.rollback();
throw exception;
} finally {
// 连接池场景中,归还连接前恢复连接状态
connection.setAutoCommit(oldAutoCommit);
}
}
}
这段代码只展示事务边界和锁定读骨架。实际项目还要配置 JDBC 驱动、连接池、数据库地址,并根据数据库文档核对语法。本文未执行。
9.4 数据库悲观锁的几个注意点
- 事务不能长时间持有锁;
- 查询条件要有合适索引,避免扫描和锁住过宽范围;
- 多个事务加锁顺序尽量一致;
- 锁等待超时和死锁是两类问题,处理方式不完全相同;
- 普通
SELECT的一致性读和FOR UPDATE锁定读不是一回事; - 不同数据库对间隙锁、范围锁和隔离级别的实现可能不同。
十、数据库乐观锁:版本号和条件更新
10.1 最小可用方案
sql
SELECT id, stock, version
FROM product_stock
WHERE id = ?;
应用层拿到 stock = 10、version = 7 后,执行:
sql
UPDATE product_stock
SET stock = 9,
version = 8
WHERE id = ?
AND version = 7
AND stock >= 1;
如果返回 1 行,说明这次更新成功;如果返回 0 行,需要判断是库存不足、版本冲突还是记录不存在。
10.2 更适合扣减的原子条件更新
如果业务只是"库存减一",可以把判断和扣减合并到 SQL 条件中:
sql
UPDATE product_stock
SET stock = stock - 1,
version = version + 1
WHERE id = ?
AND stock >= 1
AND version = ?;
这种写法的关键不是 SQL 看起来短,而是:
- 条件中带旧版本;
- 成功时版本递增;
- 应用层检查
executeUpdate()的返回值; - 失败后进入明确的业务分支。
10.3 时间戳能不能当版本号?
有些系统会用更新时间判断冲突,但时间戳会受到字段精度、数据库类型、时钟和并发写入的影响。新手示例优先使用单调递增的整数版本号;如果使用时间戳,必须核对数据库字段精度和驱动行为。
10.4 ORM 的 @Version 是什么?
很多 ORM 会用 @Version 封装版本条件更新。它的本质仍然是:
sql
WHERE id = ? AND version = ?
框架具体如何报告冲突,要以对应版本的官方文档和实际配置为准。不要把"有 @Version 注解"理解成所有外部副作用也自动受保护。
十一、分布式锁:为什么 synchronized 管不了两台机器?
11.1 本地锁的边界
假设有两个服务实例:
text
机器 A / JVM 1 机器 B / JVM 2
synchronized(lockObject) synchronized(lockObject)
│ │
└──── 两个完全不同的 JVM ─────────┘
即使变量名相同、代码完全相同,它们也不是同一个内存对象。因此 JVM 本地锁无法协调两个进程。
这时才会考虑 Redis、ZooKeeper、etcd 等外部协调组件。
11.2 Redis 获取锁的基本命令
redis
SET lock:order:1001 unique-token NX PX 30000
含义:
lock:order:1001:锁的业务 key;unique-token:当前持有者的唯一标识;NX:只有 key 不存在时才设置;PX 30000:设置毫秒级过期时间。
这里的 token 不能省略。因为释放时必须确认"这个锁是不是我自己的"。
11.3 为什么不能直接 DEL?
一个危险时间线:
text
客户端 A 获取锁,token = A
A 执行时间过长,锁过期
客户端 B 获取同一个 key,token = B
客户端 A 终于执行完,直接 DEL lock:key
结果:A 把 B 的锁删除了
正确做法是"比较 token 相同后再删除",而且比较和删除要是一个原子操作。可以使用 Lua 脚本表达:
lua
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end

这张图专门解决 Redis 锁释放时最容易犯的错误:不能无条件删除,必须确认当前 token 仍然属于自己。
11.4 Redis 锁不是万能钥匙
分布式锁还要面对:
- 业务执行时间超过租约;
- 进程暂停、网络延迟或客户端失联;
- 锁已经过期,但旧持有者仍继续写资源;
- 续租线程自身失败;
- 锁服务发生网络分区;
- 业务方把 Redis 锁误当成数据库事务。
对严格正确性场景,可以引入 fencing token:每次成功获取资源时获得递增令牌,资源端在写入时校验令牌,拒绝旧令牌的写入。TTL 解决"锁最终会释放",fencing token 解决"旧持有者还能不能写"。两者不是一回事。
十二、锁粒度、锁范围和锁顺序
12.1 锁粒度
锁粒度可以简单理解成"锁住多大范围":
- 锁一个对象;
- 锁一个用户;
- 锁一条订单;
- 锁一行数据库记录;
- 锁一张表;
- 锁整个业务模块。
锁得太细,代码管理复杂;锁得太粗,并发度下降。实际选择要看共享资源的边界。
12.2 锁范围
锁范围不仅是代码块大小,也包括锁持有的时间:
java
lock.lock();
try {
// 只把必要的共享状态操作放进来
updateSharedState();
} finally {
lock.unlock();
}
// 发送通知、调用远程服务等非必要工作尽量不要占着锁做
不要拿着数据库行锁去等待用户输入,也不要在事务里长时间调用不稳定的外部服务。
12.3 锁顺序和死锁
死锁通常可以抽象成:
text
线程 A 持有锁 1,等待锁 2
线程 B 持有锁 2,等待锁 1
避免思路:
- 所有线程按照统一顺序获取多把锁;
- 尽量减少同时持有的锁数量;
- 使用超时获取,避免无限等待;
- 缩短临界区和事务时间;
- 对数据库死锁异常做有限重试,但先修复锁顺序问题。
十三、综合实战:库存扣减怎么选?
13.1 单 JVM、低复杂度场景
如果库存只在一个 JVM 内存对象里,业务也不需要跨实例协调,可以使用 synchronized 或 ReentrantLock:
java
public class InventoryService {
private final Object lock = new Object();
private int stock = 10;
public boolean buy() {
synchronized (lock) {
if (stock <= 0) {
return false;
}
stock--;
return true;
}
}
}
但一旦部署多个实例,每个实例都有自己的 stock 和自己的锁,这个方案就不再保护共享数据库库存。
13.2 数据库为最终事实来源
如果库存最终落在数据库,常见选择有两种:
悲观锁:
sql
START TRANSACTION;
SELECT stock FROM product_stock WHERE id = ? FOR UPDATE;
-- 检查库存
UPDATE product_stock SET stock = stock - 1 WHERE id = ?;
COMMIT;
乐观锁:
sql
UPDATE product_stock
SET stock = stock - 1,
version = version + 1
WHERE id = ?
AND stock > 0
AND version = ?;
如果更新影响行数为 0,应用层可以重新读取并有限重试,也可以直接返回"库存已变化,请重试"。
13.3 多实例抢同一个定时任务
这类场景首先要明确"谁是共享资源":不是某个 Java 对象,而是"这个任务今天只能执行一次"。可以用分布式锁做入口协调,但任务内部仍要有:
- 幂等键;
- 数据库事务;
- 状态机;
- 失败补偿;
- 超时和重复执行处理。
分布式锁只是减少重复进入的机会,不能替代整个业务的一致性设计。
十四、到底怎么选?给你一张决策表
| 业务问题 | 优先考虑 | 还要补什么 |
|---|---|---|
| 一个 JVM 内多个线程修改同一对象 | synchronized / ReentrantLock |
明确锁对象、释放路径和超时策略 |
| 单个数值的原子更新 | AtomicInteger / CAS |
处理 CAS 失败、自旋和 ABA |
| 数据库行需要先读后改 | SELECT ... FOR UPDATE |
显式事务、索引、锁顺序、超时 |
| 数据库读多写少、冲突可重试 | 版本号条件更新 | 检查影响行数、有限重试、用户提示 |
| 多实例只允许一个执行者 | Redis / etcd / ZooKeeper 协调 | token、租约、幂等、fencing 或补偿 |
| 多个资源要一起提交 | 数据库事务 | 不要把事务、锁和隔离级别混为一谈 |
一个简单口诀:
text
先看共享资源在哪儿:
Java 内存 → JVM 锁 / CAS
数据库记录 → 数据库事务 + 悲观锁或乐观锁
多实例共同任务 → 分布式协调 + 幂等 + 失效处理
十五、❌ 坑:代码运行时容易出问题的地方
ReentrantLock只在正常分支解锁,异常分支忘记放进finally;- JDBC 修改了
autoCommit,归还连接池前没有恢复连接状态; SELECT ... FOR UPDATE没有放在持续事务里;- 乐观锁
UPDATE返回 0 行,却仍然向用户返回"成功"; - 版本号没有出现在
WHERE条件中; - CAS 失败后无限自旋,没有退出条件;
- 多条 SQL 以不同顺序加锁,触发死锁;
- 查询条件没有合适索引,导致扫描和锁范围扩大;
- Redis 锁没有设置过期时间;
- Redis 释放锁时直接
DEL,误删其他客户端刚获得的锁; - 锁的租约太短,业务还没结束锁已经过期;
- 把远程调用、用户等待和大批量计算全部放在持锁区;
- 把数据库锁当成缓存、文件和消息系统的全局保护;
- 连接池复用连接时遗留了隔离级别、只读属性或事务状态。
十六、★ 误区:代码能跑,但理解是错的
- "乐观锁就是不加锁。" 乐观锁依靠版本或条件检查检测冲突。
- "悲观锁一定慢,乐观锁一定快。" 结果取决于冲突率、临界区、重试成本、数据库和网络等因素。
- "CAS 比
synchronized高级。" CAS 是原子更新原语,不是所有业务临界区的替代品。 - "CAS 一定不会阻塞。" CAS 循环可能长时间自旋,线程也可能长期得不到进展。
- "加了
volatile就线程安全。"volatile++仍然是复合操作。 - "数据库事务就是锁。" 事务描述操作边界,锁是并发协调手段,隔离级别描述并发可见性规则。
- "
SELECT ... FOR UPDATE到处都一样。" 数据库、存储引擎、索引和隔离级别都会影响精确语义。 - "拿到 Redis 锁后,数据库更新就绝对安全。" 租约过期和旧持有者继续写仍然需要版本校验或 fencing。
- "Redis
SETNX成功后直接DEL就完整了。" 还需要唯一 token、过期策略和原子校验释放。 - "锁住缓存就等于锁住数据库。" 缓存和数据库是不同资源,需要一致性、幂等和补偿策略。
- "锁的范围越大越安全。" 范围越大,等待和死锁的影响面也可能越大。
- "锁等待超时就是死锁。" 死锁通常是循环等待,超时则是等待超过阈值,诊断方式不同。
十七、📌 易忘点、❌ 坑、★ 误区、✅ 结论
📌 易忘点
- 乐观锁的关键是提交时的版本/条件检查;
- 悲观锁的关键是进入临界区之前先占用资源;
- CAS 比较的是共享值,数据库版本锁比较的是数据库记录条件;
FOR UPDATE要和事务边界一起理解;- Redis 解锁要校验 token,并且校验和删除要原子完成;
- JVM 锁只覆盖当前进程,跨实例要换成数据库或分布式协调方案。
❌ 坑
- 忘记解锁;
- 忽略影响行数;
- 无限重试;
- 长事务;
- 直接
DEL分布式锁; - 连接池连接状态污染。
★ 误区
- 把乐观锁理解成完全没有保护;
- 把 CAS 当成所有锁的替代品;
- 把事务、锁和隔离级别当成同一个概念;
- 认为分布式锁拿到后业务就天然一致。
✅ 结论
锁的选择不是背 API,而是先回答三个问题:
- 共享资源到底在哪里?Java 内存、数据库,还是多实例共同访问的外部资源?
- 冲突发生时希望等待,还是希望失败后重试?
- 锁释放、事务提交、客户端崩溃和网络异常发生后,业务还能不能自洽?
十八、我自己是这么记的
我会把锁记成三句话:
- 悲观锁先占位,乐观锁后验货。
- CAS 管一个小状态,事务管一组数据库动作。
- 本地锁管一个 JVM,分布式锁管多个进程,但最终正确性还要靠版本、幂等、事务和补偿。
再遇到"这里该不该加锁"的问题,我不会先搜索某个 API,而是先画出:谁在并发访问、共享资源在哪儿、冲突怎么发现、失败怎么收场。
十九、下篇预告
下一篇咱们可以继续拆:
- MySQL 事务隔离级别和 MVCC;
- Spring
@Transactional的事务边界; - Redis 分布式锁的续期、fencing token 和幂等设计;
- 订单、库存和支付状态机如何组合起来。
如果这篇文章帮你把乐观锁和悲观锁分清楚了,欢迎收藏,后面写并发业务时直接回来查。代码示例按本文说明复制即可尝试,实际运行前记得补齐本机依赖和配置。
参考资料
以下链接属于官方 API 或官方手册,用于核对命令和类的语义;本文没有把外部资料中的性能数字写成结论。
- Java SE API:ReentrantLock ------ JDK 官方 API。
- Java SE API:AtomicInteger ------ JDK 官方 API。
- MySQL:Locking Reads ------ MySQL 官方手册,核对
FOR UPDATE等锁定读说明。 - Redis:SET command ------ Redis 官方命令文档,核对
NX、PX等参数。 - Redis:EVAL command ------ Redis 官方命令文档,核对 Lua 脚本调用方式。
配图说明
| 图片 | 用在哪 | 解决什么理解问题 |
|---|---|---|
| 文章桌面图 | 文首 | 一眼看到本文的五个关键词:乐观锁、悲观锁、CAS、数据库锁、分布式锁 |
| 锁层次全景图 | "先看全貌"一节 | 说清锁分别在线程、CAS、数据库、分布式协调哪一层生效 |
| 乐观锁/悲观锁对照图 | 乐观锁一节 | 对比"先等待"和"提交时发现版本冲突再重试" |
| Redis 安全释放图 | 分布式锁一节 | 说明释放锁必须先校验 token,不能无条件删除 |
图片源文件与本文放在一起,路径统一在 images/locks-guide/ 下。