☰
从库存超卖到分布式锁:乐观锁与悲观锁的实战选型
2026/10/12 18:29:38 网站建设 项目流程

1. 锁的分类坐标系:在选锁之前先搞清楚几件事

几年前我在上线前突然撞上一个现实问题:商品详情页显示库存剩5件,用户下单买了3件,支付成功之后后台查库存却还是5件,下单通道继续开着,后台一会儿就欠了20多件货。排查到最后,两个服务节点各自执行了一遍“先查库存,再扣减”的逻辑,中间没有加任何互斥,两条线程都把对方刚扣完的数据当成了初始值。那是我第一次深刻意识到,乐观锁、悲观锁、局部锁这些概念不该只躺在面试题里,它们是真能在线上救命的。

先说一个很多人没想透的结论:乐观锁和悲观锁是一对,局部锁跟它俩不是同一维度上的分类。乐观锁与悲观锁回答的是“冲突发生时,我是提前防守还是最后补救”;局部锁回答的是“这把锁的作用范围到底在哪”。很多人把三者摆在一张表里横向比较,其实是拿两种坐标系去套同一个问题,越比越别扭。

1.1 并发冲突的源头:从一次库存覆盖事故讲起

要理解锁,先看两个线程同时执行一次“读-改-写”。线程A读出库存5,线程B也读出库存5,A把库存改成2,B不知道A改过,又把库存改成2,一次扣减变成了两次扣减却只减少了一次库存,业务上直接表现就是超卖。这也是所有并发控制都要解决的根源问题:读写交错且缺乏可见性约束。

在数据库里,它对应的是脏写、丢失更新、不可重复读一类问题;在JVM里,它对应的是共享变量的内存可见性和原子性冲突。锁要做的不是消灭并发,而是把“读-改-写”这个不可分割的语义重新拼回去,让你在一步操作完成之前,别人插不进脚。说得再直白一点:锁的本质就是给临界区画一条界线,让所有想踩进这块区域的线程排好队,或者让它们发现“你踩过了”之后自己退回重来。

1.2 三个维度看懂锁

我平时分析任何锁,只看三个维度。

  • 互斥方式:冲突用阻塞排队解决,还是用版本校验解决。前者是悲观锁,后者是乐观锁。
  • 作用范围:锁住的是一个方法、一个对象、一张表、一行数据,还是一个独立进程内的共享资源。局部锁讨论的就是这一层。
  • 资源形态:锁落在数据库行上,落在JVM对象头上,还是落在某个集中式存储的key上。这决定了分布式锁为什么必须单独拿出来讲。

举个例子:synchronized是悲观锁的一种典型,它活在JVM里;SELECT ... FOR UPDATE也是悲观锁,它活在数据库里。两者底层机制完全不同,但哲学思想一致。所以你在面试中被问到“乐观锁和悲观锁的区别”时,先要明白它们是一对“策略”,不是一对“API”。具体到实现时,才有行锁、表锁、对象锁、分布式锁这些分支。

数据库里的锁粒度也要专门补一句:MySQL InnoDB 除了行锁,还有表锁、意向锁、间隙锁。间隙锁是很多人忽略的元凶,它在可重复读隔离级别下为了防止幻读,不但锁住命中行,还会锁住索引之间的“空隙”。曾经有个同事在订单区间查询时发现并发插入老是被阻塞,查了半天才定位到间隙锁。所以当你以为自己在用乐观锁时,数据库底层可能已经默默给你上了一把悲观锁——事务里的每个读都有可能在特定条件下产生锁。

2. 先锁后改——悲观锁为什么在高冲突下保命

2.1 数据库悲观锁的完整链路

悲观锁的核心假设很直白:在我处理这条数据期间,一定有人也在处理它,所以我要在动手之前先把路占住。

数据库里的悲观锁长这样:

BEGIN; SELECT * FROM inventory WHERE sku_id = 1001 FOR UPDATE; -- 业务代码判断:库存充足,执行扣减 UPDATE inventory SET stock = stock - 1 WHERE sku_id = 1001; COMMIT;

FOR UPDATE会让数据库对sku_id = 1001这行记录加排他锁。从这条SELECT开始,到事务COMMIT为止,其他想对这行加锁的会话全部卡在锁等待中。注意,这个场景必须在事务里执行,而且事务要尽快提交,否则锁持有时间会拉得很长。

排他锁由数据库管理,你怎么判断锁到底生效没有?打开information_schema.INNODB_TRX和sys.innodb_lock_waits,能看到当前活跃事务和锁等待关系。线上排查锁问题基本靠这两张视图,比在代码里加日志靠谱得多。

2.2 悲观锁为什么能覆盖整段业务判断

常见质疑是:SELECT FOR UPDATE和 UPDATE 之间隔了好几步业务判断,锁不会断掉吗?

不会。数据库的锁归属到事务,事务不COMMIT,锁就不释放。也就是说,从锁定那一刻开始,后续所有步骤都被“包”在锁的遮蔽范围里。这是悲观锁最舒服的用法:用一行锁换一段完整的临界区。你在代码里写if (stock > 0)判断库存,这个判断与最后的扣减操作之间,所有并发请求都不可能插进来,因为整个事务就像一个屏障。

从这里还能得出一个选型启发:如果你的业务逻辑特别长,中间涉及多次查询、远程调用、人工审核,悲观锁绝对要慎用。因为长事务意味着长持有锁,后面排队的线程等到超时继而报错。严格来说,悲观锁更适合“短、平、快”的临界区,比如余额扣减、库存修正、状态流转。

2.3 悲观锁的代价:阻塞、超时与死锁

悲观锁最大的代价是阻塞。一个事务持有锁的时间是50ms,后面排了10个事务,即使它们操作的数据和当前业务完全不同,只要落在同一行上,就得排队。

阻塞还会引发两个连锁问题。

  • 锁等待超时:MySQL 默认innodb_lock_wait_timeout是50秒,并发一高很容易触发超时回滚。如果业务里还有重试逻辑,超时后的重试又会冲击数据库,形成恶性循环。
  • 死锁:事务A锁了行1要等行2,事务B锁了行2要等行1,两边互不相让。InnoDB 会定期检测死锁,发现后强制回滚其中一个事务。你的代码如果不捕获死锁异常,用户就会看到一条莫名其妙的报错。

另一个容易被忽略的点是索引。FOR UPDATE如果没有走索引,InnoDB 只能锁表或锁住更大的范围。我遇到过where条件字段忘建索引的情况,并发一上来,所有下单请求串行执行,接口TPS从800直接掉到20。索引决定了锁的精度,这句话建议贴在监控看板上。

2.4 JVM 里同样悲观的 synchronized 与 Lock

数据库之外,单机应用内的共享变量修改也会走悲观路线。synchronized自动加锁、自动释放,即使方法抛异常锁也能归还。ReentrantLock多了超时、响应中断等能力,适合对锁获取时间有硬指标的代码。

我在项目里见过最典型的悲观锁误用是:把锁加在方法入口,而方法内部80%的时间都在做纯计算,根本不碰共享资源。这等于让所有线程在一个计算密集区域外排队,毫无意义。悲观锁的持有范围必须尽量小,锁完马上释放,别让它陪着你走完整个业务流程。

3. 不锁资源、锁版本——乐观锁的实现原理与隐藏代价

3.1 版本号机制:一条 UPDATE 解决超卖

乐观锁的经典做法是在表上增加一个version字段,每次更新前先读出当前版本,更新时把版本号当条件:

UPDATE inventory SET stock = stock - 1, version = version + 1 WHERE sku_id = 1001 AND version = 5;

如果影响行数大于0,说明更新成功,因为版本没被别人动过;影响行数等于0,说明版本已经被改了,你需要重新查询当前值再走一遍流程。这是“乐观”的完整表达:先放手去干,失败了再回头。

相比数据库悲观锁,版本号方案不需要事务包住整段时间,更新和校验由这一条UPDATE语句原子完成。并发量大的时候,它不会因为锁排队而阻塞。这也是为什么高并发扣库存场景大多选乐观锁的重要原因。我实际压测过一个性能对比:相同硬件上,悲观锁方案在100并发时TPS约400,乐观锁方案能到1500左右,主要差距就来自省去了大量阻塞等待。

3.2 CAS 与自旋:JVM 层面的乐观锁

数据库用版本号,JVM 里用的是 CAS(Compare And Swap)。比如AtomicInteger调compareAndSet(expect, update),底层执行一条CPU指令,比较和交换在硬件层面是原子的。

自旋结构长这样:

while (true) { int current = atomic.get(); int next = current + 1; if (atomic.compareAndSet(current, next)) { break; } }

冲突时就循环重新读值,直到CAS成功。这个“重试再试”的结构,和数据库版本号机制的上层逻辑是一模一样的,只是一个在CPU指令级,一个在SQL语句级。在低冲突下,CAS几乎零开销;在高冲突下,大量线程在死循环里空转,CPU会被顶到很高。

3.3 乐观锁的三处翻车现场

乐观锁不是免费的,下面三个坑我都在线上踩过。

第一个是ABA问题。线程A读到值5,线程B把值改成6又改回5,线程A再次CAS时发现版本还是5,就误以为过程中没人动过。数据库版本号字段因为自增不会回退,所以不容易出问题;JVM的CAS如果拿对象引用做比较,就需要额外加版本标记,比如AtomicStampedReference。

第二个是重试代价被低估。冲突率10%时,大部分请求第一次就能成功,重试开销很小;冲突率达到30%以上,大量失败的UPDATE会持续消耗数据库CPU和IO,整体性能反而断崖式下跌。乐观锁真的不适合高冲突场景,冲突到了一定阈值,还不如直接悲观锁把请求串行化。

第三个是版本条件被业务条件干扰。有一次我在订单表上写UPDATE ... WHERE id = ? AND user_id = ? AND version = ?,本意是多条件校验,结果因为某些历史数据的user_id字段被迁移脚本改过,导致该更新的行更新不了,兜底逻辑直接炸掉。版本条件的where里只保留主键和版本号,业务校验放到代码层去做。

4. 乐观锁 vs 悲观锁:同一道题的两个解法

4.1 完整对比表

维度悲观锁乐观锁
加锁时机操作前先加锁操作时不加锁,更新时校验
冲突处理阻塞等待重试或放弃
适用冲突率高冲突低冲突
响应时间可能被阻塞拖长通常稳定
事务要求必须包在事务里单条UPDATE即可完成
实现复杂度依赖数据库行锁要自行处理重试逻辑
读多写少可能拖累读并发更适合
写冲突严重天然排队大量重试
对长业务宽容度低,锁持有时长难控高,无锁窗口
死锁概率较高极低
强一致体验好需要补偿或重试

4.2 选型逻辑:冲突概率是唯一判断依据

核心判断标准只有一个:冲突概率乘以操作成本,哪个解法更划算。

如果冲突率低,乐观锁最合适,因为省去了排队开销;如果冲突率高到重试概率超过50%,悲观锁的排队反而更可预测。还要看操作失败的成本:更新失败后重新读取一次再试,成本很低,可以用乐观锁;如果失败之后要把整个业务流程重走一遍,那最好别赌运气,直接悲观锁。

实际工作中还要看数据库负载。乐观锁表面不产生锁等待,但大量失败的UPDATE依然会计入数据库资源消耗,在高压力下,这比等锁更难受。一次大促后我对比过慢查询日志,发现失败UPDATE虽然只有0.1%的业务占比,却占掉了主库近10%的IO,因为它的执行计划要走一遍主键索引加版本字段判断。

4.3 混合使用:一套积分系统的双锁方案

我维护过一套积分系统:账户余额用悲观锁,因为钱不能允许任何重试式补偿,余额一致性要求极高;积分明细表用乐观锁,因为明细并发写入量大,且同一用户短期内的并发写入很少。两条线用同一账户ID串联起来,但锁的方案完全不一样。

这种混合方案值得参考。不要把“用哪个锁”看成非此即彼,而要看业务对“不可容忍的瞬间不一致”到底有多敏感。余额错了用户立刻投诉,明细少一条用户往往感知不到,那我就让前者更保守,后者更激进。

5. 局部锁:常被忽略却最容易踩坑的一类锁

5.1 局部锁到底指什么

“局部锁”不是一个有规范定义的术语,但在Java面试和实战代码里经常出现。它通常指:锁对象和锁的作用域只存在于某个局部范围,例如一个方法体、一个代码块、一个局部变量。

先看一段典型代码:

public void increase() { Object lock = new Object(); // 局部锁对象 synchronized (lock) { count++; } }

如果increase()同时被多个线程调用,每个线程进来时创建的lock都是新对象,大家锁的完全不是同一个东西,所以这段代码没有任何互斥效果。这是最经典的“局部锁无效”案例。很多新手写过,我早期也写过,线上监控看起来线程不少,但锁就像不存在一样,问题照旧复现。

那局部锁是不是意味着不能用?不是。关键在于:多线程协作时,必须共享同一个锁对象实例;如果锁对象是局部创建且不被外部共享,它只能保护单线程上下文里的临界区,多线程下就是空锁。

5.2 锁的对象无关,关键在共享

我复盘线上问题的时候发现,很多人把“局部”理解成“粒度更小的锁”,其实不是。局部锁描述的是锁对象的可见性和生命周期,而不是锁的类型。乐观锁与悲观锁的区别在“加不加锁”,局部锁的区别在“锁放哪、谁看得见”。

想让变量被多个线程安全访问,做法不是new一个局部锁对象,而是要把锁对象提升到所有参与者都能看到的位置——实例字段、静态字段、外部方法参数传入。锁对象的局部性只能决定它何时被回收,不能决定它能否完成互斥。

如果使用场景确实只在单线程内流转,那更推荐的做法是线程封闭:让可变数据只存在于线程私有的栈帧中,不逃逸到外部。此时你压根不需要锁。Java 的锁消除优化也与此有关,JIT 在分析出对象不会逃逸时,会把不必要的加锁代码直接擦掉,这也是为什么局部的synchronized (lock)可能没有实际代价,但也没有实际意义。

5.3 ThreadLocal 到底算不算锁

ThreadLocal 经常被拿来跟“局部”放在一起讨论,但它本质上是线程隔离,不是锁。它确实让每个线程拥有独立的变量副本,从而绕过竞争,从“不用锁”的角度解决冲突,这跟乐观锁“少锁”的思想有些像,但和互斥无关。多个线程如果各自操作自己的副本,根本不存在竞争,自然不需要锁;一旦你需要让多个线程看到同一个值,ThreadLocal 就失效了。

所以我面试时经常说:ThreadLocal 的价值是降低对锁的需求,它不是一种锁。局部锁如果指“每个线程各自持有一把锁”,那这把锁只能保护各自的私有空间,跨线程同步等于零。

5.4 局部锁真正有用的两种场景

  • 局部代码块里只涉及受控的单一线程访问,局部锁对象作为轻量级的操作护栏,用来防代码以后被其他人误改成多线程。
  • 严格控制锁的持有时长时,把锁放在方法最里层,让锁对象的引用局部化:用了马上释放,出了作用域锁就自动归还。

数据库里的行锁也是“局部化”思想的体现。它锁的是单行而不是整张表,就是把加锁范围局部化,减少对无关数据的影响。锁的范围越小,冲突越少;锁的范围越大,安全性越高,但并发能力越低。这和“局部锁”理解成加锁粒度是相通的。

6. 其他锁类型盘点:每个都有不可替代的用武之地

6.1 JVM 锁三件套的边界

ReentrantLock比synchronized多出lockInterruptibly()、tryLock(timeout)、任意位置加锁/解锁,还能指定公平策略。可惜它需要手动管理解锁,一旦忘记释放就是硬bug。我的经验是:能用synchronized就用synchronized,只有确实需要超时抢锁或中断响应时才换ReentrantLock。

ReadWriteLock的价值在读写分离:读读可以并行,读写互斥,写写互斥。适合读多写少的场景。有个常见的坑是锁升级:持有读锁时再去拿写锁,很容易死锁,正确做法是先释放读锁再获取写锁。另一个坑是写锁饥饿:如果读线程太多,写线程可能一直等不到锁,第三方实现里往往有公平模式可以缓解。

StampedLock是 JDK 8 加入的一种乐观读锁,读的时候不真正加锁,写完再校验版本戳,类似乐观锁的思路。它的tryOptimisticRead返回一个版本戳,业务逻辑执行完后再调用validate检查是否被写操作打断。性能很亮眼,但API设计上容易写错,我建议常规项目别碰,除非性能瓶颈已经被定位到锁竞争上了。

6.2 公平锁与非公平锁

锁按获取顺序可以分成公平锁和非公平锁。公平锁严格排队,避免饥饿;非公平锁允许新线程直接抢锁,性能更高,但可能让排队等待的线程饿死。JVM 里synchronized是非公平的;ReentrantLock可以构造公平版本。

我实际使用中很少要求绝对公平,除非业务对处理先后顺序有硬性要求。非公平锁的吞吐量通常更好,这也是为什么默认都是非公平。

自旋锁也值得一提,它是乐观锁思想在锁实现层面的体现:线程不立刻阻塞,而是反复循环检测锁状态。优点是避免了线程切换开销,缺点是CPU空转。Java 的AbstractQueuedSynchronizer内部就大量使用了自旋逻辑,但自旋次数必须控制,否则高冲突下CPU直接被烧满。

6.3 分布式锁:跨进程之后没人能独善其身

应用服务拆成多个节点后,JVM 本地的锁无法跨进程互斥,分布式锁才登场。它要解决的,是多个独立服务节点之间的统一互斥。

常见实现有三种。

  • 基于Redis加锁:用SET key value NX PX timeout加锁,释放前先校验value是否是自己的,防止释放掉别人的锁,再用Lua脚本保证“判断+删除”原子操作。
  • 基于数据库唯一索引:插入一条唯一索引记录代表获得锁,删除记录代表释放,简单可靠但性能和可用性有限。
  • 基于ZooKeeper / etcd:用临时顺序节点加监听机制,节点崩溃后锁会自动清理,避免死锁。

有一个原则我在团队里反复强调:分布式锁不是银弹。跨节点通信延迟、时钟漂移、GC停顿,都可能让锁持有时间被意外拉长。Redis锁最常见的坑是忘了设过期时间,一个节点崩溃后锁一直不释放,其他节点全部卡死。后来所有加锁操作强制带PX超时,并且在释放时校验value。如果业务能接受终态一致性,优先用版本号、幂等键这类方案替代强互斥。

7. 实战选型与经验总结

7.1 直接抄作业的选型决策表

业务场景首选方案理由
单JVM内并发修改共享变量synchronized / ReentrantLock实现成本最低
单机库存扣减乐观锁版本号无锁阻塞,吞吐量高
高一致性转账数据库悲观锁避免重试补偿
读多写少缓存刷新ReadWriteLock读写分离
多实例互斥Redis + Lua 分布式锁跨进程可见
单线程数据隔离ThreadLocal根本不用锁

选型的核心顺序是:先定作用范围(单JVM还是跨进程),再看冲突概率,最后看业务对短暂不一致的容忍度。顺序反了很容易选错。

7.2 我踩过的几个坑

第一,给库存扣减加版本号时,把version = version + 1写进UPDATE里,表面没问题,可历史数据里有一部分是迁移脚本拷过来的,版本号重复,导致某些行更新失败。后来把版本条件单独放,并给订单号加了唯一约束才彻底解决。

第二,MySQL 的FOR UPDATE没走索引,某次大促时一条慢SQL拖垮整个数据库连接池。那次事故后我给自己立了个规矩:凡是涉及锁的SQL,先看执行计划,确认type走的是range或ref,而不是ALL。

第三,Redis分布式锁忘记设置过期时间,一个节点崩溃后锁一直不释放,所有其他节点卡死。更糟的是的主节点恰好做了锁续期逻辑,进一步拉长了持有时间。之后的规范是:加锁带超时,释放校验身份,续期必须配置监控告警。

第四,局部锁踩坑,也就是开头说的案例:多线程下synchronized(new Object())等于没锁。排查时监控显示线程不少,但问题就是复现。从那之后我在Code Review里加了一条硬性检查:锁对象必须是共享字段或者方法参数传入,拒绝局部new出来的对象直接进synchronized。

第五,乐观锁重试风暴。某个活动的领券接口用了CAS自旋退避,冲突率高时线程全在循环重试,把CPU打满。后来加了最大重试次数,超过就直接失败让客户端稍后再来,系统反而稳了。

7.3 一句话收尾

锁这回事,核心不是“哪个锁更高级”,而是“冲突发生在哪里,哪个锁能精确覆盖它”。乐观锁、悲观锁、局部锁、分布式锁,它们是同一道题的四个解法,不存在绝对优劣。关键是把锁的范围、持有时长、冲突概率三个参数摸清楚,让每个锁落在它该落的位置上。我在实际项目里最深的体会是:写业务代码的人往往不关心锁,但系统出问题的时候,第一个背锅的又往往是锁。与其等事故教做人,不如在设计阶段就多问自己一句——这里真的需要锁吗?如果有,是乐观的还是悲观的,作用范围到底到哪一层?

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

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

立即咨询