☰
悲观锁与乐观锁:并发控制策略与库存扣减选型实战
2026/10/26 22:06:36 网站建设 项目流程

如果你是 Java 面试官,在同一个候选人面前连续抛三个问题——“悲观锁和乐观锁怎么实现?”“两者区别是什么?”“库存扣减你选哪个?”——第一轮通常能听到标准答案:synchronized 是悲观锁,CAS 是乐观锁,数据库里可以用版本号实现乐观锁。这个回答不算错,但只答到了 API 层。再追问一句:“你选的方案在冲突率高和冲突率低两种场景下,代价分别是多少?”很多人会停住。

原因在于,悲观锁和乐观锁本质上不是两个类,也不是两个关键字,而是两种并发控制策略。它们的核心分歧不是“用不用锁”,而是“你愿意在冲突发生之前付出多少代价,还是在冲突发生之后再去补救”。哪怕这两年 Java 生态里 Spring AI、大模型应用的讨论很热闹,并发编程依然是面试里最能筛人的一块。把这个底层逻辑想清楚,面试题、线上问题排查和技术选型,其实是一条线。

1. 先把你对锁的理解拔高一层:这不是 API,是策略

1.1 悲观锁的默认假设:冲突一定会发生

悲观锁的思维模型是:我操作的数据,很可能同时被别人修改,所以我必须先把我关心的资源保护起来,确保在我读完、算完、写完的整个过程中,别人碰不到它。

这句话里有三个关键词:读、算、写。悲观锁保护的是一段完整操作过程,而不只是一个“更新语句”。锁定期间其他人只能等待。这种等待在单机 Java 里是线程被阻塞,在数据库里是事务等待行锁或表锁。阻塞本身不是问题,问题是阻塞时间取决于临界区代码要跑多久。

比如这段代码:

public synchronized void deductStock(Long skuId, int num) { // 注意:这里的同步块锁的是当前对象,不是数据库行 Stock stock = stockMapper.selectById(skuId); if (stock.getCount() < num) { throw new BizException("库存不足"); } stock.setCount(stock.getCount() - num); stockMapper.updateById(stock); }

看起来逻辑完整,实际有两个隐蔽问题。

第一,synchronized 锁的是当前 Java 对象,在多实例部署时完全无效;即使单实例,它也锁住了所有走这个方法的事务,而不是只锁某一款商品的库存。第二,持锁期间做了数据库查询和更新,锁的生命周期被网络 IO 拉长。并发一高,整个接口的 TPS 会被拖垮。

不是说 synchronized 不能用在这里,而是你要先分清:锁保护的边界是“对象”还是“数据行”,锁的生命周期覆盖的是“内存计算”还是“跨网络 IO”。这是在悲观锁场景里最常见的错误。

1.2 乐观锁的默认假设:冲突是少数情况

乐观锁不做提前保护。它先把任务做完,然后在“写回”的那一步检查:在我操作期间,有没有别人改过这个数据?如果有,就放弃或者重试。

对应的 Java 实现就是 CAS:比较当前值与预期值,一样就更新;不一样就说明有人抢先了。数据库层面就是版本号或状态条件更新:更新时带上 version = 当前版本,如果影响行数是 0,说明版本已经变了。

这两种策略其实是两种人生哲学:一个觉得“路上一定会堵车,所以我提前两个小时出门”,一个觉得“路上大概率不堵,我先出门,真堵了再改路线”。不能说哪个更好,要看你在什么城市、什么时间出门。

1.3 为什么很多人的理解停留在“实现”层面

主要原因是 Java 并发编程的学习顺序。大多数人都是先学 synchronized,再学 ReentrantLock,然后学 AtomicInteger,最后学数据库悲观锁、乐观锁。学的是 API,而 API 背后是一类策略。面试官想听到的恰恰是策略层的东西,也就是你怎么把业务映射到某种策略上。

所以,这篇文章先不讲“哪个锁更好用”,而是先把两种策略的代价模型讲清楚。后面的所有实现和场景题,都是从这个模型推出来的。

2. Java 里的悲观锁:不止 synchronized,关键在锁的边界

2.1 synchronized 的常见写法与锁升级

synchronized 是 JVM 原生支持的悲观锁实现,使用简单,不需要手动释放。很多资料都会讲它有一个锁升级过程:偏向锁、轻量级锁、重量级锁,JVM 会根据竞争程度自动升级。这部分细节在不同 JDK 版本上有调整,背题时可以了解,落地时不要依赖某个版本的锁行为。

由此产生一个误解:很多人以为 synchronized 天生很慢。实际上在低竞争场景下,JVM 会做大量优化,未必会膨胀到重量级锁。反过来,在高竞争场景下,重量级锁会让线程阻塞唤醒,性能会明显下降。所以评估 synchronized 性能不能简单说“快”或“慢”,要看竞争烈度。

这里有一个更实际的经验:很多人用 synchronized 时,习惯直接加在方法上。如果这个方法里只有几行内存计算,问题不大;如果方法里有数据库查询、远程调用,锁的整体代价就要重新评估。锁粒度不是越小越好,但“一个方法整体加锁”通常是过度设计。

2.2 ReentrantLock:可超时、可中断、可公平

ReentrantLock 是 java.util.concurrent 包提供的悲观锁,比 synchronized 更灵活:

  • lock()与unlock()成对出现,必须手动释放。
  • tryLock(timeout, unit)可以等待有限时间。
  • 可以响应中断。
  • 构造时可以选公平锁,但公平锁通常不是性能最优解。
Lock lock = new ReentrantLock(); if (lock.tryLock(3, TimeUnit.SECONDS)) { try { // 临界区代码 } finally { lock.unlock(); } } else { // 获取失败后的降级逻辑 }

这段代码的价值不是“换一个更高级的锁”,而是它给了你一个失败出口。悲观锁最怕的就是获取不到锁就一直等下去。tryLock 让调用方可以在有限时间内决定是重试、降级,还是直接报错。真实系统里,“拿不到锁怎么办”往往比“怎么拿到锁”更重要。

2.3 悲观锁真正烧钱的地方:持锁时间

数据库悲观锁的经典写法是:

SELECT * FROM stock WHERE sku_id = ? FOR UPDATE;

FOR UPDATE 会对命中的行加写锁,锁会一直持有到事务提交或回滚。这里要特别注意:这个锁是数据库层的行锁,不是 Java 层的对象锁。多实例部署时它能正常工作,前提是大家都操作同一张表、同一行数据,而且走的是同一个数据库。还要留意,如果查询没有走索引,数据库有可能从行锁退化成更粗粒度的锁,影响范围会突然变大。

最需要警惕的是:事务里往往不只是“查一行、改一行”。一个事务如果包含多个查询、外部接口调用、甚至网络请求,锁的持有时间会被拉得很长。锁等待链一长,数据库连接池就会被占满,最后表现为整个服务不可用。

注意:用悲观锁时,锁内只放必须的操作,锁外的内容越少越好。持锁时间,而不是锁本身,才是悲观锁真正的成本来源。

3. Java 里的乐观锁:CAS、版本号与 ABA

3.1 CAS 是怎么工作的

CAS 全称是 Compare And Swap,是一组 CPU 指令级的原子操作。它做的事情非常简单:比较内存里的当前值是否等于预期值,等于就更新为新值,不等于就返回失败。

Java 里最常用的是java.util.concurrent.atomic包下的类:

AtomicInteger stockCount = new AtomicInteger(100); while (true) { int current = stockCount.get(); if (current <= 0) { throw new BizException("库存不足"); } if (stockCount.compareAndSet(current, current - 1)) { break; } }

注意这个 while 循环。CAS 失败后必须重试,否则扣减动作就丢失了。这个循环在低冲突率下几乎一次通过,在高冲突率下会变成空转,CPU 占用上升。

这就是乐观锁的典型代价模型:平时几乎无锁开销,一旦冲突发生,调用方要自己承担重试成本。它不是没有成本,而是把成本从“冲突前”挪到了“冲突后”。

3.2 从 AtomicInteger 到数据库版本号

单机内存里可以用 AtomicInteger,但多实例部署时,Java 堆内的原子变量无法跨进程生效。这时数据库的版本号机制就很有用:

UPDATE stock SET count = count - 1, version = version + 1 WHERE sku_id = ? AND version = 5;

如果影响行数为 1,说明更新成功;如果影响行数为 0,说明 version 已经不是 5,说明别人抢先改过了。

为什么用 version 而不用 time 或 count?因为版本号只做一件事:判断这个数据是否发生过变化。它不需要可读,不需要精确到毫秒,只要保证“每次修改都会 +1”。时间戳可能出现同一毫秒内两次修改,count 作为新旧值比较时也可能被业务数字干扰,都不如单调递增的 version 语义干净。

在 Spring 生态里,MyBatis-Plus 通过 @Version 注解和乐观锁插件做这件事,JPA 也有 @Version。这些框架只是帮你生成带版本条件的 UPDATE 语句,核心原理没有变。不管框架多方便,你都要清楚:没有自带的重试机制,数据库也不会帮你重试。

3.3 ABA 问题为什么要用版本号而不是时间戳

CAS 有一个著名的 ABA 问题:线程 A 读到值是 1,线程 B 把它改成 2,又改回 1,线程 A 再 CAS 时发现还是 1,于是判定“没有人改过”。实际上数据已经被改过两次。

解决思路不是用“当前值”做比较,而是用“带变化次数的值”做比较。AtomicStampedReference 就是把值和版本号打包在一起。数据库里的 version 字段同理,它的核心价值是提供一个“值相同但已发生过变化”的识别器。

所以在面试里谈到乐观锁时,如果能主动提到 ABA 问题,会明显比只说“版本号防止超卖”更有区分度。它不是面试官想要的标准答案,而是区分记忆型选手和理解型选手的分界线。

4. 悲观锁 vs 乐观锁:差异不是快慢,而是失败代价模型

4.1 一张表看清八个维度

对比维度悲观锁乐观锁
核心假设冲突很可能发生冲突是少数
加锁时机操作前先加锁写入时检查冲突
冲突处理等待对方释放重试或放弃
典型 Java 实现synchronized、ReentrantLockAtomicInteger、AtomicStampedReference
数据库实现SELECT ... FOR UPDATEversion 条件更新
冲突前代价加锁、阻塞、唤醒几乎为零
冲突后代价排队等待,可能死锁重试,浪费一次操作
更适用的场景写多读少、高冲突读多写少、低冲突

这张表里最有价值的不是前几行,而是最后两行:冲突前代价和冲突后代价。

4.2 冲突前代价 vs 冲突后代价

可以把这两种策略想象成两种过闸机的方式。悲观锁是“一个人进去,闸机就锁上,后面所有人都排队,等他出来再放行”。乐观锁是“所有人都直接进,每个人进的时候刷一下卡,如果闸机提示刚才已经有人进过,就退回去再排一次”。

排队等待是冲突前的代价,退回去重来是冲突后的代价。

当冲突概率很低时,悲观锁会为“几乎不会发生的冲突”持续支付加锁、阻塞、唤醒的代价;乐观锁在“几乎不会发生的冲突”上则几乎不花钱。

当冲突概率变高时,乐观锁的重试次数会急剧上升,大量请求会把数据库 IO 和 CPU 烧在“反复尝试”上;悲观锁虽然也差,但锁机制本身有排队语义,等待是可控的,不会像自旋一样空转。

所以,真正重要的不是“哪个快”,而是“哪个代价模型更符合你的业务”。这也是面试官想听到的东西。

4.3 一句面试点评最加分的话

面试里说“乐观锁性能好、悲观锁性能差”是一个危险的结论。更好的说法是:

“我理解悲观锁和乐观锁的本质是两种冲突处理策略。悲观锁在冲突前就支付加锁成本,适合高冲突、临界区复杂、不能容忍重试的业务;乐观锁把成本放到冲突后,适合低冲突、临界区短、写入路径可控的业务。在库存扣减这种高竞争场景,我会优先评估数据库行锁加短事务;如果热点特别集中,再考虑排队或分桶。”

这句话之所以加分,是因为它表达的是选型逻辑,而不只是背诵结论。

5. 场景题实战:库存扣减、订单状态和 Spring 事务

5.1 库存扣减:为什么乐观锁方案要带重试

库存扣减是面试最高频的场景题。它真正难的地方不是锁本身,而是:扣减动作必须原子完成,不能超卖,同时不能把整个表的锁都拖住。

悲观锁方案是:

BEGIN; SELECT * FROM stock WHERE sku_id = ? FOR UPDATE; -- 业务校验,比如 stock.count >= 1 UPDATE stock SET count = count - 1 WHERE sku_id = ?; COMMIT;

乐观锁方案是:

UPDATE stock SET count = count - 1, version = version + 1 WHERE sku_id = ? AND version = ?; -- 影响行数为 0 则重试

乐观锁方案看似简单,但有一个隐藏要求:调用方必须有重试机制。数据库不会帮你重试。如果更新影响行数为 0,不能直接把失败抛给用户,要在业务层重新查一次、再试一次,并且限制最大重试次数。

重试次数设置得太高,冲突高的时候每个请求会打很多次数据库;设置得太低,用户会频繁看到失败。实际工程里需要结合压测找出一个平衡值。通常我会建议先给一个保守上限,比如 3 到 5 次,再根据日志里的“更新影响行数为 0”的次数做调整。

5.2 Spring 里的事务边界决定了锁的生命周期

在 Spring 的 @Transactional 方法里,锁的生命周期和事务边界绑定。悲观锁尤其明显:FOR UPDATE 取得的行锁要等事务提交才释放。

这里有一个典型坑:在事务里先调用远程接口,再做扣减。这会让行锁一直挂着等远程接口返回。远程接口一旦变慢,数据库连接和行锁都会被长时间占用,很快把连接池耗尽。

正确做法是:远程调用放在事务外,或者先做本地预校验,真正扣减时再进短事务,把事务和锁的持续时间压到最短。

特别注意:事务边界决定锁生命周期,锁生命周期决定并发上限。在 Spring 场景里,关注事务边界往往比纠结“用悲观锁还是乐观锁”更关键。

5.3 分布式环境下,锁都是有边界的

悲观锁的 Java 实现,比如 synchronized 和 ReentrantLock,只在单 JVM 内有效;多实例部署时必须换成数据库行锁、Redis 分布式锁或 Zookeeper 锁。乐观锁因为不依赖 JVM 堆内存,多实例下反而更容易迁移,只要共享存储上的版本字段语义保持一致。

但分布式锁还有一个确定性问题:锁超时。如果持有锁的线程在锁过期之后才完成操作,其他线程就会进入临界区重复执行。这属于“锁的边界小于操作时间”。

所以,真正工程化的锁方案要回答三个问题:锁的获取是否原子、锁的释放是否可靠、锁的超时是否覆盖操作的最坏时间。这三个问题只要有一个答不上来,方案就还不能上线。

6. 从背八股到答好场景题:一套可复用的选型框架

6.1 四步选型法

我在实际项目里总结过一套选型流程,核心是四步:

第一步,估算冲突概率。同一个数据被并发修改的频率有多高?秒杀场景的同一个 SKU 就是高冲突;普通评论点赞就是低冲突。

第二步,评估临界区成本。操作是一句 UPDATE 就能完成,还是包含查询、校验、计算、远程调用?临界区越贵,悲观锁的持有代价越大。

第三步,设计失败路径。乐观锁失败后能不能重试?重试一次要付出多少数据库开销?悲观锁拿不到锁时,是排队等待、有限等待,还是快速失败?

第四步,做压测回归。别只测正常路径。要模拟冲突高峰、锁等待超时、连接池耗尽、重试风暴这些异常路径。只有异常路径能扛住,方案才算落地。

这套流程不复杂,但它能把一个“八股题”变成一个“工程决策题”。面试和线上问题排查都用得上。

6.2 问题排查链路

如果在线上遇到锁相关的问题,建议按下面的顺序排查:

  1. 先看现象:是线程阻塞、接口超时、TPS 下降,还是数据被覆盖、更新丢失、超卖?
  2. 再确认临界区:锁保护的代码范围对不对?锁内是否包含网络请求、长 SQL 或者不必要的计算?
  3. 再确认锁的生命周期:事务是否正常提交?异常时锁是否被正确释放?连接是否归还到连接池?
  4. 再确认并发模型:请求量多大?冲突概率多高?是否存在热点 key 或热点行?多实例是不是各锁各的?
  5. 最后看实现边界:用的是 JVM 锁还是数据库锁?有没有锁超时?有没有重试机制?ABA 有没有被处理?

这个链路的核心思想是:先搞清楚是哪一层出了问题,再决定改哪一层,而不是一上来就换锁。很多人一遇到性能问题就怪锁选错了,实际上问题常常出在事务边界、锁粒度或者连接池配置上。

6.3 面试回答的黄金结构

最后,回到最开始的问题:如果面试官问你悲观锁和乐观锁,怎么答才完整?

我会建议按这个顺序组织答案:

  1. 一句话定义:悲观锁认为冲突很可能发生,所以操作前先加锁;乐观锁认为冲突是少数,所以操作完再校验。
  2. 说实现:Java 里 synchronized、ReentrantLock、数据库 FOR UPDATE 是悲观锁;AtomicInteger、CAS、版本号更新是乐观锁。
  3. 说关键区别:冲突前代价和冲突后代价不同;悲观锁适合高冲突、临界区复杂;乐观锁适合低冲突、临界区短。
  4. 说一个场景题:库存扣减怎么选、重试怎么做、事务边界怎么控。
  5. 说边界:单机锁、数据库锁、分布式锁的适用范围完全不同;没有最优的锁,只有最匹配的代价模型。

这套结构的好处是:面试官无论往哪个方向追问,你都有下一层可以展开。它不是一个标准答案,而是一个能证明你“真的理解并发”的思考路径。

悲观锁和乐观锁从来不是一道背诵题。它真正考察的是,你在面对并发冲突时,能不能先判断代价,再决定策略。把这套代价模型放进脑子里,下一次再听到“锁”这个字,你会多一层理解。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询