
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 READMySQL默认隔离级别。它保证同一个事务里同一条查询语句每次查出来的结果一致即使其他事务提交了修改也看不到除非当前事务自己提交后才看得到变化。这个级别下脏读和不可重复读都解决了但理论上还会有幻读的问题。串行化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.0binlog默认是row格式其实已经不怕这个了但默认隔离级别依然保留为可重复读因为这套机制已经被验证是稳的而且很多业务逻辑写的时候就默认了这个行为贸然改变影响面太大。3. MVCC与锁隔离级别的两套底层引擎3.1 MVCC如何实现快照读MVCC全程是Multi-Version Concurrency Control多版本并发控制。它做的事很简单每行数据不只存当前值还保留历史版本读的人根据自己的事务ID决定看到哪个版本。InnoDB在每行记录后面隐藏了两个字段trx_id记录最近一次修改这行数据的事务IDroll_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 PROCESSLISTEXPLAIN执行计划给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_isolationMySQL 8.0或SELECT global.tx_isolationMySQL 5.xSESSION级别同理。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的实现原理是没变的。把这套原理吃透遇到什么场景都不会慌。