悲观锁 & 乐观锁

乐观锁和悲观锁

1. 本质:它们到底在解决什么问题

乐观锁和悲观锁,本质上都不是为了“加锁而加锁”,而是为了解决并发场景下共享数据的一致性问题

只要多个线程、多个请求、多个事务同时修改同一份数据,就可能出现这些问题:

  • 丢失更新:两个请求都读到旧值,后写入的覆盖先写入的结果
  • 超卖:库存本来只有 1,却被卖出去 2 份
  • 脏写:一个事务修改了另一个事务正在处理的数据
  • 状态覆盖:A 把状态从 待支付 改成 已支付,B 又基于旧数据改回去

所以锁的核心目标只有一句话:

让多个执行单元在访问共享资源时,既尽量并发,又尽量正确。

而乐观锁和悲观锁,恰好代表了两种完全不同的控制思路:

  • 悲观锁:我先假设冲突很容易发生,所以先锁住,再操作
  • 乐观锁:我先假设冲突不常发生,所以先操作,提交时再检查

所以这俩不是“语法点”,而是两种 concurrency control philosophy


2. 悲观锁:先锁再改,拿等待换正确性

悲观锁的核心假设是:

共享数据很危险,别人很可能同时改,所以我必须先拿到独占权。

它的典型流程是:

  1. 先获取锁
  2. 锁住共享资源
  3. 执行业务逻辑
  4. 修改数据
  5. 释放锁

在锁释放前,别人要么阻塞等待,要么直接失败。

悲观锁的特点

悲观锁最核心的关键词就是:

  • 互斥
  • 阻塞
  • 串行化
  • 强保护

也就是说,它不是让大家一起跑,而是让大家排队跑

数据库中的悲观锁

数据库里最典型的悲观锁就是:

SELECT … FOR UPDATE

例如库存扣减:

BEGIN;

SELECT stock
FROM product

1
2
WHERE id = 1
FOR UPDATE;

UPDATE product

1
2
SET stock = stock - 1
WHERE id = 1 AND stock > 0;

COMMIT;

这段 SQL 的意思是:
先把 id=1 这行锁住,锁住后别的事务不能同时改它,我这边读库存、扣库存、提交,整个过程是受保护的。

Java 中的悲观锁

Java 里最常见的悲观锁就是:

  • synchronized
  • ReentrantLock

例如:

1
2
public class Counter {
private int count = 0;
1
2
3
4
    public synchronized void increment() {
count++;
}
}

这里的 synchronized 就是在说:

同一时刻只能有一个线程进入这个方法。

悲观锁的优缺点

优点很直接:

  • 思路简单,容易理解
  • 强一致性好
  • 冲突高时更稳
  • 不依赖频繁重试

缺点也很明显:

  • 会阻塞,吞吐量下降
  • 竞争激烈时响应变慢
  • 可能死锁
  • 对事务时长、锁粒度很敏感

所以悲观锁更像一种 “先封场,再施工” 的做法。


3. 乐观锁:先改再校验,拿重试换性能

乐观锁的核心假设是:

大多数时候并发冲突并不多,所以没必要一开始就锁死。

它的典型流程是:

  1. 先读取数据
  2. 记住当前版本或当前值
  3. 基于这份数据做业务处理
  4. 提交更新时检查:这份数据有没有被别人改过
  5. 没改过就更新成功,改过了就失败或重试

所以乐观锁不是“完全无控制”,而是把控制点从操作前挪到了提交时

数据库中的乐观锁:version 字段

这是最经典的实现方式。

表结构:

CREATE TABLE product (
id BIGINT PRIMARY KEY,
stock INT NOT NULL,
version INT NOT NULL DEFAULT 0
);

查询到:

  • stock = 10
  • version = 3

更新时这样写:

UPDATE product
SET stock = stock - 1,
version = version + 1

1
2
3
WHERE id = 1
AND version = 3
AND stock > 0;

它的关键就在这句:

AND version = 3

如果更新时 version 还是 3,说明这份数据没被别人动过,更新成功;
如果 version 已经变成 4 了,说明别人先改了,那这次更新就失败。

Java 中的乐观锁:CAS

JVM 世界里最经典的乐观锁实现就是 CAS(Compare And Swap)

例如:

import java.util.concurrent.atomic.AtomicInteger;

1
2
public class CasDemo {
private final AtomicInteger count = new AtomicInteger(0);
1
2
3
4
5
6
7
8
9
10
    public void increment() {
while (true) {
int oldValue = count.get();
int newValue = oldValue + 1;
if (count.compareAndSet(oldValue, newValue)) {
break;
}
}
}
}

这里的逻辑就是:

  • 我先读旧值
  • 再算新值
  • 更新时检查旧值有没有被别人改
  • 没改就写入
  • 改了就重试

这就是很标准的 optimistic style。

乐观锁的优缺点

优点:

  • 不阻塞,吞吐量高
  • 并发度更好
  • 适合读多写少
  • 线程不会大量挂起等待

缺点:

  • 冲突高时会频繁失败
  • 可能反复重试,浪费 CPU
  • 实现复杂度比悲观锁高
  • 很依赖业务是否允许失败重试

所以乐观锁更像一种 “先提交,最后验票” 的做法。


4. 实际应用:两种锁分别落在什么场景里

这部分不讲抽象定义,直接讲 real business cases

悲观锁的实际应用

场景 1:账户转账

转账本质上是高一致性场景:

  • A 扣钱
  • B 加钱
  • 中间不能被并发覆盖
  • 不能出现扣了没加、加了没扣

这种场景 usually 更适合悲观锁,比如:

BEGIN;

1
2
SELECT balance FROM account WHERE id = 1 FOR UPDATE;
SELECT balance FROM account WHERE id = 2 FOR UPDATE;
1
2
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;

COMMIT;

因为这类业务的核心诉求不是高并发,而是绝不能错

场景 2:核心库存扣减

如果是少量高价值商品,或者后台管理系统中的人工扣减库存,悲观锁也很适合。
原因很简单:宁可排队,也别超卖。

场景 3:Java 临界区保护

多个线程修改复杂共享对象时,比如:

  • 修改订单状态 + 写日志 + 触发事件
  • 修改缓存结构
  • 维护复杂链表或 Map

这类逻辑不是简单加 1,而是一段临界区逻辑,那通常 synchronized / ReentrantLock 更稳。


乐观锁的实际应用

场景 1:商品信息编辑 / 用户资料更新

后台管理系统很适合 version 乐观锁。
比如两个运营同时改商品标题:

  • A 先打开页面
  • B 后打开页面并先保存
  • A 再保存时,version 不一致,系统提示“数据已变更,请刷新重试”

这种场景没必要长时间锁表或锁行,乐观锁最自然。

场景 2:普通库存扣减

如果库存冲突没有那么剧烈,可以用 version 做扣减。
更新失败就重试几次,系统吞吐量通常比悲观锁更高。

场景 3:Java 原子计数

像访问次数、自增序号、简单状态位修改,AtomicIntegerAtomicLong 这类基于 CAS 的原子类特别适合。

也就是说:

  • 复杂临界区 更偏悲观锁
  • 简单原子更新 更偏乐观锁

5. 对比与选型:什么时候该用哪个

这部分是面试和工程里最常问的,也是最需要“判断力”的地方。

维度 悲观锁 乐观锁
核心思想 先锁再改 先改再校验
冲突假设 默认冲突多 默认冲突少
是否阻塞 通常否
性能特征 冲突高时更稳 冲突低时更优
典型实现 synchronizedReentrantLockselect for update version、CAS、AtomicInteger
失败方式 等锁 / 获取失败 更新失败 / 重试失败
适合场景 写多、强一致、高冲突 读多写少、可重试、追求吞吐

怎么选

可以记住一个非常实战的判断逻辑:

选悲观锁

如果业务具有这些特征,优先考虑悲观锁:

  • 冲突概率高
  • 写操作多
  • 数据价值高
  • 强一致要求高
  • 失败后不方便重试

比如:

  • 转账
  • 核心账务
  • 高价值库存
  • 状态机严谨推进

选乐观锁

如果业务具有这些特征,优先考虑乐观锁:

  • 读多写少
  • 冲突概率低
  • 允许失败重试
  • 更关注吞吐量
  • 不希望线程大量阻塞

比如:

  • 表单更新
  • 用户资料修改
  • 普通库存扣减
  • 原子计数

一句总结选型原则

冲突少,用乐观;冲突多,用悲观;一致性要求极高,也偏悲观。

这个结论非常够用。


6. 常见误区与面试总结

最后这部分专门收口,把最容易混淆的地方一次讲清。

误区 1:乐观锁就是不加锁

不准确。
乐观锁不是没有并发控制,而是不在整个操作过程中加排他锁。它把控制点放在提交时,比如:

  • 数据库的 version
  • JVM 的 CAS

所以它不是“没控制”,而是“延迟控制”。

误区 2:悲观锁一定慢,乐观锁一定快

也不对。
如果冲突很高,乐观锁会疯狂失败、疯狂重试,最后可能比悲观锁更慢。
所以性能不能脱离场景谈。

误区 3:MVCC 就是乐观锁

不等价。
MVCC 更偏向数据库的读一致性和读写并发优化,而乐观锁更偏向更新冲突检测
两者 related,但不是一个概念。

误区 4:select for update 一上就完事了

也不行。
它很依赖这些前提:

  • 必须在事务中
  • 查询最好命中索引
  • 事务不能太长
  • 锁粒度要尽量小

否则数据库很容易被锁竞争拖慢。


面试标准回答模板

如果面试官问:乐观锁和悲观锁的区别?

可以这样答:

乐观锁和悲观锁是两种并发控制思想。悲观锁认为并发冲突很容易发生,所以在操作共享数据前先加锁,期间其他线程或事务需要等待,典型实现有 Java 的 synchronized、ReentrantLock,以及数据库的 select for update。它的优点是一致性强、实现直观,但缺点是会阻塞,竞争激烈时吞吐量下降,还可能死锁。
乐观锁认为并发冲突不常发生,所以不会先加排他锁,而是在更新时通过版本号、时间戳或者 CAS 检查数据是否被别人修改过,典型实现有数据库的 version 字段和 Java 的 AtomicInteger。它的优点是并发度高、性能更好,但缺点是冲突高时会频繁失败和重试。
一般来说,读多写少、冲突低适合乐观锁;写多、冲突高、强一致要求高更适合悲观锁。

最后的总收束

乐观锁和悲观锁没有绝对优劣,只有场景是否匹配。

  • 悲观锁:先锁后改,拿等待换正确性
  • 乐观锁:先改后验,拿重试换性能