☰
MySQL乐观锁与悲观锁:从超卖问题到并发库存扣减实战
2026/10/20 15:07:14 网站建设 项目流程

做后端开发这几年,几乎每个人都会在某一天突然撞上同一个问题:库存明明还剩 1 件,线上却卖出去 3 单;余额明明只有 100 块,两个请求各自加钱之后,最终结果却不是预期值。这些现象的根源,都是同一行数据被多个事务并发修改,MySQL 的乐观锁与悲观锁,就是解决这类问题的两条经典路线。

这篇文章我会用一次真实的商品扣库存场景做主线,把两种锁的实现原理、适用边界、实际坑点一次性讲透。适合正在做订单、库存、余额类系统的开发者,也适合准备面试时想把“锁”这件事真正搞懂的人。我会从每个人都写过的“先查询再判断再更新”代码片断讲起,慢慢延伸到死锁日志、版本号机制、MySQL 8.0 的进阶特性,全程用实践视角来拆解,不走教科书路线。

1. 从一次超卖说起:并发更新到底是怎么互相踩的

1.1 超卖复现的完整路径

先看一段非常典型、而且大概率你我都写过的代码:

// 伪代码:普通扣库存逻辑 Stock stock = stockMapper.selectById(100); if (stock.getStock() >= 1) { // 生成订单 orderService.createOrder(...); // 扣减库存 stockMapper.deduct(100, 1); // UPDATE sku SET stock = stock - 1 WHERE id = 100 }

库存表里只剩 1 件商品时,两个用户同时点击购买,会发生什么?

事务 A 执行查询,读到库存stock = 1,判断>= 1,通过。事务 B 几乎同时执行查询,也读到stock = 1,判断也通过。两个用户都生成了订单,然后各自执行扣减语句,数据库内部两条 UPDATE 会排队执行:第一条把库存从 1 减到 0,第二条继续基于最新值把 0 减成 -1。表面上数据库没有报错,但实际上这个商品被卖了两单。

问题出在哪?并不在 UPDATE 本身,而在你“先查询、再判断、再更新”这个结构上。查询和更新之间隔了一段业务代码,而这段代码执行期间,事务 B 完全不受事务 A 的影响。两个事务读到的都是 1,于是两个都认为自己可以扣,最终超卖。

单行 UPDATE 语句在 MySQL 里其实是安全的:UPDATE sku SET stock = stock - 1 WHERE id = 100这句本身会走当前读并加行锁,多个事务更新同一行时会排队执行。真正危险的是“读-判断-写”这种拆分模式——你把一个应当原子完成的动作拆成了两步,中间留出了巨大的时间窗。

1.2 快照读、当前读与丢失更新

要彻底理解这个时间窗,得先分清 MySQL 的两种读取方式:快照读和当前读。

普通SELECT在不加任何子句时,走的是快照读,也就是 MVCC 多版本控制。在默认的 REPEATABLE READ 隔离级别下,同一个事务内多次普通 SELECT 看到的是同一个快照。这个机制保证了普通读的一致性,但它有一个特点:不加锁、不阻塞,也不会感知到其他事务正在修改同一行数据。

SELECT ... FOR UPDATE、UPDATE、DELETE这些语句走的是当前读。当前读必须读取行的最新版本,并且在读取时对记录加锁,其他事务的写操作会被拦下。

那么超卖的本质,就是两个事务的快照读互相不干扰,但业务判断依赖的是这份快照,最终判断结果在新数据落地那一刻已经过期了。

另外还有一种典型的并发问题叫丢失更新,它比较容易和超卖混淆。假设一个账户余额是 100,两个事务同时读取,各自在应用层加上 100,算成 200,然后写回数据库。由于两个事务都写了同样的值 200,最终余额停留在 200,而不是正确的 300。这就是“读-改-写”模式下的更新丢失。如果你写的是UPDATE account SET balance = balance + 100 WHERE id = ?,则没有这个问题,因为这条语句是当前读,后一个事务会基于前一个事务提交后的值继续计算。

所以,锁存在的意义不是“保护 UPDATE”,而是“保护你从读到写的整段业务逻辑”。

2. 悲观锁路线:SELECT FOR UPDATE 的加锁语义与有效边界

2.1 拿到排他锁后,其他事务被塞在门口

悲观锁的核心思想很直接:我悲观的认为这个数据一定会被别人改,所以从读取那一刻起就把它锁住,直到我整个事务结束。

在 MySQL 中实现悲观锁,依靠的是SELECT ... FOR UPDATE,配合事务一起使用。标准写法如下:

BEGIN; SELECT stock FROM sku WHERE id = 100 FOR UPDATE; -- 业务判断:如果 stock >= 1,则继续 UPDATE sku SET stock = stock - 1 WHERE id = 100; COMMIT;

事务 A 执行SELECT ... FOR UPDATE后,会拿到 id = 100 这条记录上的排他锁。此时事务 B 执行同样的SELECT ... FOR UPDATE,会一直阻塞,直到事务 A 提交或回滚,锁被释放,事务 B 才能读到事务 A 更新后的最新库存。

从这个例子可以看出悲观锁几个关键特征:

  • 锁在事务内生效,COMMIT或ROLLBACK释放锁。
  • 锁是排他的,其他事务无论是更新还是加锁读,都得在门口排队。
  • 代码逻辑很简单:锁住了数据,业务判断随便做,不用担心中间被别人插脚。

我给这个机制打过一个比方:图书馆里只有一本参考资料,我进去之后直接把书抱在手里,其他人想翻只能等我放下。这个“抱在手里”的状态,就是悲观锁。

2.2 行锁其实加在索引记录上:为什么没索引会锁全表

理解悲观锁容易,但很多人在生产环境里第一个翻车点,是“我以为锁的是那一行,结果整个表都被锁了”。

InnoDB 的行锁并非抽象地锁在“某一行数据”上,而是锁在索引记录上。更准确地说,锁是加在通过索引定位到的记录上。当你执行SELECT ... WHERE id = 100 FOR UPDATE时,InnoDB 通过主键索引定位到 id=100 那条记录,并把对应索引记录锁住。

如果 WHERE 条件里的列上没有索引,InnoDB 就无法通过索引快速定位记录,只能扫描主键聚簇索引。这意味着它会逐条扫描记录,并在扫描过程中对每条记录都加锁。表面看是一条更新语句,实际效果可能是全表加锁——所有对这张表的写操作都会被阻塞。

所以用悲观锁时,第一步不是写 SQL,而是检查 WHERE 条件是否走了索引,最好是唯一索引或主键。联表、范围扫描、隐式类型转换导致索引失效,都会让“行锁”悄悄退化成近似表锁的状态。

另一个值得注意的边界是间隙锁。在 REPEATABLE READ 隔离级别下,不仅被命中的记录会加锁,索引记录之间的“空隙”也会被锁住,这是为了阻止其他事务在范围内插入新数据,防止幻读。比如执行SELECT * FROM t WHERE id BETWEEN 1 AND 10 FOR UPDATE,id=1 到 id=10 之间不存在的记录位置也会被锁。这个机制保护了数据一致性,但也带来一类非常隐蔽的死锁,后面我专门讲。

2.3 悲观锁适合什么场景,不适合什么场景

悲观锁最大的优点是强一致、实现直观、不需要重试逻辑。请求来了就是排队,等到了锁就继续执行,拿不到锁就等,业务代码里几乎不用关心冲突处理。

典型适用场景包括:

  • 账户余额扣减、转账等强一致性业务。
  • 短事务内完成读判断、更新,没有外部网络调用。
  • 同一行的写冲突相对频繁,采用排队策略比反复重试更可控。

不适合的场景也很明显:

  • 读多写少、冲突率很低的业务。为了偶尔一次写冲突,让所有读都被阻塞,代价过高。
  • 长事务场景。一个事务内如果有外部接口调用、耗时的业务逻辑,锁会一直持有,一个慢接口就能拖垮整个数据表的写吞吐。
  • 高并发下热点行集中访问。比如秒杀场景,所有请求都等同一把锁,锁等待队列越来越长,最终表现为接口超时堆积。

3. 乐观锁路线:版本号校验与条件更新的原子性密码

3.1 把判断和写入装进一条 UPDATE

悲观锁是“我先把门锁上,再慢慢做事”。乐观锁的思路正相反:我不锁门,我相信冲突很少发生,但在最后写数据那一刻,我会检验一下“自己读到的数据是不是最新的”,如果不是,这次操作就失败,重来。

MySQL 里实现乐观锁最经典的手段就是版本号字段。表结构里加一列 version,每次更新数据时同步把 version + 1:

-- 读取时:拿到旧版本号 SELECT stock, version FROM sku WHERE id = 100; -- 返回:stock = 1, version = 3 -- 更新时:带上版本号条件 UPDATE sku SET stock = stock - 1, version = version + 1 WHERE id = 100 AND version = 3;

如果这条 UPDATE 影响的行数是 1,说明更新成功;如果影响行数是 0,说明在这期间已经有别的事务把 version 从 3 改成了 4,你的判断条件不成立,本次操作失败,需要重新读取数据再做判断。

为什么这样能防超卖?因为在只有一个库存、版本号为 3 的情况下,事务 A 和事务 B 都读到了(stock=1, version=3)。事务 A 执行 UPDATE 成功后,版本号变成 4。事务 B 再拿 version=3 去更新,条件根本不匹配,UPDATE 影响行数为 0,事务 B 就知道自己抢失败了。

胜在没有任何阻塞,整个冲突检测被压缩进一条数据库语句里。当前事务不会堵住其他事务,所有人都能同时读、同时尝试写,最后靠版本号判断谁胜出。单条 UPDATE 在 MySQL 中是原子的,执行过程中天然带有行锁,可以确保不会出现“两个事务同时通过旧版本号检查、又同时更新”的情况。

3.2 版本号、时间戳与 CAS 条件的变体

版本号方案里,version 字段可以是整数,也可以换成时间戳、业务状态值、甚至校验字段。

用时间戳实现时,条件就是WHERE update_time = ?,更新时把 update_time 改成当前时间。但时间戳存在粒度问题,如果同一秒内有两次更新,后一次读到的时间戳可能与当前值相同,容易导致判断失效。生产环境我更推荐整数版本号,它单调递增、没有精度争议、还能顺便辅助排查数据变动历史。

还有一个值得强调的细节:版本号必须放在 UPDATE 的 WHERE 条件里,数据库在单条语句内部完成“比较版本号”和“更新数据”两个动作。如果你把版本号比较放到应用层,自己先查一次再判断,再执行更新,那就回到了前面说的时间窗问题,乐观锁的原子性完全丧失。

另外,有些场景下可以不用专门的 version 字段,而是利用唯一索引作为冲突检测。比如防重复下单,user_id 和 order_no 建联合唯一索引,同时插入两个相同订单,只有一个能成功。这种约束式并发控制在不少场景比加锁更优雅。

3.3 失败后的重试与补偿设计

乐观锁冲突发生后,业务不能就这么干瞪着。常见处理方式是重试:重新读取最新版本号,再次尝试更新。

重试策略里需要注意的点不少:

  • 重试次数要封顶,我一般设 3 到 5 次,超过后直接返回“系统繁忙”或提示用户稍后重试。
  • 重试间隔需要错开,避免所有失败请求在同一时刻打回去。固定间隔会导致重试请求集中爆发,指数退避加随机抖动会更稳。
  • 如果操作本身涉及订单创建、通知发送等副作用,重试逻辑必须幂等。否则第二次尝试可能重复生成订单。
  • 注意写放大问题。乐观锁冲突率高时,每次失败都带来一次新的 UPDATE,数据库压力会成倍增加。用乐观锁前,最好先评估同一行数据的写竞争强度。

归根结底,乐观锁不是“免费的并发方案”,它把失败的处理责任从数据库转嫁到了应用层。应用层得有重试、报错、降级的完整预案。

4. 选型坐标系:业务冲突率决定锁的姿势

4.1 两维判断:写冲突率、事务时长与阻塞容忍度

选乐观锁还是悲观锁,网上有很多标准答案,但落到实际业务里,最核心的维度只有三个:同一行数据的写冲突频率、事务执行时长、业务对“操作失败”的容忍度。

我把两类锁的取舍整理成一张对比表,方便对照:

维度悲观锁乐观锁
锁模型从读数据那一刻加排他锁,其他事务排队等待不加锁,写数据时校验版本号判断冲突
实现方式SELECT ... FOR UPDATE + 事务version 字段 + 条件 UPDATE
优点强一致,逻辑直观,不需要重试无阻塞,并发吞吐高,实现成本低
缺点锁等待和死锁风险,长事务容易拖垮吞吐冲突率高时重试成本大,需要额外处理失败
典型场景转账、强一致扣款、并发写碰撞高的热点行读多写少、冲突率低、可接受一次性失败的场景

一个比较实用的判断方法:如果一天之内同一行数据的写冲突次数很少,乐观锁会比悲观锁划算得多;如果热点行常年处于高并发写状态,比如秒杀商品、爆款库存,悲观锁的排队模型反而更可控,乐观锁的一轮轮重试会放大数据库压力。

事务时长也很关键。悲观锁的锁持有时间是“从加锁到事务结束”的完整时间,事务越长,其他请求被阻塞的时间越长,整体吞吐下降越明显。乐观锁则没有这个问题,冲突只发生在最后一条 UPDATE 的瞬间,事务长短对并发影响不明显。

4.2 从两个真实需求推演选型结论

看两个具体的业务需求,就能理解选型不是背结论,而是推导。

第一个需求:文章点赞计数。用户对一篇文章的点赞行为,并发冲突的概率很低,而且即使因为冲突导致一次点赞失败,用户刷新后也看不出来。这个场景我会选乐观锁,或者干脆不用锁。点赞本身就是一条UPDATE,基于当前值加一即可,只有在“先检查是否已点赞,再写入”的时候才需要版本校验。用悲观锁反而会让所有查看点赞数的人都被阻塞,明显不合理。

第二个需求:账户余额扣减。用户可能同时用多个优惠券下单,同一个账户的余额会被多个并发事务扣减,而且余额绝对不能扣错。一旦乐观锁失败重试,用户端可能看到支付失败,体验很差,而且重试过程中的幂等处理复杂。这种场景我更倾向悲观锁,或者配合分布式锁使用,让同一个账户的操作串行化,保证结果强一致。

4.3 锁是兜底手段,不是第一选择

说了这么多,还是想提醒一句:锁不是并发问题的唯一解,甚至不总是最优解。

有些业务看起来需要锁,其实用一个唯一的业务键就能挡住。比如防止重复创建订单,在订单表上建user_id + order_no唯一的索引,第二次插入会直接报错,根本轮不到锁出场。再比如库存扣减,如果业务允许“先扣再说”,一条UPDATE...WHERE stock >= 扣减数量就能利用行锁和条件判断完成安全扣减,不需要 FOR UPDATE,也不需要 version。这其实是很多秒杀系统最朴素的实现:

UPDATE sku SET stock = stock - 1 WHERE id = 100 AND stock >= 1;

这条语句执行后,如果影响行数为 1,说明扣减成功;为 0 说明库存不足或者被别人扣完了。它利用的正是当前读在 UPDATE 时的原子性。这个方案能成立,是因为库存扣减只需要“防止超卖”,不需要在扣减前做复杂的业务判断。如果你的业务在更新前必须查很多其他数据,再做判断,那才需要考虑把整段逻辑用锁保护起来。

5. 实操中容易翻车的五个细节:死锁、超时与事务边界

5.1 一个典型的双向等待推演

悲观锁最常见的线下事故就是死锁。我先用一个简单推演还原它是怎么发生的。

假设表里有两条记录 A 和 B。事务 T1 先更新 A,然后更新 B;事务 T2 先更新 B,然后更新 A。在某个时间点上,T1 拿到了 A 的锁、等待 B 的锁,T2 拿到了 B 的锁、等待 A 的锁。两边都在等对方释放自己需要的那把锁,谁也没法继续,这就死锁了。

死锁发生后,InnoDB 会自动检测并回滚其中一个事务,让另一个事务继续。回滚的事务会收到类似Deadlock found when trying to get lock; try restarting transaction的错误。

避免死锁最实用的一条经验:多个事务操作多条记录时,统一按固定顺序加锁。比如所有更新都先按主键排序,再逐条执行。这样 T1 和 T2 都会先锁 A 再锁 B,不会出现交叉等待。批量更新、批量删除的代码里,这个习惯尤其重要。

5.2 间隙锁带来的“看不见的锁”

比普通死锁更隐蔽的,是间隙锁死锁。前面提过,REPEATABLE READ 隔离级别下,范围查询会锁住记录之间的空隙。这个保护幻读的机制,在高并发下会带来一些让人看不懂的等待。

举一个实际场景。表里 id 有 1 和 3 两条记录,事务 A 执行SELECT * FROM t WHERE id BETWEEN 1 AND 3 FOR UPDATE。此时它不只锁住 id=1、id=3 两行,还锁住了它们之间 id=2 位置的空隙。另一个事务 B 想插入 id=2 的记录,会被阻塞。

如果事务 B 执行的是一条范围查询的 FOR UPDATE,同样会请求这把间隙锁。两个事务都持有对方需要的间隙锁时,就形成死锁。这类问题从日志里看,会经常出现lock_mode X locks gap before rec之类的信息。排查时需要分析两点:一是 WHERE 条件是不是范围查询,二是是否有多个事务同时操作相近的区间。

5.3 死锁日志解读与锁等待参数

遇到死锁,第一反应不是重启应用,而是去看死锁日志。执行:

SHOW ENGINE INNODB STATUS;

输出结果中有一段LATEST DETECTED DEADLOCK,记录了最近一次死锁的现场。里面会列出参与死锁的多个事务,每个事务的持有锁信息和等待锁信息,比如:

*** (1) TRANSACTION: ... *** (1) HOLDS THE LOCK(S): ... *** (1) WAITING FOR THIS LOCK TO BE ACQUIRED: ... *** (2) TRANSACTION: ... *** (2) HOLDS THE LOCK(S): ... *** (2) WAITING FOR THIS LOCK TO BE ACQUIRED: ...

看日志时,重点确认每个事务等待的是哪把锁,以及锁对应的 SQL 语句。大部分死锁都能从加锁顺序不一致解释清楚。

锁等待超时参数也值得提前调优。默认的innodb_lock_wait_timeout是 50 秒,意味着一个事务拿不到锁时会默默等 50 秒才报错。这个时间对绝大多数业务接口来说都太长,生产中我一般会调到 3 到 5 秒,让等待快速失败,再由应用层做重试,而不是让请求一直挂在那里。

5.4 事务边界过长:锁持有时间的放大器

用悲观锁时,最怕的不是冲突本身,而是事务边界被人为拉长。

举一个我在项目里真实优化过的例子:某个接口先开启事务,查库存,然后调用远程服务预留库存,服务响应比较慢,等它回来后,再更新本地库存,提交事务。事务里包含了一次完整的远程调用,谁也不知道这次调用要花多久。结果就是,只要远程服务慢一次,所有对这个库存行的更新都会堆在锁上,数据库连接慢慢被占满,整个接口跟着雪崩。

修复思路很简单:把远程调用挪出事务。先在前置阶段调用远程服务,成功后开启短事务,直接做本地扣减并提交。实在无法提前的外部调用,可以考虑最终一致性方案,用异步任务去处理,而不是占用着数据库锁等待外部系统。

事务边界的原则永远是:锁只保护真正需要保护的临界区,其他事情一律放到事务外面做。

5.5 乐观锁的重试风暴与幂等边界

乐观锁有一个隐蔽的放大器,就是重试风暴。假设一个热点商品的库存剩余 1,一百个请求同时来抢。最终只有一个事务能成功,剩下 99 个事务全部版本冲突。如果这 99 个请求都在业务代码里自动重试,下一轮又是一波同样的 UPDATE 冲击,重试若干轮后,数据库可能被打出大量行锁等待和 CPU 飙升。

遇到这种情况,不能无脑重试。我会先看冲突率:短时间内大量冲突时,说明乐观锁已经不适用,应该考虑把热点商品单独用悲观锁或 Redis 预扣方案处理。重试策略本身也要有限流,比如利用本地限流器控制每秒钟最多发起多少重试。

幂等设计同样重要。乐观锁失败重试时,如果重试包含“生成订单”这类副作用,可能导致同一笔业务被重复进入订单表。常用的方案是引入业务幂等键,比如每次请求生成全局唯一 requestId,写入订单前先查重。这样即使重试多次,也不会产生重复的订单数据。

6. 工程延伸:框架适配与锁之外的协同手段

6.1 ORM 对乐观锁的原生支持

现在主流 ORM 框架基本都把乐观锁做成了开箱即用的功能。比如 MyBatis Plus 里的@Version注解,Spring Data JPA 里的@Version字段,都会在更新时自动把版本号拼进 WHERE 条件。

但我建议不要因为框架封装了就掉以轻心。框架帮你处理的是版本号的拼装,并不负责重试策略、事务边界和幂等控制。乐观锁真正落地时,应用层仍需要自己处理重试次数、失败提示,以及可能出现的并行事务风暴。悲观锁也一样,JPA 的@Lock(LockModeType.PESSIMISTIC_WRITE)底层依然是 SELECT FOR UPDATE,锁等待时间和事务边界问题一个都不会少。

6.2 当 MySQL 本地锁不再够用时

单库单表环境下,MySQL 行锁足够解决并发问题。但应用一旦拆成多实例、分库分表,MySQL 的锁就无能为力了:一个节点的锁无法阻止另一台服务器上的请求修改同一个逻辑资源。

这时通常要引入分布式锁或分布式事务。常见的选择有 Redis 锁、基于数据库的乐观锁全局版本号、以及各分布式事务方案。但分布式锁的引入会显著增加系统复杂度,不到必要场景不建议滥用。很多业务其实可以通过唯一键、状态机、消息队列的顺序化来解决,不需要分布式锁这个层面的强一致。

一个参考原则:能用数据库约束或逻辑幂等解决的问题,不要引入锁;能用单库行锁解决的问题,不要引入分布式锁;只有跨实例且强一致是硬需求时,才考虑分布式锁。

6.3 MySQL 8.0 的 NOWAIT 与 SKIP LOCKED

最后聊一个容易被忽略的进阶特性。传统SELECT ... FOR UPDATE拿不到锁时只会在那等着,对很多“抢一次抢不到就放弃”的业务来说,等待是一种浪费。MySQL 8.0 开始支持两个选项:

-- 拿不到锁立即报错,不等待 SELECT * FROM task WHERE id = 1 FOR UPDATE NOWAIT; -- 跳过已经被其他事务锁住的行,只处理能拿到的行 SELECT * FROM task WHERE status = 0 ORDER BY id LIMIT 5 FOR UPDATE SKIP LOCKED;

NOWAIT适合那种“抢一次,抢不到就换策略”的场景,避免无谓等待。SKIP LOCKED更适合任务队列和定时调度:多个 worker 同时领取一批任务,每个 worker 执行带 SKIP LOCKED 的查询,只会拿到还没被别人锁住的任务行,不用担心重复分配。

这个特性用起来很顺手,但要注意它改变的是加锁失败时的行为,不改变加锁本身的性质。业务逻辑仍然要保证事务短小,查询尽量走索引,否则跳过了锁还是可能被全表扫描拖垮。


我个人在实际项目里体会最深的一点是:锁不是越重越好,也不是越轻越好。乐观锁和悲观锁之间的差异,绝不只是“加不加版本号”这么简单,它背后是你对业务冲突率、接口耗时、失败成本的综合预期。先把事务边界缩到最短,再评估同一份数据的竞争概率,最后才决定是上悲观锁、乐观锁,还是换一种完全不依赖锁的方案。

如果你也遇到过“明明加了锁还是超卖”的诡异事故,第一步别急着换锁,先看事务是否真的提交了、WHERE 条件有没有走索引、隔离级别是不是被偷偷改过。并发问题大多数时候不是锁的方案不行,而是锁没有被正确地放在它该在的地方。

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

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

立即咨询