
做后端这些年面试了上百人几乎每个候选人都能说出“事务有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采用的是WALWrite-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事务回滚库存还是100B事务就读到了一个从未真实存在过的数值。不可重复读指同一个事务里两次查询同一行结果不一样。比如A事务先查到金额100元此时B事务提交了更新把金额改成200A事务再查同一行变成了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不仅会锁住id5和id10这两行还会锁住(5,10)这个区间以及10后面的开区间具体范围要看索引结构。这意味着另一个事务如果尝试插入id7或id12的记录都会被阻塞直到事务A提交。再极端一点如果查询条件没有命中任何记录SELECT * FROM stock WHERE id 3 FOR UPDATE;如果id3不存在InnoDB会锁住(1,5)这个间隙防止其他事务插入id3。这个行为常常让人摸不着头脑但在可重复读层面它就是为了防止快照读和当前读的幻读问题。给行锁加锁的常用语句有-- 排他锁当前事务读并锁住这些行其他事务不能更新、删除也不能加共享锁或排他锁 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;两个并发事务都能查到stock1然后都通过判断并执行更新最后库存变成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 stockstock-1 WHERE id1;事务B执行UPDATE stock SET stockstock-1 WHERE id2;。然后事务A继续执行UPDATE stock SET stockstock-1 WHERE id2;事务B继续执行UPDATE stock SET stockstock-1 WHERE id1;。如果A先拿到id1的锁B先拿到id2的锁接着双方都要对方手里的锁死锁立刻形成。排查死锁最直接的方法是查看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)readOnlytrue不会开启真正的只读事务但能让底层做一些优化也起到代码语义约束作用。别小看这个属性团队里新人瞟一眼就知道这个方法不该有写操作。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而不是盲目调超时参数。这套流程看起来繁琐但真能在高并发场景里帮你少熬几个夜。