ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

悲观锁与乐观锁:并发控制策略与库存扣减选型实战

悲观锁与乐观锁:并发控制策略与库存扣减选型实战 如果你是 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、版本号与 ABA3.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 问题排查链路如果在线上遇到锁相关的问题建议按下面的顺序排查先看现象是线程阻塞、接口超时、TPS 下降还是数据被覆盖、更新丢失、超卖再确认临界区锁保护的代码范围对不对锁内是否包含网络请求、长 SQL 或者不必要的计算再确认锁的生命周期事务是否正常提交异常时锁是否被正确释放连接是否归还到连接池再确认并发模型请求量多大冲突概率多高是否存在热点 key 或热点行多实例是不是各锁各的最后看实现边界用的是 JVM 锁还是数据库锁有没有锁超时有没有重试机制ABA 有没有被处理这个链路的核心思想是先搞清楚是哪一层出了问题再决定改哪一层而不是一上来就换锁。很多人一遇到性能问题就怪锁选错了实际上问题常常出在事务边界、锁粒度或者连接池配置上。6.3 面试回答的黄金结构最后回到最开始的问题如果面试官问你悲观锁和乐观锁怎么答才完整我会建议按这个顺序组织答案一句话定义悲观锁认为冲突很可能发生所以操作前先加锁乐观锁认为冲突是少数所以操作完再校验。说实现Java 里 synchronized、ReentrantLock、数据库 FOR UPDATE 是悲观锁AtomicInteger、CAS、版本号更新是乐观锁。说关键区别冲突前代价和冲突后代价不同悲观锁适合高冲突、临界区复杂乐观锁适合低冲突、临界区短。说一个场景题库存扣减怎么选、重试怎么做、事务边界怎么控。说边界单机锁、数据库锁、分布式锁的适用范围完全不同没有最优的锁只有最匹配的代价模型。这套结构的好处是面试官无论往哪个方向追问你都有下一层可以展开。它不是一个标准答案而是一个能证明你“真的理解并发”的思考路径。悲观锁和乐观锁从来不是一道背诵题。它真正考察的是你在面对并发冲突时能不能先判断代价再决定策略。把这套代价模型放进脑子里下一次再听到“锁”这个字你会多一层理解。
返回列表