乐观锁和悲观锁详解
面试高频 + 实战常用的并发控制手段
核心问题:什么时候锁别人,什么时候先干再说?
一、先把概念捋清楚
1. 悲观锁(Pessimistic Lock)
思想:
“我觉得你一定会和我抢,所以我先把门锁上再干活。”
- 假设“冲突很可能发生”;
- 在读/写数据前,先加锁,其他事务读写会被阻塞或受限;
- 典型实现:数据库的行锁 / 表锁,
SELECT ... FOR UPDATE等。
2. 乐观锁(Optimistic Lock)
思想:
“我先干活,最后再确认一下期间有没有人改过,如果有就重来。”
- 假设“冲突很少发生”;
- 操作数据时不立即加锁,提交前检查是否有冲突;
- 冲突检测失败:一般是重试或提示失败;
- 典型实现:版本号(version 字段)、时间戳、CAS(Compare And Swap)。
一句话总结:
- 悲观锁:先上锁,再干活;
- 乐观锁:先干活,最后校验。
二、悲观锁详解
2.1 悲观锁的典型场景
- 并发写多、冲突概率高;
- 更新逻辑复杂且代价大,重试成本高;
- 强一致性要求高的场景:
- 银行转账;
- 库存扣减但业务逻辑复杂、流水多步更新;
- 余额变动、核心账户系统等。
2.2 在数据库层的实现(以 MySQL InnoDB 为例)
常见悲观锁语法:
-- 排他锁(写锁):锁住选中的行,其他事务不能修改/加排他锁
SELECT * FROM product
WHERE id = 1
FOR UPDATE;
-- 共享锁(读锁):允许多个事务加共享锁,但不能加排他锁
SELECT * FROM product
WHERE id = 1
LOCK IN SHARE MODE;
特点:
- 需要在事务中使用:
BEGIN;
SELECT stock FROM product
WHERE id = 1
FOR UPDATE;
-- 检查库存并更新
UPDATE product
SET stock = stock - 1
WHERE id = 1;
COMMIT;
- InnoDB 会对命中的行加行锁(可能伴随间隙锁);
- 其他事务如果也想
FOR UPDATE同一行,会被阻塞直到锁释放或超时。
2.3 悲观锁的优点
- 一致性强:锁住资源,避免同时修改;
- 逻辑简单:思维模型直接,容易理解;
- 适合对数据正确性极度敏感的场景。
2.4 悲观锁的缺点
- 并发度低:大量事务同时抢同一行时,会排长队;
- 可能出现:
- 死锁(多个事务交叉持有锁);
- 锁等待时间长,影响整体性能;
- 对长事务极不友好,事务持锁时间越长,影响越大。
三、乐观锁详解
3.1 乐观锁的典型场景
- 读多写少,冲突相对较少;
- 允许“失败重试”的业务;
- 不希望加锁阻塞,提高并发度:
- 用户资料更新;
- 配置信息修改;
- 大部分后台管理类表单更新;
- 一些简单扣减场景(如非极端抢购)。
3.2 版本号实现方式(最常用)
数据表设计:
CREATE TABLE product (
id BIGINT PRIMARY KEY,
name VARCHAR(50),
stock INT,
version INT NOT NULL
);
业务流程:
- 先查出数据:
SELECT id, stock, version
FROM product
WHERE id = 1;
- 更新时带上版本条件:
UPDATE product
SET stock = stock - 1,
version = version + 1
WHERE id = 1
AND stock > 0 -- 防止扣成负数
AND version = #{oldVersion};
- 判断执行结果:
- 如果
UPDATE返回影响行数 = 1:说明版本匹配,更新成功; - 如果返回 0:说明数据已经被别人修改过(version 不相等 / stock 不够),当前更新失败,需要:
- 重查数据后重试,或
- 直接报“库存不足”/“数据已更新,请刷新”等。
**本质:**把“加锁串行化”变成“无锁 + 成功就成功,失败就重试/提示”。
3.3 其他乐观锁方式
-
时间戳字段:
通过比较update_time是否等于原值来判断是否被修改。 -
字段值对比:
不单独用版本号,而是直接比较旧值:
UPDATE user
SET balance = balance + 100
WHERE id = 1
AND balance = #{oldBalance};
- CAS 思想(在内存 / Redis 等):
类似compareAndSet(oldValue, newValue),仅在值没变时才更新。
3.4 乐观锁的优点
- 不依赖数据库锁,减少阻塞,提高吞吐;
- 在冲突少的情况下,整体性能非常好;
- 实现简单(在业务层加版本字段 + 条件更新)。
3.5 乐观锁的缺点
-
遇到高并发 & 高冲突的热点数据:
- 失败重试次数可能很多;
- 业务复杂时重试逻辑难写。
-
并不能完全避免“丢更新”,真正安全依赖 SQL 条件:
- 如果更新语句写错(没带版本条件),就失去了保护。
四、乐观锁 vs 悲观锁 对比总结
4.1 思想层面对比
| 对比维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 冲突假设 | 相信“会冲突” | 相信“很少冲突” |
| 控制方式 | 先加锁,再操作 | 不加锁,提交时校验版本 |
| 读写关系 | 写时阻塞其他读/写(视锁类型而定) | 读写通常不互相阻塞 |
| 失败代价 | 一般不会失败(除死锁或异常) | 可能更新失败,需要重试或提示 |
| 实现位置 | 多在数据库层(行锁、表锁等) | 多在业务/应用层(version 字段、CAS 等) |
4.2 使用场景对比
| 场景类型 | 推荐方案 |
|---|---|
| 银行转账、资金扣减 | 悲观锁 + 严格事务 |
| 秒杀、抢购库存 | 通常结合多种手段(限流、队列),乐观锁只是其中一环 |
| 一般配置更新、个人信息修改 | 乐观锁 |
| 复杂多步更新且高度冲突 | 更倾向悲观锁 |
| 分布式系统跨服务修改 | 多采用乐观锁 + 重试,或分布式锁 |
五、MySQL + Java 中的常见用法
5.1 MySQL 中的悲观锁
-- 案例:扣减库存(悲观锁版本)
BEGIN;
SELECT stock
FROM product
WHERE id = 1
FOR UPDATE;
-- 检查 stock >= 1
UPDATE product
SET stock = stock - 1
WHERE id = 1;
COMMIT;
注意:
- 事务必须显式开启;
- FOR UPDATE 会加行锁,避免并发修改冲突。
5.2 MySQL 中的乐观锁
-- 先查
SELECT id, stock, version
FROM product
WHERE id = 1;
-- 再更新(乐观锁)
UPDATE product
SET stock = stock - 1,
version = version + 1
WHERE id = 1
AND stock > 0
AND version = #{oldVersion};
在业务代码中判断:
- 影响行数 = 1 → 成功;
- 影响行数 = 0 → 失败,可能是库存不足或被别人先一步更新。
5.3 Java / JPA 中的乐观锁示例
例如使用 JPA / Hibernate:
@Entity
public class Product {
@Id
private Long id;
private Integer stock;
@Version
private Integer version;
}
- 添加
@Version字段后,JPA 在更新时会自动加上版本条件:WHERE id = ? AND version = ?
- 如果更新失败,会抛出乐观锁异常,需要业务做重试或处理。
六、如何选择:用乐观锁还是悲观锁?
可以记一个简单判断维度:
-
冲突概率高不高?
- 高:更倾向用悲观锁;
- 低:乐观锁更合适。
-
能不能接受重试?
- 能:乐观锁没问题;
- 不能(比如写操作很重、事务很复杂):更适合悲观锁。
-
并发量大小?
- 极高并发 + 单热点:
- 纯悲观锁会把大家都锁死;
- 纯乐观锁失败率很高,也会浪费资源;
- 通常要结合限流、队列、拆分热点等手段。
- 极高并发 + 单热点:
-
一致性要求:
- 资金、余额这类强一致场景:
- 更多配合悲观锁 / 严格事务;
- 一般业务数据:
- 乐观锁足够,必要时给用户一个“请刷新页面”的提示。
- 资金、余额这类强一致场景:
七、小结
-
悲观锁:
- 特点:先上锁再操作,牺牲并发换取简单强一致;
- 实现:
SELECT ... FOR UPDATE、行锁、表锁等。
-
乐观锁:
- 特点:先操作再校验,冲突失败就重试或提示;
- 实现:版本号字段、时间戳、CAS。
-
如何选:
- 冲突高 + 严格一致性 + 难以重试 → 倾向悲观锁;
- 冲突低 + 允许失败重试 + 追求高并发 → 倾向乐观锁。
真正的生产系统里,往往是两种都用:
- 某些关键点用悲观锁兜底;
- 大部分业务采用乐观锁 + 幂等 + 重试机制,来换取性能和扩展性。
8243

被折叠的 条评论
为什么被折叠?



