☰
MySQL事务与锁实战:从ACID到高并发库存扣减的避坑指南
2026/9/26 5:38:52 网站建设 项目流程

做后端这些年,面试了上百人,几乎每个候选人都能说出“事务有ACID、锁有行锁表锁”,但一追问“MySQL默认隔离级别为什么是可重复读”“更新语句没走索引为什么会锁全表”“高并发扣库存到底用乐观锁还是悲观锁”,就卡壳了。这篇文章就围绕MySQL事务与锁这两块基石,把高并发数据系统里最容易踩坑、也最常被问到的东西一次性讲透。适合刚写CRUD的后端开发、正在准备面试的朋友,以及正在设计订单、库存等强一致场景系统的团队参考,内容偏实战,不绕弯子。

1. 事务基石:ACID在InnoDB里到底怎么落地的

1.1 一个事务背后站着哪些组件

面试时很多人能把ACID背得滚瓜烂熟,但一问“InnoDB靠什么保证原子性和持久性”,就只剩一句“有redo log和undo log”。实际上,把这两类日志和事务的执行过程串起来理解,很多疑难问题会迎刃而解。

先看原子性。一个事务里可能有10条SQL,执行到第5条时报错了,前4条的修改必须全部撤销。这个回滚能力来自undo log,它记录的是“修改前的值”。InnoDB在更新一行数据时,会先把旧值写入undo log,再修改数据页。事务回滚时,通过undo log把旧值覆盖回去。你可以把undo log理解成拍立得照片:每次改动前先拍一张旧样子,出了问题就按照片恢复现场。

再看持久性。事务提交后,即使数据库瞬间崩溃,数据也不能丢,这依靠redo log。redo log记录的是“修改后的页操作”,是物理日志。InnoDB采用的是WAL(Write-Ahead Logging)机制,数据页先写内存缓冲池,然后顺序写redo log,最后才异步刷盘。由于redo log是追加写,效率比随机写数据页高得多,这也是MySQL能扛住高并发写入的关键设计。

隔离性靠的就是锁和MVCC(多版本并发控制)这套组合拳,下一章细说。一致性本质是约束加业务逻辑,比如唯一索引、外键、事务内状态检查,数据库帮你拦一部分,应用逻辑再兜底一部分。理解了这些对应关系,你就不会把日志、锁、MVCC当成孤立的知识点。

注意:redo log是物理日志,记录“某个页做了什么改动”;undo log是逻辑日志,记录“这一行之前长什么样”。两者作用不同,面试时别混为一谈。

1.2 四种隔离级别,为什么MySQL默认是可重复读

SQL标准定义了四种隔离级别,MySQL的默认值是REPEATABLE READ,也就是可重复读。先列一个对比表,把每个级别解决的问题和遗留问题梳理清楚:

隔离级别脏读不可重复读幻读
READ UNCOMMITTED可能发生可能发生可能发生
READ COMMITTED避免可能发生可能发生
REPEATABLE READ避免避免可能发生(InnoDB通过间隙锁和MVCC基本解决)
SERIALIZABLE避免避免避免

脏读就是读到别的事务还没提交的数据。假设A事务把库存从100改成50,还没提交,B事务读取到50,接着A事务回滚,库存还是100,B事务就读到了一个从未真实存在过的数值。

不可重复读指同一个事务里两次查询同一行,结果不一样。比如A事务先查到金额100元,此时B事务提交了更新,把金额改成200,A事务再查同一行变成了200。这在财务类场景里很致命。

幻读更隐蔽:第一次查询有10条记录,第二次查询变成了11条,多出来的那行像幻觉一样出现。可重复读只防住了已有行被修改,防不住有新行插入。

InnoDB在REPEATABLE READ级别下,通过MVCC让普通SELECT走快照读,保证事务内多次读到的结果一致;同时配合next-key lock(下一章讲)在写操作时锁定范围和间隙,基本把幻读也堵住了。这也是MySQL敢把默认级别设为可重复读的原因。SQL Server、PostgreSQL默认采用READ COMMITTED,是因为它们认为互联网场景下多数业务不需要可重复读。但MySQL的InnoDB既然能在可重复读下连幻读一起解决,自然就选了更严格的默认值。

如果你刚装好MySQL,建议先用这条SQL确认当前参数:

SELECT @@transaction_isolation; -- 5.7及以下版本用 SELECT @@tx_isolation;

我当时接手一个老项目,发现线上隔离级别是READ UNCOMMITTED,一问才知道是之前有同事图“读得快”改的。这就是拿数据一致性换性能的典型反面教材,在高并发订单场景里等于埋雷。

2. 锁的体系:从行锁到间隙锁,一次讲透

2.1 为什么InnoDB行锁经常“退化”成表锁

InnoDB的行锁不是锁在“行”这个抽象概念上,而是锁在索引记录上。这个设计非常重要,因为只有通过索引定位到具体记录,才能锁住那一行。如果SQL没有走索引,InnoDB只能全表扫描,扫描过程中会对扫描到的所有记录加锁,最终表现就是“明明是行锁,却锁了整张表”。

举个例子,用户表里有字段phone但没有索引,你执行:

UPDATE user SET name = '张三' WHERE phone = '13800138000';

这条SQL全表扫一遍,把每一行都加上锁。此时别的线程想更新任意一行,都会等你这个事务提交。如果你以为自己在做“精准改一行”,实际上却堵住了整个表的写操作。

实测心得:排查线上慢SQL时,重点看type列,如果是ALL或index,就要警惕是不是全表扫描把锁范围扩大了。索引不是建得越多越好,但高频更新和查询的WHERE条件字段,一定要有合适的索引。

意向锁也是高频考点。InnoDB有表级意向锁,分为意向共享锁(IS)和意向排他锁(IX),本质是个表级“标记”,表示“某个事务正在或即将给这个表的某些行加锁”。它的作用是为了快速判断表锁和行锁之间是否冲突。比如事务A正在锁住某一行,事务B想直接对整个表加排他锁,如果没有意向锁,就要遍历所有行才能确认;有了意向锁,一眼就发现这个表上存在IX锁,直接判断冲突,效率极高。

2.2 记录锁、间隙锁、next-key lock的加锁范围

InnoDB在REPEATABLE READ级别下,默认加的是next-key lock,也就是“记录锁+间隙锁”的组合。间隙锁锁的是一个区间,防止别的事务在这个区间插入新记录,本质是为解决幻读服务的。

先看一个经典案例。假设stock表里id有1、5、10三条记录,事务A执行:

SELECT * FROM stock WHERE id BETWEEN 5 AND 10 FOR UPDATE;

InnoDB不仅会锁住id=5和id=10这两行,还会锁住(5,10)这个区间,以及10后面的开区间(具体范围要看索引结构)。这意味着另一个事务如果尝试插入id=7或id=12的记录,都会被阻塞,直到事务A提交。

再极端一点,如果查询条件没有命中任何记录:

SELECT * FROM stock WHERE id = 3 FOR UPDATE;

如果id=3不存在,InnoDB会锁住(1,5)这个间隙,防止其他事务插入id=3。这个行为常常让人摸不着头脑,但在可重复读层面,它就是为了防止快照读和当前读的幻读问题。

给行锁加锁的常用语句有:

-- 排他锁:当前事务读并锁住这些行,其他事务不能更新、删除,也不能加共享锁或排他锁 SELECT * FROM stock WHERE id = 1 FOR UPDATE; -- 共享锁:其他事务可以继续读,但不能修改;修改需要等待 SELECT * FROM stock WHERE id = 1 LOCK IN SHARE MODE;

还有一个容易忽略的点:在READ COMMITTED级别下,InnoDB会禁用间隙锁,只保留记录锁。这也是有些人在测试环境没问题,上线切换到可重复读后发现并发插入互相阻塞的原因。如果业务允许,适当降低隔离级别可以缓解间隙锁带来的并发瓶颈;如果必须保持可重复读,就要好好设计更新和插入的节奏。

3. 高并发扣库存:乐观锁、悲观锁与原子操作的取舍

3.1 最经典的库存扣减方案对比

高并发场景里,库存扣减绝对是出镜率第一的问题。秒杀、电商下单、ERP库存出库,核心矛盾都一样:多个请求同时扣同一件商品的库存,怎么避免超卖。

最容易被新手写出来的错误版本是:

-- 错误示例:先查再改,中间有竞态窗口 SELECT stock FROM stock WHERE product_id = 1; -- 程序判断 stock > 0 UPDATE stock SET stock = stock - 1 WHERE product_id = 1;

两个并发事务都能查到stock=1,然后都通过判断并执行更新,最后库存变成0而不是负数?不,更常见的是两个都执行成功,库存变成-1,超卖。

正确的做法有很多种,我这里按使用频率排序。

第一种,原子更新,也是我推荐的首选:

UPDATE stock SET stock = stock - 1 WHERE product_id = 1 AND stock > 0;

这条SQL的妙处在于UPDATE本身是原子的,并且WHERE条件带上了stock > 0。InnoDB在更新时会锁住命中行,真正执行更新时判断库存是否大于0,如果库存不足,影响行数为0,应用层据此返回“库存不足”。这段逻辑全程没有出现“先SELECT再UPDATE”的竞态窗口,是性能和数据安全性平衡最好的方案。

第二种,乐观锁版本号:

UPDATE stock SET stock = stock - 1, version = version + 1 WHERE product_id = 1 AND version = 5;

如果其他事务已经把version改成了6,这条更新影响行数为0,应用层需要重试或者提示用户。乐观锁适合并发冲突不太激烈、读多写少的场景,因为每次更新都要带上版本号校验,失败还要重试。但扣库存这种高频冲突场景,重试率会比较高,效果反而不如原子更新。

第三种,悲观锁:

BEGIN; SELECT * FROM stock WHERE product_id = 1 FOR UPDATE; -- 程序判断库存 UPDATE stock SET stock = stock - 1 WHERE product_id = 1; COMMIT;

悲观锁的优势是“我先锁住,别人别想动”,逻辑直白,适合并发冲突极大、对一致性要求极苛刻的场景。但缺点是并发能力差,所有请求串行排队,数据库压力很大。秒杀这种QPS动辄几千的场景,不建议直接用for update扛,除非你能接受排队。

一个小建议:用原子更新时,最好把stock字段设计成无符号整数,一旦算成负数会直接报错,等于数据库层面给你兜了一层底。

3.2 死锁怎么产生,怎么排查

死锁的典型成因是两个事务各自持有对方需要的锁,互相等待,谁也提交不了。MySQL的InnoDB会自动检测死锁,选择回滚一个代价更小的事务,应用层会收到1213错误码。

举个例子:

事务A执行UPDATE stock SET stock=stock-1 WHERE id=1;,事务B执行UPDATE stock SET stock=stock-1 WHERE id=2;。然后事务A继续执行UPDATE stock SET stock=stock-1 WHERE id=2;,事务B继续执行UPDATE stock SET stock=stock-1 WHERE id=1;。如果A先拿到id=1的锁,B先拿到id=2的锁,接着双方都要对方手里的锁,死锁立刻形成。

排查死锁最直接的方法是查看InnoDB状态:

SHOW ENGINE INNODB STATUS;

输出里找到LATEST DETECTED DEADLOCK段,里面会打印两个事务各自执行到哪条SQL、持有哪些锁、等待哪些锁。我在一次线上事故里就是靠这段信息发现,业务代码里有两条SQL的更新顺序不一致:接口A先更新主表再更新明细表,接口B先更新明细表再更新主表。后来统一成先主后细,死锁直接清零。

避免死锁可以从几个角度下手:

  • 所有事务按相同顺序访问表或行。
  • 尽量缩短事务持续时间,减少持锁时间。
  • 适当调低innodb_lock_wait_timeout参数,让锁等待快速失败而不是无限阻塞。
  • 批量操作改成小批次提交,避免一个事务锁几百上千行。

4. Spring事务:注解真的万无一失吗

4.1 事务传播行为与常见失效场景

Java后端十里有七八在用Spring的@Transactional,但很多人只把它当“开了事务”的开关,不知道它的底层是AOP动态代理。Spring通过代理对象在进入目标方法前开启事务,方法正常返回后提交,抛出异常后回滚。问题恰恰出在“代理”这两个字上。

经典的失效场景我列一下,基本都是我见过真出事的:

第一种,同类内部自调用。类A有方法a()和b(),a()内部直接调用this.b(),b()上标了@Transactional,这个事务不生效。因为this.b()走的是当前对象,不是Spring生成的代理对象,事务逻辑根本没机会切入。解决办法是注入自身代理,或者把b()拆到另一个Bean里。

第二种,异常被吞。方法里catch了异常但不重新抛出,事务回滚判定触发不了。这是我排查过最多的一类问题,代码看着“对了”,实际上异常被记录日志后吞掉,数据库该扣的没扣,该加的没加,数据悄悄错了。

第三种,rollbackFor没配。默认情况下,Spring只对RuntimeException和Error回滚,对checked异常不回滚。很多老项目直接写@Transactional,出现IOException、业务异常时数据照样提交。业务校验失败抛自定义异常时,记得配置:

@Transactional(rollbackFor = Exception.class)

第四种,方法不是public。Spring AOP默认不拦截非public方法,protected或private方法上的事务注解不生效。这个问题在代码审查时经常被忽略。

第五种,数据库引擎不支持事务。如果表是MyISAM引擎,InnoDB才有的redo、undo、行锁全都不支持,事务注解自然无效。检查表引擎推荐全部改为InnoDB。

事务传播行为也是面试高频题。REQUIRED是默认的,如果当前有事务就加入,没有就新建;REQUIRES_NEW是挂起当前事务,开启一个全新事务;NESTED是嵌套事务,利用保存点实现局部回滚。一个典型的REQUIRES_NEW场景是:主业务更新订单,同时调用积分服务记录日志,日志哪怕失败也不能把订单回滚。这里就适合把日志服务的方法设成REQUIRES_NEW。

4.2 大事务是锁等待的头号推手

事务越长,持有的锁越久,并发能力越低。我曾经排查过一个ERP库存系统的线上故障:一个入库事务里,开发把库存统计报表的查询也放进去了,事务里跑一条20秒的聚合SQL,导致整个库存表被读锁和写锁拖住,其他订单的出库请求全部排队,最终积压几千个任务。

大事务常见操作有:

  • 在一个事务里调用外部HTTP接口。
  • 在事务里发送MQ消息。
  • 循环几千次更新操作。
  • 在事务里做慢查询。

优化的思路很明确:事务里只保留和一致性强相关的写操作,把可延后的动作放到事务外面,比如先落库、提交后再发MQ;查询类的逻辑移到事务前;批量更新拆成小批次。只读方法也可以明确标注:

@Transactional(readOnly = true)

readOnly=true不会开启真正的只读事务,但能让底层做一些优化,也起到代码语义约束作用。别小看这个属性,团队里新人瞟一眼就知道这个方法不该有写操作。

5. 锁等待与并发瓶颈的排查实操

5.1 用系统表快速定位谁在阻塞谁

线上出现“锁等待超时”报错(错误码1205),第一反应不该是重启服务,而是先看当前有哪些事务在跑、持有锁、等待锁。MySQL提供了现成的数据源。

先看当前所有事务:

SELECT * FROM information_schema.innodb_trx;

关注trx_id、trx_state、trx_started、trx_query这几个字段。trx_state为LOCK WAIT的说明正在等锁。

再看锁等待关系,8.0之前:

SELECT * FROM information_schema.innodb_lock_waits;

8.0之后performance_schema里的表改名了,但sys库已经帮我们封装好视图,直接一条SQL查到阻塞链条:

SELECT * FROM sys.innodb_lock_waits;

这张表的waiting_pid、waiting_query就是被阻塞的会话和执行语句,blocking_pid、blocking_query就是“罪魁祸首”。我处理过的一个真实案例:一个后台任务要更新十万行数据,事务跑了半小时没提交,期间订单表正常更新全部卡住。用这条SQL定位到阻塞会话后,查了一下业务逻辑,发现开发写了个大循环,本以为每轮循环都会自动提交,结果外层套了事务注解,整个任务一个事务跑完。后来改成分批提交,锁问题瞬间消失。

实操技巧:线上排查时,如果某条UPDATE一直卡着,先别急着kill,用sys.innodb_lock_waits看清阻塞者是谁,确认后再kill阻塞会话,否则可能误伤正在跑的关键事务。

5.2 三个参数与优化习惯

和锁等待强相关的参数有三个,我建议每个项目启动前都根据业务调一遍:

参数默认值建议说明
innodb_lock_wait_timeout50秒5~10秒锁等待超时时间,业务高峰期50秒会让请求卡死
innodb_rollback_on_timeoutOFFON超时后是否回滚整个事务,建议开启避免部分提交的脏数据
transaction-isolationREPEATABLE-READ按业务定读多写少可考虑READ COMMITTED降低间隙锁影响

索引设计对锁的影响比参数更大。前面说过,不走索引的UPDATE会锁全表。优化锁问题,第一件事永远是检查执行计划:

EXPLAIN UPDATE stock SET stock = stock - 1 WHERE product_id = 1;

看type和key,确保用了索引,而不是全表扫描。另外,不要在索引列上做函数操作,比如WHERE DATE(create_time) = '2024-01-01'会让索引失效,锁范围跟着扩大。

我个人的另一个习惯是,每周拉一次慢查询日志,重点看事务内的慢SQL。如果一个慢SQL出现在事务内部,即使本身只有200毫秒,也会因为事务前后还有30秒的其他操作而持锁30秒,影响面会成倍放大。

6. 面试高频考点与问题速查

6.1 面试官最喜欢问的几个深层问题

第一个问题:InnoDB在可重复读下是怎么阻止幻读的?很多人只答“MVCC”,但完整答案是快照读走MVCC,当前读走next-key lock。快照读让普通SELECT看到一致快照,当前读(SELECT FOR UPDATE、UPDATE、DELETE)通过记录锁+间隙锁锁住范围和间隙,从根上堵住新插入。两个机制各管一段,面试时分开讲,说服力完全不同。

第二个问题:乐观锁和悲观锁怎么选?核心不是哪个更好,而是看冲突概率和业务容忍度。冲突少、重试成本低选乐观锁;冲突激烈、要求强一致选悲观锁;单行库存扣减这种高频写场景直接用原子UPDATE,连版本号都省了。

第三个问题:分布式锁和数据库锁的区别是什么?数据库锁作用在单库单表内,分布式锁解决的是跨进程、跨机器的互斥问题。常见的Redis分布式锁要注意原子性和过期时间,但Redis锁也有主从切换丢锁的问题,需要结合业务容忍度做取舍。我通常建议,能不加分布式锁就不加,先用唯一索引、乐观锁、原子更新这类数据库手段;实在不行,再上分布式锁并做好续期和降级方案。

6.2 一张速查表解决日常问题

把日常容易遇到的问题整理成一张表,排查的时候照着看:

现象可能原因快速排查/解决
报1213死锁两个事务加锁顺序不一致SHOW ENGINE INNODB STATUS,看LATEST DETECTED DEADLOCK
报1205锁等待超时其他事务持锁时间过长查sys.innodb_lock_waits,定位阻塞会话
UPDATE执行慢且后续写阻塞SQL没走索引,行锁升级成表锁EXPLAIN分析,补合适的索引
@Transactional不生效同类自调用、异常被吞、非public检查调用的对象是不是代理对象,异常是否抛出
业务逻辑回滚了但数据没回滚checked异常没配rollbackFor配置rollbackFor = Exception.class
库存超卖先SELECT再UPDATE的竞态改为UPDATE ... WHERE stock > 0原子操作

这张表本质上是把“数据库事务+锁”的理论和现实故障对应起来。项目里如果经常出这些事,建议沉淀成团队的故障手册,新人入职先看一遍,比反复踩坑高效得多。

做订单系统这几年,我最大的体会是事务与锁从来不是孤立的数据库概念,它们和业务SQL、索引设计、事务边界深度绑定。很多人拿着“可重复读”“间隙锁”这些术语聊天头头是道,一遇到线上锁等待就慌了手脚,本质是没把理论套到具体SQL和具体表结构里。最后再分享一个很实用的习惯:每次上线前,我都会打开慢日志和performance_schema,挑几个核心事务跑一遍EXPLAIN,确认加锁范围没有超出预期;上线后如果出现锁等待,永远先看谁在阻塞、在等哪条SQL,而不是盲目调超时参数。这套流程看起来繁琐,但真能在高并发场景里帮你少熬几个夜。

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

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

立即咨询