MySQL事务这块,几乎是后端开发绕不过去的坎儿。面试会问,线上出问题排查要懂,平时写代码更是天天打交道。但说实话,我见过太多人把事务简单理解成“要么全成功,要么全失败”,等真遇到并发数据错乱、死锁、大事务拖垮主库的时候,才意识到自己对事务的理解还停留在表面。
这篇东西我不打算给你念文档,就按我这些年实际踩坑、调优、和DBA扯皮的经验,把MySQL事务从头到尾捋一遍。从ACID到底怎么落地,到四种隔离级别分别防住了什么,再到MVCC和锁是怎么配合工作的,最后聊聊Spring事务注解那些让人头大的坑,以及分布式事务到底是怎么回事。如果你正准备面试,或者线上出了数据一致性问题不知道从哪下手,这篇应该能帮你省不少时间。
1. 事务的本质:不只是“要么成功要么失败”
1.1 ACID到底是怎么落地的
很多教程一上来就抛ACID四个字母,但这四个特性并不是平级的概念。简单拆一下你就能看清它们之间的关系。
原子性保证的是“做就做完,不做就不做”,这靠的是undo log。事务里每改一条数据,InnoDB都会先把旧值写到undo log里,一旦中途出错,就顺着undo log把数据回滚成原来的模样。这就像你写文档先开个历史版本,改坏了还能退回旧版。
一致性是最终目标,但数据库本身并不直接“维护”一致性,它只是提供了约束和事务机制,真正保证业务逻辑一致性的,是你怎么写代码。比如转账场景里“扣钱”和“加钱”两个操作必须同时成功或同时失败,这是原子性帮你兜底;但如果你业务代码只扣了钱忘了加钱,数据库是不知道的。
隔离性解决的是多个事务同时跑的时候互相干扰的问题,这是锁和MVCC干的活,后面单独展开。
持久性最简单,靠的是redo log。每次提交事务,数据页的修改会先写进redo log,再异步刷到磁盘。就算数据库突然宕机,重启之后也能靠redo log把已提交的数据捞回来,不会丢。
1.2 一个经典场景看清事务的边界
用一个最经典的电商下单场景来说:用户下单时,需要扣库存、生成订单、扣减账户余额。这三个操作不在同一张表里,甚至可能不在同一个服务里。如果不用事务,扣完库存之后生成订单那一步报错了,库存就凭空少了一件,用户钱也没扣,订单也没有——这是彻底的脏数据。
用了事务之后,三个操作打包成一个原子操作,任何一个失败都会把前面的操作回滚掉。这就是事务存在的意义:把多个步骤变成一个整体,对外表现为“要么全发生,要么全不发生”。
但这里有个很多人忽视的点:事务的边界在哪里?什么时候开启、什么时候提交、事务里执行了哪些SQL,这些都是有讲究的。尤其是在Spring里用@Transactional,默认是方法级事务,一个方法里所有的数据库操作都在同一个事务里。但如果方法里调了别的方法,或者被this调用绕过了代理,事务边界就会变得很微妙,这个后面细说。
2. 四种隔离级别:你到底能看到多少“别人”的数据
2.1 隔离级别怎么影响数据可见性
隔离级别解决的核心问题就一个:事务A执行到一半,事务B能不能看到A的中间状态?不同的级别决定了你愿意承担什么样的数据不一致风险。
- 读未提交(READ UNCOMMITTED):事务A改了数据还没提交,事务B就能读到。这会导致脏读——读到别人还没定下来的数据,一旦A回滚,B就拿着一个不存在的账本在做事。
- 读已提交(READ COMMITTED):只能读到已提交的数据。脏读解决了,但会出现不可重复读——同一条SQL在同一个事务里跑两次,结果不一样,因为两次之间别人把数据改了并提交了。
- 可重复读(REPEATABLE READ):MySQL默认隔离级别。它保证同一个事务里,同一条查询语句每次查出来的结果一致,即使其他事务提交了修改也看不到,除非当前事务自己提交后才看得到变化。这个级别下,脏读和不可重复读都解决了,但理论上还会有幻读的问题。
- 串行化(SERIALIZABLE):所有事务排着队来,读写互相阻塞,隔离性最强但并发能力最差,生产环境几乎不用。
2.2 脏读、不可重复读、幻读到底有什么区别
这三个概念是面试高频题,但很多人背了定义还是分不清。
脏读最容易理解,就是读到别人“没定稿”的数据。比如你现在看这篇文章,作者还在改,你看到的是半成品,这就是脏读。只要隔离级别是读已提交以上,脏读就不会发生。
不可重复读关注的是“同一行数据”被改了。事务A查了一条记录余额是100,事务B把余额改成200然后提交了,事务A再查这条记录,发现变成200了。在一个事务里,同一条记录前后两次读到不同的值,这就是不可重复读。它的本质是行更新导致的结果变化,普通行锁要锁到事务结束才能彻底防住。
幻读关注的是“一批数据”变多了或变少了。事务A查出一个符合条件的记录列表,事务B插入了一条新记录还提交了,事务A再查一遍,发现多了一行。多出来的那行就像幻影一样。不可重复读是行内容变了,幻读是结果集变了,这是本质区别。
MySQL的默认级别是可重复读,InnoDB用间隙锁(Gap Lock)解决了幻读问题。但要注意,这个解决是有条件的——只在当前读(SELECT ... FOR UPDATE、UPDATE、DELETE)下有效,普通快照读是靠MVCC保证一致性快照的。
2.3 MySQL的默认级别为什么是可重复读
这是个很有意思的问题。Oracle的默认隔离级别是读已提交,但MySQL偏偏选了可重复读。究其原因,和MySQL的复制机制有关系。在MySQL 5.0及之前的时代,binlog只有statement格式,即记录SQL语句本身。这种情况下,如果隔离级别是读已提交,意味着一个事务里两次查询可能看到不同的数据,那statement格式的binlog在从库上重放的时候,就可能产生和主库不一致的结果。为了让主从复制不出幺蛾子,MySQL就把默认隔离级别定成了可重复读,配合间隙锁把幻读也一并挡住。
到了MySQL 8.0,binlog默认是row格式,其实已经不怕这个了,但默认隔离级别依然保留为可重复读,因为这套机制已经被验证是稳的,而且很多业务逻辑写的时候就默认了这个行为,贸然改变影响面太大。
3. MVCC与锁:隔离级别的两套底层引擎
3.1 MVCC如何实现快照读
MVCC全程是Multi-Version Concurrency Control,多版本并发控制。它做的事很简单:每行数据不只存当前值,还保留历史版本,读的人根据自己的事务ID决定看到哪个版本。
InnoDB在每行记录后面隐藏了两个字段:trx_id记录最近一次修改这行数据的事务ID,roll_pointer指向undo log里的旧版本链。当一个事务要读取某行数据时,它不会直接拿当前值,而是沿着版本链往回找,找到第一个“对当前事务可见”的版本。
那怎么判断可不可见?这就要看ReadView了。ReadView在事务第一次执行快照读时生成,里面记录了几个关键信息:当前活跃事务ID列表、最小活跃事务ID、最大事务ID。判断规则是:
- 数据行的
trx_id小于最小活跃事务ID,说明这个版本在事务开始前就已经提交了,可见。 trx_id在活跃事务列表里,说明这个版本是其他还没提交的事务改的,不可见。trx_id大于最大事务ID,说明这个版本是在当前事务开始之后才产生的,不可见。
这套机制的好处是:读操作不需要加锁,不会阻塞写操作,并发性能大幅提升。普通SELECT就是快照读,看到的是一个一致性快照,永远不受其他事务中间状态的影响。
3.2 当前读和锁的配合
快照读不锁,但你执行SELECT ... FOR UPDATE、UPDATE、DELETE这些操作时,必须拿到最新的数据,这叫当前读,它走的是锁机制。
InnoDB的锁分几类:记录锁锁定单条索引记录,必须在索引上才能生效;间隙锁锁定一个区间,不允许其他事务在这个区间里插入新记录,专门防幻读;临键锁是前两者的结合体,既锁记录又锁记录前面的间隙。
加锁的具体过程是这样的:执行UPDATE时,存储引擎会根据WHERE条件扫描索引记录,对命中的记录加X锁(排他锁),对扫描过程中经过但未命中的间隙加间隙锁。这个“扫描过程中经过”很关键——即使你WHERE id = 10,如果id上没有索引,InnoDB会走全表扫描,把整张表所有间隙都锁上,这就是无索引更新把整张表锁住的根源。
不同隔离级别下锁的力度不一样。读已提交只加记录锁,不锁间隙,所以幻读可能发生;可重复读下,加锁的SELECT、UPDATE、DELETE都会自动加上间隙锁,挡住幻读。你在RR级别下执行SELECT ... FROM t WHERE id > 5 FOR UPDATE,不仅锁住已有的id大于5的记录,还会锁住id在5之后的整个区间。后面的会话想插入id为10的记录,会被阻塞住,直到这个事务提交或回滚。
3.3 死锁是怎么产生的,怎么解决
两个事务互相持有对方需要的锁,就会形成死锁。经典场景是:事务A先更新表t1再更新表t2,事务B先更新表t2再更新表t1,两边同时执行到第二步,谁都没法往下走。
MySQL对死锁的处理是:检测到死锁后,回滚其中一个事务,让另一个继续执行。你会在应用日志里看到Deadlock found when trying to get lock; try restarting transaction的报错。
死锁排查的思路是固定的:先用SHOW ENGINE INNODB STATUS查看最近一次死锁的详细信息,里面会明确标出两个事务各自持有什么锁、在等什么锁。然后看业务代码里是不是有多个表的加锁顺序不一致,统一成相同的顺序;检查是不是有长事务长时间占着锁不放;看是不是某个UPDATE的WHERE条件没走索引,导致间隙锁范围过大。
有一点实测下来很管用:死锁不一定全是Bug,它可能是高并发场景下的正常现象。如果你的业务逻辑确实无法避免交叉加锁,那就在代码里做好重试机制,捕获死锁异常后重试整个事务,这比指望数据库避免死锁靠谱得多。
4. 事务注解的正确打开方式:Spring @Transactional 的隐藏陷阱
4.1 注解貌似简单,坑可不少
Spring的@Transactional把事务管理变得极其简单,一个注解丢上去就完事。但越简单的东西,出问题的时候越让人抓狂。我整理了几个高频失效场景,网上相关“事务注解”的搜索量一直高居不下,说明踩坑的人不在少数。
第一个是自调用问题。同类内部this.method()调用,方法上的@Transactional不生效。原因在于Spring事务是基于AOP代理实现的,只有通过代理对象调用方法,事务拦截器才会介入。this指向的是原始对象,不是代理对象,事务直接就没了。解决方法是注入自身代理,或者把需要事务的方法拆到另一个Bean里。
第二个是方法不是public。Spring的事务默认只对public方法生效,protected、private方法就算标了注解也不会加事务。这个在文档里有写,但很多人根本没注意到。
第三个是用try-catch吞了异常。这是最阴间的坑之一。代码里把异常捕获了,自己处理掉,没有往外抛,Spring的事务拦截器根本不知道出了事,自然就不会回滚。记住,事务回滚的前提是异常能传到代理层。要么别吞异常,要么在catch块里手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。
第四个是rollbackFor没设对。@Transactional默认只在遇到RuntimeException和Error时回滚,检查异常默认不回滚。你一个业务方法抛了个Exception,事务照样提交,数据就脏了。阿里开发规范里明确要求rollbackFor = Exception.class,这是有道理的。
4.2 事务传播行为:嵌套事务的江湖规矩
事务传播行为解决的是“当前已经在一个事务里了,又调了一个带事务注解的方法,这怎么办”的问题。Spring定义了七种传播行为,实际开发中最常用的是这三种:
- REQUIRED:默认值。如果当前有事务就直接加入,没有就新建一个。大多数情况用它就对了。
- REQUIRES_NEW:不管当前有没有事务,都新开一个独立事务,外层事务挂起。适合日志记录这种“就算主流程挂了,日志也得写进去”的场景。
- NESTED:如果当前有事务,则创建一个Savepoint作为嵌套事务,内层回滚不影响外层事务的提交,外层回滚会带动内层一起回滚。
这里我得提一个常见的认知误区:REQUIRES_NEW和NESTED看起来很相似,实际差别很大。REQUIRES_NEW是完全独立的物理事务,内层提交了就不会再跟着外层回滚;NESTED仍然是同一个物理事务,只是逻辑上建了一个保存点,内层回滚只回滚到保存点的位置。你要是把日志记录的方法设成REQUIRES_NEW,那主流程挂掉之后日志还是提交了;如果设成NESTED,主流程整体回滚时日志也会被回滚掉。这两种行为对业务的影响完全不一样,选之前要想想你到底要哪种。
4.3 大事务是性能杀手
事务不是越大越好,恰恰相反,事务越大,持有锁的时间越长,锁定的数据越多,阻塞其他事务的概率越大。一个典型的反面教材:在事务里循环查Redis、调外部API、做耗时的计算,数据库连接被你白白占着,锁还压着其他事务,整个系统的吞吐量就被这种大事务拖垮了。
解决方案不复杂:把事务控制在只包含必要的数据库操作。查询放到事务外面,远程调用挪到事务提交之后,事务里的循环改成批量操作。一句话,事务里能干的事越少越好,越快提交越好。
5. 常见问题与排查实录
5.1 一张表帮你理清排查方向
| 现象 | 可能原因 | 排查工具/命令 | 解决思路 |
|---|---|---|---|
| 死锁 | 多个事务加锁顺序不一致 | SHOW ENGINE INNODB STATUS | 统一加锁顺序,或加入重试机制 |
| 事务不回滚 | 异常被吞掉/rollbackFor未设置 | 检查代码异常处理逻辑 | 不吞异常,显式设置回滚条件 |
| 事务不生效(注解) | 自调用/非public方法 | 检查调用链和代理方式 | 通过代理调用,拆到独立Bean,改public |
| 更新阻塞 | 无索引更新导致间隙锁范围过大 | SHOW PROCESSLIST,EXPLAIN执行计划 | 给WHERE条件加索引 |
| CPU打满 | 慢SQL + 大事务长时间持有连接 | EXPLAIN+ 慢查询日志 | 优化SQL,拆分事务 |
| 数据重复插入 | 并发下唯一性约束没兜住 | 查看业务日志时间线 | 加分布式锁或数据库唯一索引 |
| 主从延迟 | 从库跟不上主库写入速度,长事务加剧 | SHOW SLAVE STATUS里的Seconds_Behind_Master | 缩短事务耗时,排查锁竞争 |
5.2 一个线上案例:库存超卖的真相
有一年我做电商项目,上线第一天就发现库存偶尔会变成负数。当时第一反应是代码逻辑问题,后来把日志翻出来才发现,是并发下单时两个事务同时读到库存为1,各自扣减后都写入0,最后库存剩0但订单生成了两笔。
这个问题的本质是并发安全问题,单靠事务隔离级别解决不了。可重复读只能保证读的一致性,但普通SELECT是快照读,两个并发事务都能读到同一个快照,不会互相阻塞。解决方式有两种:一是用SELECT ... FOR UPDATE把库存这行锁住,让第二个事务等第一个提交后再读,读到的是最新的值;二是用UPDATE t SET stock = stock - 1 WHERE id = ? AND stock >= 1这种原子语句,让数据库自己去判断。
我后来用的是第二种,配合受影响行数判断。执行完发现影响行数为0,说明库存不够,直接返回“库存不足”。这种方式不用显式加锁,代码更简洁,性能也更好。加锁要小心死锁问题,高并发场景下锁的粒度越大越容易出事。
5.3 聊到停不下来的分布式事务
提一嘴分布式事务,因为微服务架构普及之后,单机事务解决不了跨服务的数据一致性问题了,这也是最近一直有人在搜分布式事务的原因。分布式事务的常见方案有几种:两阶段提交(2PC)适合强一致场景但性能差;TCC模式将业务拆成Try/Confirm/Cancel三个阶段,灵活但要写大量补偿代码;消息最终一致性方案用本地消息表或事务消息,允许短暂不一致但最终数据对齐,是大部分业务的实用选择;Seata作为成熟的框架,提供了AT模式,无侵入地实现分布式事务,底层靠全局锁加undo log回滚。
我对分布式事务的态度是:能不用就不用,用的话优先选最终一致性方案。强一致的分布式事务,比如2PC,性能和可用性代价太高,除非是资金类、核心订单这类不妥协的场景,否则没必要。大多数业务场景下,通过状态机加对账补偿,足以可靠地保证数据最终一致。
6. 事务实战:从配置到调优的完整路径
6.1 事务相关的配置和状态查询
写代码是一回事,真正要处理线上问题,你最少得掌握几个排查命令。
查看当前所有连接和正在执行的事务:SELECT * FROM information_schema.INNODB_TRX,这个表能告诉你哪些事务在跑、跑了多久、在等什么锁。配合SHOW PROCESSLIST能看到每个连接当前执行的SQL。
查看锁等待情况:SELECT * FROM sys.innodb_lock_waits,遇到锁等待问题,这个视图直接告诉你谁在等谁。
查看InnoDB引擎状态:SHOW ENGINE INNODB STATUS,死锁详细信息、当前事务列表、锁信息都在这里。
查看全局事务隔离级别:SELECT @@global.transaction_isolation(MySQL 8.0)或SELECT @@global.tx_isolation(MySQL 5.x),SESSION级别同理。
6.2 隔离级别的调整时机
默认的可重复读已经解决绝大多数问题,但某些极端场景下调成读已提交反而更好。比如一个报表系统,数据量巨大,频繁有并发更新,业务上又不要求同一事务内重复读一致。这种情况下,读已提交可以少加间隙锁,减少锁冲突,提升并发度。代价是同一事务内两次查询结果可能不同,你得在业务层面接受这一点。
有一个迁移场景值得聊一下:从Oracle往MySQL迁,如果原系统依赖的是读已提交特性,那MySQL端也可以把隔离级别设成读已提交,但这时候要注意幻读问题可能重新冒出来,因为读已提交下InnoDB不会加间隙锁。我自己在迁移类似系统时,一般会在SQL层面加上条件约束或唯一索引来兜底,而不是盲目依赖隔离级别。
6.3 事务与索引的关系
很多人忽略了一个关键点:锁是加在索引记录上的。如果你的表没有索引,或者UPDATE的WHERE条件用不上索引,InnoDB只能全表扫描找到目标记录,这意味着所有扫描过的记录都会被加锁,间隙锁的范围也会无比巨大。效果上约等于锁住了全表,并发直接打对折。
所以建索引不只是优化查询速度,它直接影响事务并发的锁粒度。这也是为什么生产环境几乎要求每个WHERE条件涉及的字段都有合适的索引。反过来讲,索引也不是越多越好——索引本身需要维护,写多读少的表索引太多会导致写入变慢,进而拉长事务执行时间,间接增加锁竞争。
6.4 关于事务的一些经验心得
回到开头那个问题:为什么说理解事务不能只停留在“要么全成功,要么全失败”?
因为真实世界比这个复杂得多。并发场景下,隔离性决定了你的数据有多“干净”,锁策略决定了系统的吞吐量,MVCC让你在性能和一致性之间找到平衡点。不同的隔离级别、不同的索引设计、不同的事务边界,组合在一起产生了千变万化的行为。我在实际排查线上问题时养成了几个习惯,分享出来供你参考:
新写的SQL先EXPLAIN一遍,确认走了索引,再上线。绝大多数锁问题和慢查询,根源都是没走索引。
写事务方法时,顺一遍里面有没有外部调用、有没有循环、有没有不需要在事务里做的操作。没有最好,有就拆出去。
线上碰到死锁,先别急着改代码,用SHOW ENGINE INNODB STATUS看完整锁信息,确认是业务逻辑问题还是SQL设计问题,针对性解决。
还有一个容易被忽略的点是事务超时时间。默认的transaction_timeout是60秒还是120秒取决于具体配置,但长事务一旦超过,会导致事务被自动回滚,这也是业务报错的一个隐藏来源。排查问题时要记得结合错误日志的具体报错时间和MySQL的日志一起看,才能定位到真实原因。
事务这块内容太多了,一篇文章根本写不完。如果你是在准备面试,隔离级别、MVCC原理、死锁排查这三块建议重点看;如果是线上出了问题,先看连接数、锁等待、慢查询这三个指标,基本能定位大部分问题。MySQL本身也在不断演进,从MySQL 5.7到8.0,新增了不少与事务相关的特性,比如transaction_isolation动态变量、更精细的锁信息等,这些新特性在实际问题排查中帮了我不少忙。但万变不离其宗,底层那套ACID的实现原理是没变的。把这套原理吃透,遇到什么场景都不会慌。