← 返回题目列表

乐观锁和悲观锁有什么区别?

高频 中等 第 2 / 31 题 更新于 2026/07/25
乐观锁悲观锁CAS并发

简化版

区别在对「冲突」的假设不同:悲观锁假设「一定有人来抢」,所以先加锁再操作,别人只能等(如 synchronizedReentrantLock、数据库 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 updateCAS/原子类、数据库版本号

完整版教学

一、两种「态度」决定两种做法

这道题的精髓是两个字:假设

  • 悲观锁是「悲观」的世界观——它假设「我一动数据,肯定有人跟我抢」,所以先把门锁上,独占资源,别人只能在门外等。安全,但别人干等着,并发度低。
  • 乐观锁是「乐观」的世界观——它假设「大概率没人跟我抢」,所以不锁门直接干,只在最后提交时回头看一眼「刚才有没有人动过」。没动过就成功,动过就重试。省了加锁开销,但如果冲突真的多,会反复重试。

理解了这个「态度」差异,其余的实现细节、适用场景都能自然推出来。

二、乐观锁的两种典型实现

① 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 才表示本次乐观更新成功
请求读取版本目标值影响行数处理
A59001成功,版本到 6
B58000冲突,重读/失败

版本字段还应在同一条原子 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,写入阶段仍可能有数据库内部行锁。冲突低时乐观方式省等待,热点冲突高时悲观排队或串行化可能更稳。回答时用成功率与平均重试次数算一遍,才能把“怎么选”从口号变成成本模型。