悲观锁和乐观锁有什么区别?分别适用什么场景?
结论先行:悲观锁(pessimistic lock)假设冲突一定会发生,操作前先加锁,让别人排队;乐观锁(optimistic lock)假设冲突很少,先改、提交时再校验有没有被别人改过,冲突了重试。前者用阻塞换安全,后者用重试换吞吐。
1. 对比表
| 维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 核心假设 | 冲突频繁,先锁再说 | 冲突稀少,改完再验 |
| 实现手段 | synchronized、ReentrantLock、select ... for update | CAS、版本号字段、StampedLock 乐观读 |
| 冲突时的行为 | 阻塞等待(或超时失败) | 检测到版本变化,失败重试 |
| 开销分布 | 加锁/解锁固定开销,冲突时线程切换 | 无锁开销,冲突时浪费一次计算 |
| 适用并发度 | 写多、冲突高 | 读多写少、冲突低 |
| 是否可能饥饿 | 可能(非公平锁) | 可能(长事务持续失败) |
2. 两条执行路径
悲观锁:
T1 加锁 ──▶ 读数据 ──▶ 修改 ──▶ 写回 ──▶ 解锁
T2 全程阻塞等待 ──▶ 拿到锁再重复一遍
乐观锁:
T1 读 (id=1, version=3) ──▶ 计算 ──▶ UPDATE ... SET v=?, version=4
WHERE id=1 AND version=3
T2 同样从 version=3 开始 ──▶ UPDATE 影响行数 = 0 ──▶ 重试或报错
乐观锁的关键在于那条 UPDATE 语句:判断和更新合成一个原子操作,影响行数为 0 就代表"我读到的数据已经过期"。
3. 三种典型写法
// 1. 悲观锁:JVM 层面互斥
public synchronized void deduct(long amount) {
if (balance >= amount) balance -= amount;
}
// 2. 乐观锁:CAS 自旋重试
public void deductCas(AtomicLong balance, long amount) {
long expect, update;
do {
expect = balance.get();
if (expect < amount) throw new IllegalStateException("余额不足");
update = expect - amount;
} while (!balance.compareAndSet(expect, update)); // 失败说明被人改过,重来
}
// 3. 乐观锁:数据库版本号(最常用)
// UPDATE account SET balance = balance - #{amount}, version = version + 1
// WHERE id = #{id} AND version = #{version} AND balance >= #{amount}
int rows = mapper.deduct(id, amount, version);
if (rows == 0) {
// 冲突:重试、提示用户刷新,或抛业务异常
}
数据库层面还有一个悲观锁写法:SELECT ... FOR UPDATE,它会对行加排他锁直到事务结束,并发度低但语义最直接。
4. 怎么选
| 场景 | 选择 | 理由 |
|---|---|---|
| 库存扣减、秒杀 | 乐观锁(版本号)+ 限流 | 冲突集中在少数热点行,重试成本可控 |
| 账户余额、强一致账务 | 悲观锁(for update) | 不允许重试带来的语义歧义 |
| 读多写少的配置缓存 | 乐观锁 / 无锁 | 冲突概率极低,加锁纯属浪费 |
| 高冲突的计数器 | LongAdder(分段) | 比 CAS 自旋更抗竞争 |
| 长事务里的复杂判断 | 悲观锁 | 乐观锁重试会放大事务代价 |
5. 常见追问
追问一:乐观锁失败后怎么办? 三条路:重试(有限次数,加退避)、快速失败(返回"请重试"给前端)、降级为悲观锁。无限重试是最危险的写法——高冲突时线程全在空转,CPU 打满但吞吐为零。
追问二:版本号和 ABA 是什么关系?
版本号天然解决 ABA:即使值改回原样,version 也已经递增,WHERE version = 3 不会再命中。如果只用值做条件(WHERE balance = 100),就会出现"A→B→A"被误判为没变的情况。CAS 的 ABA 问题同理,用 AtomicStampedReference 加版本戳解决。
追问三:乐观锁能完全替代悲观锁吗? 不能。冲突率高时,乐观锁的重试次数呈指数上升,总开销会超过一把锁;而且重试意味着业务逻辑要可重放,不是所有操作都能安全重放(比如发消息、扣积分)。
追问四:Java 里的乐观读是什么?
StampedLock.tryOptimisticRead():读取前拿一个 stamp,读完用 validate(stamp) 检查期间有没有写操作,没有就直接用数据(全程无锁);有则退化成悲观读。它把"读"的代价压到接近零。
小结
悲观锁是"先上锁后办事",乐观锁是"先办事后校验"。选择的分界线是冲突概率:低冲突用乐观锁省掉阻塞,高冲突用悲观锁省掉重试。工程上最常见的组合是——数据库版本号 + 有限重试 + 失败快速返回。