乐观锁和悲观锁有什么区别?
简化版
区别在对「冲突」的假设不同:悲观锁假设「一定有人来抢」,所以先加锁再操作,别人只能等(如 synchronized、ReentrantLock、数据库 for update)。乐观并发控制假设冲突较少,先读取快照,提交时通过 CAS、版本号等条件更新检查数据是否变化,失败后重试或放弃(如 CAS、数据库版本号)。悲观锁适合写多冲突频繁,乐观锁适合读多写少冲突少。
详细版
悲观锁(Pessimistic Lock):先占坑再干活。
synchronized (lock) { balance -= amount; } // 加锁,别的线程阻塞等待
// 数据库悲观锁
select * from account where id=1 for update; // 锁住这行,别人改不了
乐观锁(Optimistic Lock):先干活,提交时验证没被人改过。
// CAS:只有当前值等于期望值才更新
atomicInt.compareAndSet(expect, update);
// 数据库版本号(乐观锁的经典实现)
update account set balance=?, version=version+1
where id=1 and version=?; // 版本对不上说明被别人改过,更新影响 0 行 → 重试
| 维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 假设 | 一定有冲突 | 大概率无冲突 |
| 做法 | 先加锁,独占 | 不加锁,提交时校验 |
| 冲突处理 | 别人阻塞等待 | 重试或放弃 |
| 开销 | 加锁/阻塞唤醒开销 | 无锁,但冲突多时重试耗 CPU |
| 适合 | 写多、冲突频繁 | 读多写少、冲突少 |
| 例子 | synchronized、ReentrantLock、for update | CAS/原子类、数据库版本号 |
完整版教学
一、两种「态度」决定两种做法
这道题的精髓是两个字:假设。
- 悲观锁是「悲观」的世界观——它假设「我一动数据,肯定有人跟我抢」,所以先把门锁上,独占资源,别人只能在门外等。安全,但别人干等着,并发度低。
- 乐观锁是「乐观」的世界观——它假设「大概率没人跟我抢」,所以不锁门直接干,只在最后提交时回头看一眼「刚才有没有人动过」。没动过就成功,动过就重试。省了加锁开销,但如果冲突真的多,会反复重试。
理解了这个「态度」差异,其余的实现细节、适用场景都能自然推出来。
二、乐观锁的两种典型实现
① CAS(内存级):Java 原子类的底层。compareAndSet(期望值, 新值)——只有当前值还等于期望值(没被人改过),才更新。失败就自旋重试(详见「什么是 CAS」那道题)。
② 版本号 / 时间戳(数据库级):给表加一个 version 字段:
-- 1. 读出数据和当前 version
select balance, version from account where id=1; -- 假设 version=5
-- 2. 更新时带上 version 作为条件,并让 version+1
update account set balance=900, version=6 where id=1 and version=5;
-- 3. 若影响行数=0,说明这期间别人改过(version 已不是 5),本次失败 → 重试
这就是「提交时校验有没有被动过」的数据库版本。它避免读取阶段用 for update 长时间持有排他锁,但真正执行 UPDATE 时数据库仍可能取得行锁;version 条件负责检测陈旧快照,适合冲突较少的场景。
三、怎么选:看冲突频率
核心判断标准是冲突(写竞争)的激烈程度:
- 冲突少(读多写少)→ 乐观锁:大部分时候没人抢,乐观锁省下了加锁开销,偶尔重试代价也小。比如展示型数据、低频更新。
- 冲突多(写多)→ 悲观锁:如果频繁冲突,乐观锁会反复重试、CAS 空转烧 CPU,还不如悲观锁一把锁住排队来得稳。比如秒杀热点行、高频扣减。
记忆点:冲突少用乐观、冲突多用悲观。乐观锁不是「更高级」,用错场景(高冲突)反而更差。
四、乐观锁的坑:ABA 与重试风暴
- ABA 问题:CAS 式乐观锁只看「值等不等」,值绕回原值会被误判为没变过,要用版本号解决(详见「CAS」那道题)——这也是为什么数据库乐观锁直接用 version 而非比较值。
- 重试风暴:高并发下大量乐观锁更新失败、集体重试,可能雪上加霜。要限制重试次数、加退避,或干脆换悲观锁。
五、和其他知识点的联系
- CAS 是乐观锁在内存层面的实现,
AtomicInteger、AQS 都基于它; - synchronized / ReentrantLock 常被归到悲观式互斥控制;
- 数据库中
for update是悲观式锁定读,version 条件更新是乐观控制;MVCC 负责多版本可见性,但不能简单等同于乐观锁。
一套「乐观 vs 悲观」的思维贯穿 Java 并发和数据库,是理解并发控制的一把钥匙。
六、数据库版本号必须检查影响行数
两个请求都读到 balance=1000, version=5,A 想扣 100,B 想扣 200。A 先执行条件更新成功,把数据改为 900、版本 6;B 仍带版本 5,更新影响 0 行,必须重新读取或向上返回冲突,不能把 0 行当成功。
UPDATE account
SET balance = 900, version = 6
WHERE id = 1 AND version = 5;
-- affected_rows == 1 才表示本次乐观更新成功
| 请求 | 读取版本 | 目标值 | 影响行数 | 处理 |
|---|---|---|---|---|
| A | 5 | 900 | 1 | 成功,版本到 6 |
| B | 5 | 800 | 0 | 冲突,重读/失败 |
版本字段还应在同一条原子 UPDATE 中递增。先查版本、再无条件 UPDATE 会留下竞态;只比较业务值也可能遇到 ABA 或无法区分两次相同写入。
七、冲突概率决定的是总成本
假设一次乐观更新耗时 2 ms,成功率 90%,平均尝试次数近似 1 / 0.9 ≈ 1.11;若成功率降到 20%,平均尝试次数约为 5 次,CPU、数据库写入和尾延迟都会迅速上升。重试还可能放大热点,因此要设置次数上限、指数退避和随机抖动。
悲观锁则把冲突转化为等待,代价是锁持有时间、阻塞和死锁风险。选择不能只看“读多写少”,还要看同一数据项的热点程度、临界区长度、重试副作用与延迟 SLA。
记忆钩子:乐观控制把冲突成本放在“提交失败与重试”,悲观控制把成本放在“提前互斥与等待”;比较的是冲突发生后的总代价,不是名字谁更先进。
八、常见误区与追问
- 误区:乐观锁的执行过程完全没有任何底层锁。 它是不提前长期独占的控制策略,数据库真正写行时仍可能使用内部锁。
- 误区:版本号 UPDATE 发出后就算成功。 必须检查影响行数,0 行表示快照已过期或条件不匹配。
- 误区:MVCC 就等于乐观锁。 MVCC 管理版本可见性,乐观控制还需要在写入时检测并处理冲突。
- 追问:为什么热点秒杀不适合无限乐观重试? 冲突率高会制造重试风暴,放大 CPU、数据库和尾延迟压力。
- 追问:CAS 的 ABA 与数据库 version 有什么联系? 两者都说明只比较当前业务值不够,单调版本可记录变化历史。
- 追问:悲观锁一定吞吐更低吗? 不一定;高冲突下有序等待可能比大量失败重试更节省总资源。
- 追问:乐观更新失败应该自动重试吗? 取决于操作是否幂等、业务是否允许覆盖以及重试预算,不能无上限重试。
九、加强记忆
悲观与乐观不是两种固定 API,而是两种冲突处理策略:悲观控制在操作前建立互斥,把代价放在等待;乐观控制基于快照工作,提交时用 CAS 或版本条件检测冲突,把代价放在失败与重试。数据库 version 方案的关键是同一条条件 UPDATE 并检查 affected rows,写入阶段仍可能有数据库内部行锁。冲突低时乐观方式省等待,热点冲突高时悲观排队或串行化可能更稳。回答时用成功率与平均重试次数算一遍,才能把“怎么选”从口号变成成本模型。