“昨天半夜又被报警短信吵醒了——线上商品库存居然出现了超卖。团队紧急回滚,定位代码时发现,明明在扣减库存的 update 语句前加了 synchronized,为什么在高并发下还是失效了?如果你也认为加个‘锁’就能解决一切并发问题,那你可能正站在一个危险的认知误区边缘。本文将带你穿透‘乐观锁’与‘悲观锁’的迷雾,从原理、实战到选型,让你不仅通过面试,更能真正写出健壮的高并发代码。”
在并发编程的世界里,“锁”是守护数据一致性的基石。但面对“乐观锁”与“悲观锁”这两个看似对立的概念,很多开发者容易陷入简单的字面理解:一个乐观,一个悲观,二选一罢了。然而,真正的选择绝非如此非黑即白。选错锁机制,轻则性能腰斩,重则数据错乱。今天,我们就来深入探讨这两种锁的本质与应用哲学。
一、本质探秘:两种截然不同的世界观
在深入技术细节前,我们必须从“世界观”层面理解它们。这绝非文字游戏,而是它们一切行为差异的源头。
悲观锁(Pessimistic Locking) 持有一种“总有刁民想害朕”的世界观。它悲观地认为,只要我访问的数据,就极有可能被别人修改。因此,它的策略是 “先下手为强” :在真正进行数据操作(增删改)之前,就抢先获取锁,将数据独占。在它持有锁的期间,其他所有尝试访问该数据的线程都必须挂起等待,直到锁被释放。这就像你进入图书馆的单人研究室,进去后立刻反锁门,无论你是否在写字,门外的人都只能等着。
乐观锁(Optimistic Locking) 则秉持一种“和谐社会,冲突少有”的乐观态度。它认为数据竞争是小概率事件,因此允许多个线程同时去读取和修改同一份数据。但乐观并非放纵,它有一个至关重要的 “事后校验” 环节:在提交更新时,会检查自上次读取后,数据是否已被其他线程修改过。如果没变,则提交成功;如果变了,则意味着本次更新基于过期的数据,必须放弃或重试。这就像多人协同编辑在线文档,大家都可以自由编辑,但在保存时,系统会检查自你打开后文档是否有更新,如果有,就会提示你“文档已变更,请合并更改”。
一张图看清两种锁的核心工作流程:
(此处应有一张对比流程图)
左列-悲观锁流程:开始 -> 获取锁 -> 执行业务逻辑&更新数据 -> 提交事务,释放锁 -> 结束。
右列-乐观锁流程:开始 -> 读取数据(含版本号V1)-> 执行业务逻辑(基于V1数据)-> 提交时检查当前版本号是否为V1 -> [是] 更新数据,版本号V1->V2 -> 提交成功;[否] 提交失败,回滚或重试 -> 结束。
二、实战对垒:代码里的刀光剑影
理论太抽象?让我们通过一个经典的 “电商库存扣减” 场景,看看它们如何落地。
场景:商品A库存100件,1万用户瞬间抢购。
1. 悲观锁实现(以数据库行锁为例)
// 使用 SELECT ... FOR UPDATE 在事务开始时即锁定记录
@Transactional
public boolean deductStockPessimistic(Long productId) {
// Highlight: 关键就在这里!查询时直接锁定这条记录,阻止其他事务的读(某些隔离级别下)写。
Product product = productMapper.selectForUpdate(productId);
if (product.getStock() > 0) {
product.setStock(product.getStock() - 1);
productMapper.updateById(product);
return true;
}
return false;
}
核心作用:这是一个典型的悲观锁Demo。 SELECT ... FOR UPDATE 语句是MySQL等数据库中实现悲观锁的常用手段,它在事务内对选中的行加上排他锁(X锁)。
点睛注释: // Highlight: 关键就在这里!查询时直接锁定这条记录,阻止其他事务的读(某些隔离级别下)写。
2. 乐观锁实现(以版本号机制为例)
首先,商品表需要增加一个版本号字段 version 。
public class Product {
private Long id;
private String name;
private Integer stock;
private Integer version; // 新增的版本号字段
}
业务逻辑层:
public boolean deductStockOptimistic(Long productId) {
int retryTimes = 3; // 乐观锁冲突常见处理:有限次重试
for (int i = 0; i < retryTimes; i++) {
Product product = productMapper.selectById(productId);
if (product.getStock() <= 0) {
return false;
}
// 基于读取到的数据计算新库存和版本号
product.setStock(product.getStock() - 1);
product.setVersion(product.getVersion() + 1);
// Highlight: 更新时,将最初读取的版本号作为条件。如果匹配,说明期间无人修改,更新成功。
int rows = productMapper.updateByIdWithVersion(product);
if (rows == 1) {
// 更新成功,影响行数为1
return true;
}
// 更新失败(rows == 0),说明版本号对不上,循环重试
// 在实际中,这里可以加一小段随机延迟,避免活锁
}
// 重试多次仍失败,可能并发冲突异常激烈
throw new RuntimeException(“扣减库存失败,请重试”);
}
对应的MyBatis Update SQL大致如下:
UPDATE product
SET stock = #{stock}, version = #{version}
WHERE id = #{id} AND version = #{oldVersion} -- 核心的CAS操作
核心作用:这是乐观锁原理的完整示例,展示了“读取-计算-验证并更新(CAS)”的完整流程,以及失败重试的通用模式。
点睛注释: // Highlight: 更新时,将最初读取的版本号作为条件。如果匹配,说明期间无人修改,更新成功。
生活化类比:
想象你和同事共同维护一份共享的《项目进度表》。
- 悲观锁:就像你把表格下载到本地,并立即在团队群里说:“我正在修改,大家别动!”。你修改的整个过程中,别人既看不到最新内容,也无法修改。等你改完上传,大家才能继续。
- 乐观锁:就像你们直接用在线文档(如Google Docs)。你和同事可以同时打开编辑。你改完第10行点击保存,系统会悄无声息地保存成功。但如果你的同事在你读完后、保存前,已经修改并保存了第10行,那么当你点击保存时,系统会弹出一个友好的提示:“文档已更新,请刷新后合并你的更改”。这个“提示”就是乐观锁的冲突检测,而“刷新后合并”就是重试机制。
三、抉择时刻:我该用哪个?一张表格与一个故事
避坑指南:千万不要死记“乐观锁性能一定更好”!性能高低完全取决于冲突频率。
| 特性维度 |
悲观锁 |
乐观锁 |
| 核心思想 |
防患于未然 |
事后校验,冲突重试 |
| 适用场景 |
写多读少,冲突概率高。如:金融账户扣款、抢购核心资源。 |
读多写少,冲突概率低。如:商品信息更新、文章点赞计数。 |
| 实现方式 |
数据库行锁( FOR UPDATE )、 Java synchronized 、 ReentrantLock 等。 |
版本号、时间戳、CAS原子操作(如 AtomicInteger )。 |
| 优点 |
简单粗暴,保证强一致性,没有重试开销。 |
并发度高,无锁设计减少线程挂起,吞吐量往往更高。 |
| 缺点 |
性能开销大(加锁、释放锁、线程切换),可能引发死锁,降低并行度。 |
存在ABA问题(可通过增加版本号解决),冲突频繁时重试开销大,可能降低体验。 |
| 类比 |
独占会议室 |
协同编辑在线文档 |
一个踩坑案例:
曾经在一个人力资源系统的“员工信息批量审核”功能中,我下意识地使用了乐观锁(版本号)。我认为审核动作不频繁,冲突低。上线后,在月度集中审核时,管理员经常反馈“提交失败,请重试”。一查日志,发现大量 Update rows=0 的记录。原来,管理员习惯长时间打开页面,然后快速连续审核多个员工。后提交的请求,其版本号早已被前一个请求更新,导致连续失败。
反思:这个场景看似“写少”,但操作是串行且连续的,对同一个数据的两次更新间隔极短,冲突概率接近100%。这里改用悲观锁(例如,在服务层对单个员工ID加 synchronized 锁),虽然损失了微不足道的并发度,却换来了管理员流畅的线性操作体验,是更合适的选择。这提醒我们,进行系统设计和选型时,必须深入理解真实业务场景的并发模式。
所以,选择的关键在于评估你的临界区(被保护的数据或代码段)发生竞争的真实概率。
四、超越数据库:广阔天地中的锁
锁的概念不仅限于数据库。在Java和分布式系统中也有广泛的应用。
- Java层面的乐观锁:
java.util.concurrent.atomic 包下的 AtomicInteger 等类,其 incrementAndGet() 方法底层就是通过CAS(Compare-And-Swap) 这种CPU原子指令实现的乐观锁。它是 volatile 变量(保证可见性) + CAS(保证原子性)的经典组合。
- 分布式锁是悲观锁:当我们使用Redis的
SETNX 命令或ZooKeeper的临时有序节点来实现分布式锁时,本质上实现的是一种分布式的悲观锁。因为它要求在访问共享资源前,必须先在所有服务实例间争夺一个全局唯一的锁令牌。
- MVCC是乐观锁的延伸:数据库的多版本并发控制(MVCC) ,如MySQL的InnoDB在
REPEATABLE READ 隔离级别下的实现,可以看作是乐观锁思想的一种高级扩展。它通过维护数据的多个版本来实现非锁定读,极大提升了读并发性能。更多关于数据库的并发控制技术值得深入探索。
面试官追问:“你提到了CAS,那你知道CAS的ABA问题吗?如何解决?”
答:ABA问题是指,一个变量值原来是A,被另一个线程改为B,然后又改回A。这时,使用CAS进行检查的线程会误以为它没有被修改过。解决方案是加入版本号或时间戳等“状态戳”,不比较值本身,而比较“值+状态戳”的复合信息。 AtomicStampedReference 就是JDK提供的解决方案。
五、实战总结
- 定义区分:悲观锁是“先锁后改”,乐观锁是“先改后验(冲突则弃/重试)”。
- 选择铁律:高冲突用悲观,低冲突用乐观。冲突概率是动态的,需结合业务场景评估。
- 实现方式:悲观锁可用数据库
FOR UPDATE 、Java内置锁;乐观锁常用版本号、CAS原子类。
- 性能认知:在低冲突下,乐观锁无阻塞,性能远超悲观锁;在高冲突下,乐观锁的重试成本可能使其性能反劣于悲观锁。
- 实践要点:乐观锁必须配合重试机制,并设置合理的重试次数上限;悲观锁需注意锁粒度,并小心死锁。
希望这篇文章能帮助你构建起关于锁选择的清晰认知。技术的道路需要不断学习和交流,欢迎来 云栈社区 与其他开发者共同探讨更多高并发与系统架构的实战话题。