1. 先搞定基础认知:MySQL事务到底解决什么问题
1.1 从一个转账案例说起
聊到MySQL,逃不开事务这个话题。哪怕你是个刚入门写CRUD的选手,面试时也大概率会被问“事务的ACID是什么”。但说实话,背下来四个特性很简单,真正理解事务在解决什么问题,才是拉开差距的地方。
我习惯用一个转账场景来开场:A账户有1000块,B账户有1000块,现在A要转500给B。直觉上的操作是两步——先扣A的500,再给B加500。如果第一步执行完,数据库突然宕机了,A的钱扣了,B的钱没到账,这500块就凭空消失了。这就是数据不一致。
事务要解决的核心问题,说白了就是一件事:让多个操作要么全部成功,要么全部失败,不允许中间状态残留。把上面两步包进一个事务里,要么都提交生效,要么都回滚当作没发生,这样数据就永远不会处于“扣了没到账”的尴尬状态。
很多人觉得事务是数据库理所当然的功能,其实不是。像早期的MyISAM存储引擎就没有事务能力,后来InnoDB成为默认引擎,事务才成了MySQL的标配。所以你现在用MySQL 5.7+,默认就是InnoDB,事务默认就是开启的。
1.2 ACID不是四个孤立的单词,它们是一套逻辑链
ACID四个特性,很多教程是摊开来讲的,但我的理解是它们之间存在因果关系:
**原子性(Atomicity)**是整个事务的地基。它保证了一个事务内的所有操作,要么全部执行成功,要么全部不执行。注意这里的关键词是“全部或不”,这直接解决了上面转账场景的问题。原子性靠的是undo log,事务执行过程中如果出错,就用undo log里的记录把数据回滚到执行前的状态。
**一致性(Consistency)**是事务的最终目标。它指的是事务执行前后的数据状态都必须是合法的、符合业务规则的。这个“业务规则”既包括数据库层面的约束(如唯一键、非空、外键),也包括业务代码里你自己定义的那些逻辑。原子性、隔离性、持久性本身都是在为一致性服务——如果原子性被破坏,数据就会残留中间状态,自然就不一致了;如果隔离性没做好,事务之间互相干扰,也会破坏一致性;如果持久性出问题,宕机后数据丢失,同样不一致。
**隔离性(Isolation)**处理的是多个事务并发执行时的互相干扰问题。数据库是高并发的场景,同一时刻肯定有多个事务在跑,如果不做隔离,一个事务可能读到另一个事务还没提交的数据。这个特性我们在下一节详细展开,它是MySQL事务里最复杂、坑最多的地方。
**持久性(Durability)**保证事务一旦提交,结果就被永久保存下来。即使提交之后数据库立刻宕机,数据也不会丢。这个靠的是redo log,写入事务提交时会把变更记录到redo log日志文件里,然后才去更新数据页。就算数据页没来得及刷盘,重启后MySQL也会用redo log把数据恢复出来。
这里我多说一句,很多人容易混淆redo log和undo log——redo log记录的是“做了什么更改”,用来崩溃恢复;undo log记录的是“怎样撤销更改”,用来回滚和一致性读。它们俩有本质区别,面试官特别爱考。
2. 隔离级别:MySQL事务最核心也最容易踩坑的环节
2.1 不隔离会出三种乱子
既然事务要并发执行,那就必须定义“事务之间互相能看到多少”。如果不做任何隔离,会出现三种读异常,从轻到重排列:
脏读(Dirty Read):事务A修改了一条数据,还没提交,事务B就去读,读到了A修改后的“脏”数据。然后A回滚了,B就把这个根本不存在的值拿去做后续操作了。这就像你看同事的草稿文档,他写到一半说写错了删掉了,你却把他删掉之前的内容引用进了自己的报告里。
不可重复读(Non-Repeatable Read):事务A先读了一条数据得到值X,然后事务B修改了这条数据并提交,接着事务A再读同一条数据,得到值Y,X不等于Y。在同一个事务里,同一条数据读了两次,结果不一样,说明事务B的提交影响到了事务A的读操作。
幻读(Phantom Read):事务A执行了一个范围查询(比如查salary大于5000的所有员工),得到5条记录。事务B插入了一条新的满足条件的记录并提交,然后事务A再次执行同样的范围查询,得到6条记录。多出来的那条就像“幻觉”一样。注意幻读和不可重复读的关键区别:不可重复读是同一条记录的值变了,幻读是一批记录的数量变了。
这三种异常一个比一个严重,数据库就用四种隔离级别来控制到底允许多少并发干扰发生。完整对照关系我整理成了表格,方便面试前突击用:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED(读未提交) | 可能 | 可能 | 可能 |
| READ COMMITTED(读已提交) | 不可能 | 可能 | 可能 |
| REPEATABLE READ(可重复读) | 不可能 | 不可能 | 可能 |
| SERIALIZABLE(串行化) | 不可能 | 不可能 | 不可能 |
2.2 四个隔离级别的实际对比,不只看表还要看场景
READ UNCOMMITTED基本属于“裸奔”状态,事务能读到别人还没提交的数据。实际生产中几乎没人用这个级别,因为脏读会带来非常离谱的问题——试想一个财务系统,采用的数字本身都不可靠,那计算结果还有什么意义。它唯一的用途大概是做性能极限压测的时候观察基线数据,但正常开发我强烈不建议碰它。
READ COMMITTED是很多其他数据库(比如Oracle、PostgreSQL)的默认隔离级别。它解决了脏读,允许不可重复读。意思是事务A只能读到事务B提交后的数据,所以不会读到“半成品”数据。但同一个事务里两次读同一条数据,如果这期间别人提交了修改,两次读到的值会不一样。这个级别在需要“每次读到的都是最新已提交数据”的场景下是有意义的,比如实时报表统计。
REPEATABLE READ是MySQL InnoDB的默认隔离级别。它解决了不可重复读,也就是说在同一个事务里,你反复读同一条数据,值都是一样的。但按照标准定义,它还有幻读问题——同一个事务两次范围查询,结果集数量可能不一样。
这里有个非常关键的坑:MySQL的REPEATABLE READ级别下,使用当前读(SELECT ... FOR UPDATE、UPDATE、DELETE、INSERT)配合间隙锁,实际上已经把幻读问题也解决了。也就是说,在MySQL里,REPEATABLE READ的体验接近SERIALIZABLE,但并发性能远高于SERIALIZABLE。这是InnoDB的一个隐藏福利,也是面试官非常喜欢深挖的点——很多人只知道“RR会幻读”,却不知道MySQL通过间隙锁做了额外处理。
SERIALIZABLE是最高隔离级别,直接让事务串行执行,彻底杜绝所有并发干扰。但它也是最慢的,相当于在所有人进数据库之前排个队,一个事务执行完下一个才能开始。实际业务中几乎不会用这个级别,因为它把并发能力降为零了,除非你做的是极其敏感且低并发的场景。
2.3 MySQL具体怎么实现这些隔离级别
在InnoDB里,隔离级别的实现主要靠两套机制:锁和MVCC(Multi-Version Concurrency Control,多版本并发控制)。
锁机制的思路很朴素:我读的时候不让你写,我写的时候不让你读。但这会造成严重的并发阻塞,性能很差。所以InnoDB引入了MVCC,用“读写不互斥”的思路来优化:读操作不阻塞写操作,写操作也不阻塞读操作,大家各读各的历史版本。
MVCC的核心是每条数据在数据库里不止存一个版本,而是通过undo log维护了多个历史版本。数据行上有两个隐藏字段:trx_id(最后一次修改这个行的事务ID)和roll_pointer(指向undo log里该行之前的版本)。当一个事务执行快照读(普通的SELECT就是快照读)时,它会根据事务启动时生成的read view(活跃事务列表快照)来判断哪些版本可见、哪些版本不可见。这样一来,读操作永远读到的是一个一致性的快照,不会受其他事务提交的影响。
各种隔离级别的实现差异也很清晰:READ UNCOMMITTED根本不去看read view,直接读最新版本;READ COMMITTED是每次SELECT都重新生成一个read view,所以同一事务里两次读可能读到不同结果;REPEATABLE READ是事务第一次执行SELECT时生成read view,之后整个事务都用这一个视图,所以不会被其他已提交事务干扰。
这里插一个新手容易误解的点:MVCC的read view只对快照读有效,如果使用当前读(比如SELECT ... FOR UPDATE、UPDATE、DELETE),MVCC就不管了,必须靠加锁来控制并发。这条规则在排查事务问题的时候特别重要,很多人查了半天发现“明明隔离级别是RR,怎么还会读到新数据”,一查代码,哦,用了SELECT ... FOR UPDATE,那自然不受MVCC保护。
3. 事务并发与锁机制:高并发下事务能不能扛住
3.1 锁的分类再说细一点
MySQL的锁分类,从粒度、模式、算法三个维度看会清晰很多。
从粒度上看,锁分为表锁、行锁和页锁。InnoDB默认用的是行锁,它是基于索引实现的——这就是为什么所有走不上索引的UPDATE操作会退化成锁全表,这也是生产事故最常见的原因之一。我排查过很多次线上“突然所有更新卡住”的问题,根源就是一条UPDATE语句的条件字段没有索引,InnoDB在RR隔离级别下只能逐行扫描并给每行加锁,效率极低且并发全卡。
从模式上看,行锁又分为共享锁(S锁,读锁)和排他锁(X锁,写锁)。共享锁之间可以共存,共享锁和排他锁互斥,排他锁和排他锁也互斥。直观理解:读不阻塞读,读写互相阻塞,写和写更是互相阻塞。
从算法上看,就又回到了我们上文说的幻读问题——InnoDB用三种锁来解决:
- 记录锁(Record Lock):锁的是单个索引记录,直接锁住那一条行。
- 间隙锁(Gap Lock):锁的是一个范围,但这个范围内没有任何具体记录。它锁的是索引记录之间的“空隙”。
- 临键锁(Next-Key Lock):记录锁和间隙锁的组合,锁住的是“一个范围 + 范围内的记录”。这是InnoDB在RR级别下的默认锁定算法,范围查询时会用它来防止其他事务在这个区间插入新记录,从而解决幻读。
这里可以回到手机壳的概念想一想:记录锁锁的是手机本身,间隙锁锁的是手机和手机之间那个空位置,临键锁则是把手机和它前面的空位一起锁住。这样别人想在空位置放新手机就放不进去,幻读自然被堵死了。
还有一个经常被忽略但实际非常重要的锁叫意向锁。它是表级别的锁,分为意向共享锁和意向排他锁。这个锁的意义在于:给某行加锁之前,会先给表加上一个“意向锁”,告诉其他事务“我这表里已经有人锁定行了,你想锁整张表就得等一等”。没有意向锁的话,行锁和表锁就会互相冲突,数据库需要扫描每一行才能知道有没有行锁,效率极低。意向锁让表锁的检查从O(N)变成了O(1),这是非常精巧的设计。
3.2 死锁是怎么发生的,怎么排查
死锁的经典场景是:事务A锁住了行1,想锁行2;事务B锁住了行2,想锁行1。两个事务都在等对方释放锁,谁也等不到,就形成死锁了。
我的标准排查流程是这样:出现死锁报警后,第一件事就是去MySQL里看最近一次死锁信息。数据库专门提供了命令:
SHOW ENGINE INNODB STATUS;这个命令的输出里有一段LATEST DETECTED DEADLOCK,会打印出死锁发生的时间、两个事务各自执行到了哪条SQL、持有的锁和等待的锁分别是什么。结合应用日志里的业务ID,基本可以在几分钟内定位到是哪两个业务路径互相抢锁了。
定位之后,最常见的缓解方法是调整两条SQL的执行顺序,让所有事务都按照同一个顺序获取锁。比如订单模块的所有更新操作,统一先更新订单主表、再更新订单明细表,这样就永远不存在交叉等待的情况,死锁概率大幅下降。
另一个常用的兜底方案是设置锁等待超时时间:
-- 查看当前值 SHOW VARIABLES LIKE 'innodb_lock_wait_timeout'; -- 设置锁等待超时,单位是秒 SET GLOBAL innodb_lock_wait_timeout = 10;锁等不到就不能一直等下去,达到超时阈值后MySQL会让当前事务报错回滚,至少不会无限期卡住业务。但要注意,这个参数不宜设置得太小,否则一些正常的业务高峰排队操作会被误杀,我见过一些团队因为把这个调到3秒,导致大促期间大量订单更新失败,属于过犹不及。
死锁还有一个很有意思的机制:MySQL内部有死锁检测器,默认开启。它会在事务等待锁的时候检测是否存在循环等待,发现后就主动牺牲一个事务(回滚它)让另一个事务继续跑。所以实际上InnoDB并不会让你真的“卡死”,而是牺牲较小代价的那一方。但被牺牲的事务需要应用层捕获异常、重试处理,所以应用代码里对数据库唯一约束冲突、死锁回滚异常要有对应的兜底重试逻辑。
3.3 结合热词看:高并发事务的常规优化思路
每次看到“mysql高并发解决方案”这个热搜词,我就知道又有一批同学在并发和事务的交汇处卡住了。结合事务这个话题,我给几个真正落地的优化方向。
**第一,减小事务范围。**事务越小,持有的锁越少、时间越短,并发自然越高。很多新手喜欢在一个事务里一口气做完所有数据库操作,包括同步调用其他外部服务、做耗时的计算等。正确做法是:外部调用和纯内存计算都不要放进事务里,让事务保持“短、平、快”。
**第二,降低锁竞争。**同一个热点数据(比如库存)被大量事务同时更新,行锁竞争会非常激烈。一种方案是把一行拆成多行,比如库存表里把一个商品的库存拆成多个桶,每次扣减随机选一个桶,这样把对同一行的竞争分散到了多行上。另一种方案是引入异步削峰,先把更新请求写入MQ,由消费端以低并发慢慢更新库存,把瞬时压力拉平。
**第三,合理利用Redis。**在读多写少的库存、商品场景,可以用Redis做热点数据的预扣减,数据库只负责最终的异步落账。当然这会引入最终一致性的问题,需要自己设计补偿机制,这是分布式事务的范畴了。
4. 事务在代码里怎么落地:注解用法与翻车现场
4.1 事务注解背后的真实工作逻辑
Java开发的同学对@Transactional这个注解肯定不陌生。它用起来确实简单,在方法上打一行注解就完事了。但它的底层逻辑其实是一个AOP切面:方法执行前,Spring帮你开启事务;方法正常返回,Spring帮你提交;方法抛出未捕获的RuntimeException,Spring帮你回滚。
这个注解上有几个关键属性,真正理解的人不多。propagation属性控制事务的传播行为,默认是REQUIRED,意思是如果当前已经有事务就复用,没有就新建。REQUIRES_NEW则是无论如何都新起一个独立事务,常用于日志记录——主业务失败回滚,但日志希望保留下来。isolation属性可以单独设置本方法的隔离级别,默认跟随数据库。timeout属性控制事务超时时间,超过就回滚;rollbackFor则是用来处理受检异常的回滚问题,默认情况下Spring只对RuntimeException回滚,如果你在一个方法里捕获了Exception并自行吞掉,事务是不会回滚的。
这里我分享一个真实的生产事故。当时有个对账接口,里面循环处理几千条数据,代码大概是:
public void process(List<Data> list) { for (Data data : list) { try { updateData(data); } catch (Exception e) { log.error("单条处理失败", e); } } }process方法上虽然打了@Transactional,但由于异常被吞掉了,事务感知不到任何异常,所以永远不会回滚。更遭的是,由于事务范围覆盖了整个循环,任何一条数据失败虽然被catch住了,但事务并没有回滚,而是继续执行,最终结果就是只有那一条失败,前面的数据其实都已经写进库里了。这种方式并不可控,而且如果循环中某条更新触发了死锁回滚,会导致整个事务回滚,连带之前成功的几千条也一起回滚掉。优化的方案是把循环放在事务外面、单条处理自己开小事务,或者至少降低事务范围到“一批数据一个事务”。
4.2 事务失效的六种场景,面试和实战都会考
事务失效是Spring事务里面最容易踩的坑,我也是被坑过好几回才总结出这个清单。做一个速查表你直接保存就行:
| 失效场景 | 根本原因 | 解决办法 |
|---|---|---|
@Transactional加在非public方法上 | Spring AOP默认只代理public方法 | 改成public方法 |
| 同类内部方法调用(this调用) | 代理不生效,直接走原方法 | 注入自身代理或拆分到另一个Bean |
| 异常被catch住后吞掉 | Spring感知不到异常就不会回滚 | 不吞异常,或手动TransactionAopUtils.currentTransactionStatus().setRollbackOnly() |
| 抛出的是受检异常 | 默认只对RuntimeException回滚 | 用rollbackFor = Exception.class |
| 数据库表引擎不是InnoDB | MyISAM不支持事务 | 换InnoDB |
| 配置了多数据源但切面没有走对事务管理器 | 事务管理器配置不正确 | 明确指定transactionManager |
其中第二条“同类内部方法调用”是隐蔽性最高的。Spring的声明式事务依赖AOP,而AOP代理的机制要求你的方法必须通过代理对象调用才有效果。如果你在同一个类里写了一个方法A调用了另一个方法B,这个调用走的是this指向的原对象,完全没有经过代理,B上的@Transactional就变成了摆设。
解决方案有两种:一是给B方法单独拆到另一个Spring Bean里,注入进来再调用;二是在类里注入自己(利用@Autowired注入本类代理对象),然后通过代理调用。第一种方案更清晰,我一般推荐第一种。
4.3 分布式事务:订单与库存的经典一致性难题
热度很高的“订单与库存分布式事务”这个话题,本质上是单机事务能力不够用了之后的自然延伸。
先说为什么单机事务解决不了。订单和库存往往分别在两个独立的数据库里(甚至是两个微服务各自管一套库),MySQL的单机事务只能保证同一个数据源内部的操作一致,跨库操作在单体事务里是管不住的。传统的两阶段提交(2PC)在跨服务场景下存在同步阻塞问题——每个参与者都要等待协调者通知才能提交或回滚,一旦协调者所在节点宕机,所有人都卡住。
目前业界更主流的是最终一致性方案,典型代表是本地消息表和事务消息。以订单-库存场景举例,下单时先在本地数据库写订单记录,同时插入一条“扣减库存”的消息到本地消息表。这两个操作用本地事务保证原子性。然后有一个定时任务扫描本地消息表,把没发送成功的消息投递到MQ;库存服务消费MQ后执行扣减,执行成功后回调通知订单服务更新消息状态。如果扣减失败,MQ的重试机制会继续重试,或者进入死信队列由人工介入处理。
这套方案牺牲了强一致性,换来的是高可用和高吞吐。业界还有个常被讨论的Saga模式,把一个长事务拆成多个本地事务,每个本地事务都有对应的补偿操作。订单失败了就去补偿(逆向操作)库存,扣库存失败了就反向释放已扣的资源。
关于分布式事务,我有一条很务实的建议:能靠业务设计规避的就别上分布式事务框架。比如很多场景用幂等键、状态机、对账机制就能解决问题,非要引入Seata这类框架,会带来额外的部署运维成本和性能损耗。我在实际项目中就见过有些订单状态流转不小心引入分布式事务后,反而因为框架自身的协调开销把原本只有几十毫秒的接口拖到了几百毫秒。
5. 常见问题与排查技巧实录
5.1 我遇到过的高频事务问题
做了这么多年MySQL排查,总结几个出现频率最高的问题场景,直接做成速查表:
| 现象 | 排查方向 | 快速解决 |
|---|---|---|
| 事务执行到一半报“Lock wait timeout exceeded” | 查看information_schema.innodb_trx里是否有事务长期未提交 | 杀掉长事务:KILL TRX_ID对应的连接 |
| 死锁报错“Deadlock found” | 执行SHOW ENGINE INNODB STATUS查死锁现场 | 调整SQL执行顺序,统一加锁顺序 |
UPDATE卡住,perf里全是等待 | 检查是否有事务未提交 | 看未提交事务的SQL,联系对应开发处理 |
| 事务没有回滚,数据被污染 | 确认代码里是否catch住了异常 | 检查@Transactional的rollbackFor配置 |
| 多节点部署下重复出现的重复数据 | 检查是否用了非幂等的写操作 | 引入唯一索引或者幂等键 |
5.2 几条实用排查命令
排查事务问题时,我最常用的几条SQL几乎形成了肌肉记忆,分享给各位:
-- 查看当前所有正在运行的事务 SELECT * FROM information_schema.innodb_trx; -- 查看锁等待情况 SELECT * FROM performance_schema.data_lock_waits; -- 查看当前事务持有的锁 SELECT * FROM performance_schema.data_locks; -- 杀事务(先找到trx_mysql_thread_id) KILL <trx_mysql_thread_id>;实际排查时,一般流程是先看innodb_trx里有没有执行时间特别长的事务,比如trx_started显示是几小时以前的,那基本可以断定是这个长事务持有了锁,导致后续所有对该表的操作都在排队等锁。找到后联系业务方确认,确实没有用的话直接KILL掉连接。
有个小技巧值得分享:查看innodb_trx时,重点关注trx_rows_modified这个字段,如果值很大说明它改了很多行,还没提交,这就意味着它持有大量行锁,对其他事务的阻塞面非常广。
5.3 实操中的几条独家心得
第一条心得:开发环境就要把事务问题暴露出来,别等上线再查。我见过太多团队在开发库用Navicat手动提交,导致事务长期不散,到了生产环境一并发立刻原形毕露。建议项目从一开始就配置好性能监控,把锁等待和死锁指标给到DBA和开发双端。
第二条心得:避免在事务里做外部调用(RPC、HTTP、Redis写)。道理很简单,外部调用的耗时不可控,如果那个外部服务变慢了,你的事务就得一直开着、锁一直持有着,直接影响当前表的所有并发操作。正确姿势是:先更新数据库,提交事务,再发MQ或RPC通知,配合对账任务来处理发送失败的情况。
第三条心得:SQL的条件列必须有索引,这件事再怎么强调都不过分。一条UPDATE没走索引会升级成锁全表,几个并发就直接把数据库打挂。排查时遇到事务相关的事故,我第一步永远是看SQL的执行计划。养成写完条件查询就顺手EXPLAIN的习惯,能帮你躲掉一半的线上事故。
第四条心得:关注autocommit的坑。MySQL默认是开启自动提交的,也就是说在不显式BEGIN的情况下,每一条SQL都会自动提交。问题出在这个设置会作用和连接绑定——如果你用连接池,一个坑人的操作就是某处代码开启了事务却忘记提交,连接归还到池子里后,下一个人拿到这条连接时事务状态是脏的。虽然MyBatis等框架一般都会在逻辑前重置连接,但排查问题时如果发现“奇怪的事务行为”,可以先看看连接池的配置和连接状态。
最后关于MySQL版本的提醒:不同版本的MySQL在事务控制细节上是有差异的。比如MySQL 5.7对MVCC的处理和8.0有些微差别,8.0还引入了一些新的锁机制(比如不可见索引、备用事务相关参数等)。写代码和排查问题时先看清版本,别拿老经验硬套新环境。现在新项目我一般建议直接用8.0系,毕竟8.0才是最活跃迭代的版本,5.7已经步入维护尾声状态。