悲观锁和乐观锁是两种截然不同的并发控制思想
悲观锁假设:冲突必然发生,操作前先加锁以确保独占资源;
乐观锁假设:冲突概率较低,先执行操作,仅在提交时校验数据一致性。
选择依据核心在于业务场景的冲突频率:
写多读少:强一致性要求高的场景用悲观锁;
读多写少:冲突较少的场景用乐观锁。
一、核心定义与思想
1. 悲观锁
- 核心思想:假设并发冲突一定会发生,因此在访问数据前主动加锁,确保其他线程/事务无法同时修改数据。
- 行为模式:“先锁后操作”,类似“锁门上厕所”,必须拿到锁才能继续执行。
- 典型场景:银行转账、库存扣减、秒杀等强一致性要求高、写操作频繁的场景。
2. 乐观锁
- 核心思想:假设并发冲突很少发生,操作时不加锁,仅在提交更新时检查数据是否被修改过。
- 行为模式:“先操作后验证”,类似“超市自助结账”,冲突时重试或报错。
- 典型场景:商品浏览、点赞计数等读多写少、冲突概率低的场景。
3. 两者对比
| 对比维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 核心思想 | 假设冲突必然发生,操作前强制加锁以确保独占资源 | 假设冲突很少发生,先执行操作,提交时校验数据一致性 |
| 加锁时机 | 读取数据时立即加锁(如SELECT ... FOR UPDATE) | 不加锁,仅在提交更新时校验(如版本号比对) |
| 实现方式 | 数据库行锁(FOR UPDATE)、synchronized、ReentrantLock | 版本号机制、CAS 算法、时间戳校验 |
| 并发性能 | 较低(锁竞争导致阻塞,高并发下吞吐量下降) | 较高(无锁设计,冲突少时吞吐量显著提升) |
| 一致性保障 | 强一致性(ACID 事务级别,绝对避免脏写) | 最终一致性(冲突时需重试,短暂不一致可接受) |
| 失败处理 | 阻塞等待锁释放(可能超时) | 提交失败需重试或报错(如返回“数据已被修改”提示) |
| 典型风险 | 死锁、锁等待超时、长事务性能瓶颈 | ABA 问题、高冲突下重试风暴(CPU 消耗激增) |
| 适用场景 | - 写多读少(如银行转账、库存扣减)-冲突率 >40%-强一致性要求(金融交易) | - 读多写少(如点赞计数、配置更新)-冲突率 <20%-可容忍短暂不一致 |
| 代码复杂度 | 较低(依赖数据库或 JVM 原生支持) | 较高(需自行实现重试逻辑、冲突合并策略) |
二、实现机制
1. 悲观锁的实现
- 数据库层面:
- 通过
SELECT ... FOR UPDATE(排他锁)或SELECT ... FOR SHARE(共享锁)显式加锁,需在事务中使用。 - 必须确保索引命中,否则可能锁全表(如 MySQL 中未走索引的
FOR UPDATE会锁整张表)。
- 通过
- Java 语言层面:
synchronized关键字、ReentrantLock等独占锁机制,线程竞争时会阻塞等待。
数据库层面和Java层面仅需选择一种
FOR UPDATE适用于分布式部署的多服务器
synchronized适用于单服务器
2. 乐观锁的实现
- 版本号机制:
- 表中增加
version字段,更新时校验版本号是否匹配:UPDATEproductsSETstock=stock-1,version=version+1WHEREid=1ANDversion=旧值; - 若影响行数为 0,说明数据已被修改,需重试或报错。
- 表中增加
- CAS(Compare and Swap):
- 通过 CPU 原子指令实现,如 Java 的
AtomicInteger:AtomicIntegercount=newAtomicInteger(0);count.incrementAndGet();// 底层通过 CAS 重试实现 - 需注意 ABA 问题(值被修改后又恢复),可通过
AtomicStampedReference添加版本戳解决。
- 通过 CPU 原子指令实现,如 Java 的
三、关键对比
1. 适用场景
- 悲观锁更适合:
- 写多读少(如订单支付、库存扣减)。
- 冲突概率高(>20%)或强一致性要求严格(如金融交易)。
- 临界区执行时间短(避免长时间锁持有)。
- 乐观锁更适合:
- 读多写少(如商品详情页、用户配置更新)。
- 冲突概率低(<20%)且能容忍短暂不一致。
- 高并发场景(避免锁竞争导致的性能瓶颈)。
2. 性能与风险
| 维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 并发性能 | 低(阻塞等待,吞吐量受限) | 高(无锁竞争,冲突少时效率更优) |
| 一致性保障 | 强一致性(ACID 事务级别) | 最终一致性(需处理冲突重试) |
| 失败处理 | 阻塞直至获取锁 | 提交失败需重试或报错 |
| 典型风险 | 死锁、锁等待超时 | ABA 问题、高冲突下重试风暴 |
3. 悲观锁的陷阱
- 死锁风险:多资源交叉加锁时易发生(如事务 A 锁资源 X 后申请 Y,事务 B 锁 Y 后申请 X)。
规避:按固定顺序加锁、设置锁超时时间。 - 性能瓶颈:未走索引的
FOR UPDATE会锁全表(MySQL 中常见问题)。
规避:确保查询条件命中索引,避免全表扫描。
4. 乐观锁的陷阱
- ABA 问题:值被修改后恢复原状,导致校验通过但逻辑错误(如库存从 10→5→10)。
规避:用版本号替代时间戳(自增版本号可追溯修改次数)。 - 重试风暴:高冲突场景下重试逻辑可能耗尽 CPU 资源。
规避:限制最大重试次数(如 3 次)、采用指数退避算法调整重试间隔。
四、实际应用建议
1. 选择原则
- 冲突频率是核心指标:
- 若冲突率>40%,悲观锁更高效(避免频繁重试消耗 CPU)。
- 若冲突率<20%,乐观锁性能优势显著(吞吐量可提升 5-10 倍)。
- 业务一致性要求:
- 金融级操作必须用悲观锁;非核心数据(如浏览量)可用乐观锁。
2. 混合策略
- 动态切换:监控冲突率,低冲突时用乐观锁,高冲突时自动降级为悲观锁。
- 分层设计:
- 入口层用乐观锁过滤大部分请求。
- 核心层用悲观锁保障关键事务。
3. 常见误区
- 乐观锁性能一定更好?
否。高冲突场景下,乐观锁的重试开销可能超过悲观锁的阻塞成本。 - synchronized 是纯悲观锁?
否。JVM 会自适应:初始用轻量级锁(类似乐观策略),竞争激烈时升级为重量级锁。
五、典型示例
1. 库存扣减(悲观锁)
BEGIN;SELECTstockFROMproductsWHEREid=1FORUPDATE;-- 先加锁IFstock>0THENUPDATEproductsSETstock=stock-1WHEREid=1;ENDIF;COMMIT;2. 库存扣减(乐观锁)
-- 查询时获取版本号SELECTstock,versionFROMproductsWHEREid=1;-- 更新时校验版本UPDATEproductsSETstock=stock-1,version=version+1WHEREid=1ANDversion=旧值;