
1. 事务是什么为什么绕不开聊MySQL事务是绝对躲不开的一个话题。不管是做订单系统、库存管理、支付流程还是简单的用户注册只要涉及数据一致性你早晚要和事务打交道。就算日常CRUD写得再多对事务的理解深度也直接决定了你在团队里是写业务代码的还是排查数据问题的。简单说事务就是一组SQL操作的集合这些操作要么全部成功要么全部失败回滚。我举个最直白的例子转账。A账户扣100块B账户加100块如果扣款成功但加款失败这钱就凭空消失了。把这两个操作放进一个事务里要么都完成要么都回滚数据永远处于合理状态。这么说有点抽象你把它想成一个快递包裹的发货流程——扫描出库、装车、更新物流信息这三步必须全部完成顾客才会正常收到货。如果扫描出库了但装车失败包裹就处于不知道在哪儿的异常状态必须整套流程退回重来。事务就是给数据库操作安装的这套全有或全无的保险机制。从MySQL 5.5开始InnoDB就是默认存储引擎也是唯一支持完整事务语义的引擎。MyISAM虽然还在用但它连事务都不支持一个update执行一半断电了表就等着修复吧这在生产环境是不可接受的。这篇文章我会从ACID特性讲起把四种隔离级别、锁机制和MVCC的实现原理拆开揉碎最后落到实际开发中最容易踩的坑上。不管你是刚接触数据库的初学者还是已经写了几年业务代码的开发者读完应该都能对MySQL事务有一个完整的认知框架。2. 四个必须知道的特性ACID事务的核心价值就体现在ACID这四个字母上。Atomicity原子性、Consistency一致性、Isolation隔离性、Durability持久性这四个特性不是独立的它们是互相配合才保证了数据安全。2.1 原子性和一致性原子性说的是事务里的操作不可分割要么全做要么全不做。MySQL实现原子性的关键是undo log也就是回滚日志。每当你执行一条修改语句InnoDB除了更新数据和缓存还会把修改前的数据写到undo log里。如果事务中途出错或者你手动ROLLBACK就按undo log的记录把数据还原回去。一致性指的是一条规则事务执行前后数据必须满足所有约束。比如账户余额不能为负数订单数量不能超过库存。这和原子性有什么关系原子性保证不会出现半截操作的状态一致性保证即使并发执行业务规则也不被破坏。可以说原子性是手段一致性是目的。一个很常见的一致性破坏场景你用MyISAM引擎先扣库存再创建订单第二个SQL失败库存却已经扣掉了。这种问题在InnoDB加事务的场景下就不存在——扣库存和建订单在同一个事务里任何一个失败都会把这批操作一起撤销。2.2 隔离性和持久性隔离性解决的是并发问题。多个事务同时操作同一批数据时彼此之间应该隔离到适度程度不能读到对方未提交的半成品数据。这个特性的具体实现程度由隔离级别决定后面会详细展开。持久性最容易理解事务一旦提交成功数据就必须是永久的哪怕瞬间断电、宕机也不丢。InnoDB靠的是redo log加双写机制。你可能听说过先写日志后写数据这句话指的就是WALWrite-Ahead Logging策略。事务提交时先保证redo log落盘数据文件可以晚点刷——因为崩溃恢复时可以根据redo log把没来得及刷盘的操作重放一遍。这里有个常见的误解很多人以为持久性每次提交都刷数据文件其实不是。InnoDB是先把变更记录到redo log这个日志是顺序写的速度极快而数据文件是随机写性能代价高得多。所以持久性的关键是redo log先落盘而不是数据文件。2.3 隐含的注意点隔离级别不是越高越好隔离性是ACID里最需要权衡的部分。隔离级别越高并发能力越低。可串行化SERIALIZABLE能解决所有并发问题但代价是性能断崖式下降——因为它对每一行读都加锁。大多数业务场景选择可重复读REPEATABLE READ或读已提交READ COMMITTED这个取舍后面细说。3. 四种隔离级别每种的坑都不太一样SQL标准定义了四种隔离级别MySQL的InnoDB都支持。它们解决的问题层层递进但也各有取舍。3.1 读未提交几乎不敢用读未提交是最低的隔离级别允许事务读取其他事务未提交的修改。这会导致脏读事务A改了一行数据还没提交事务B就把这个未提交的中间状态读走了然后A回滚了B手里拿着的是根本不存在的幽灵数据。生产环境几乎没人用这个级别除非是做一些完全不在意数据准确性的统计类查询而且还得专门设置。我印象里唯一见到过的场景是某些报表的临时探查但即便是那种低频任务也很有可能因为脏读导致报表里出现对不上的数字排查起来极为痛苦。3.2 读已提交最通用但小心不可重复读读已提交解决了脏读——事务只能读到其他事务已提交的数据。这个级别是Oracle和PostgreSQL的默认级别很多从Oracle迁MySQL的老项目迁移后MySQL默认是可重复读业务逻辑上就会出现一些微妙的不一致现象。读已提交的问题是会产生不可重复读同一个事务里两次相同的SELECT可能返回不同的结果。事务里先查库存有100件期间另一个事务提交了扣减操作你再去查变成90件了。如果在事务里先判断再操作就可能基于过期的数据做出错误决策。3.3 可重复读MySQL默认的底气可重复读解决了不可重复读的问题事务启动后在同一个事务里多次查询同一数据返回一致的结果。InnoDB这里不只是靠锁更重要的是靠MVCC——多版本并发控制。每行数据都有多个历史版本事务通过视图Read View确认自己能看到哪个版本的数据修改只对最新版本做查询读的是符合自己视角的快照版本。很多对MySQL不熟的人会问可重复读是不是就能对付幻读了答案是不完全。在InnoDB里可重复读配合间隙锁Gap Lock和临键锁Next-Key Lock确实把标准的幻读问题也一起搞定了。但如果你把隔离级别调到读已提交幻读就可能复现。3.4 可串行化最安全也最慢可串行化把所有读操作都变成锁定读相当于给每个操作都加了共享锁或排他锁事务之间彻底排开串行执行。这能解决所有一致性问题但数据库的并发吞吐会掉到惨不忍睹的程度。如果统计线程池监控发现某个库的事务等待时间异常高而隔离级别又是可串行化八成就是这个配置在拖后腿。实际上如果你的事务逻辑在可重复读下都还会出现并发冲突一般是业务设计不合理比如两条数据互相交叉更新这种情况加上可串行化也只能掩盖问题不能根治。隔离级别脏读不可重复读幻读读未提交READ UNCOMMITTED会会会读已提交READ COMMITTED不会会会可重复读REPEATABLE READ不会不会不会InnoDB配合锁解决可串行化SERIALIZABLE不会不会不会3.5 怎么选隔离级别的实战取舍我见过不少团队用默认的可重复读就再没动过也有团队因为历史遗留原因从Oracle迁过来后把隔离级别调到读已提交。这两者没有绝对好坏看业务场景金融、账务系统可重复读更稳妥。事务里几次查询结果必须一致中间不能因为别人提交影响判断。高并发互联网应用读已提交可能更好锁竞争更小吞吐更高。特殊统计场景单独会话设置读未提交做探查可以但别写入正式任务里。补充一点修改隔离级别可以用SET TRANSACTION ISOLATION LEVEL也可以直接在Spring的Transactional(isolation Isolation.REPEATABLE_READ)里指定。全局修改要谨慎单独事务指定更灵活。4. 事务的底层实现锁与多版本并发控制两条腿走路隔离级别只是行为层面的定义真正落地靠的是InnoDB的锁机制和MVCC机制。这两者配合事务才能在并发环境里既保证隔离又保证性能。4.1 行级锁、间隙锁与临键锁锁到底锁了什么InnoDB支持行级锁和表级锁其实还有意向锁这种东西平时开发几乎感知不到但它是行锁和表锁之间的协调员——一个事务想给表加锁得先确认有没有行锁在占用意向锁用来快速判断这类冲突。行级锁有共享锁S锁和排他锁X锁两种。共享锁允许其他事务也加共享锁来读同一行排他锁则阻止一切其他S锁或X锁。SELECT ... FOR UPDATE就是加X锁用来锁住一批要更新的行防止其他事务并发改。LOCK IN SHARE MODE加的是S锁适合那种别人不能改但我能一起读的场景。间隙锁是InnoDB可重复读隔离级别下防幻读的核心工具。它锁的是索引记录之间的空隙——比如表里有id为10和20的两行间隙锁可以锁住(10,20)这个区间让其他事务没法往这个区间里插入id15的新行。临键锁就是记录锁间隙锁的组合锁住一个索引记录同时锁住这个记录之前的间隙。这也是可重复读级别下默认的锁定方式。当你执行WHERE id BETWEEN 10 AND 20 FOR UPDATEInnoDB会锁住匹配行还会锁住附近间隙阻止其他事务在范围内插入数据。4.2 MVCC读不锁写不堵靠版本链和Read ViewMVCC是InnoDB的另一条腿。它的核心思路是保存数据的多个版本读操作访问旧版本快照写操作生成新版本从而实现读写互不阻塞。每个被Modify的行都有一个隐藏列叫DB_TRX_ID记录最后修改该行的事务ID。还有一个DB_ROLL_PTR指针指向undo log里的旧版本记录这些旧版本串成一条版本链。事务执行读操作时会生成一个Read View里面记录了当前活跃的所有事务ID列表由此判断版本链上哪个版本是可见的。规则大致是如果版本的事务ID比Read View里最小的活跃事务ID还小说明这个版本已经提交了可以读如果在活跃事务ID列表里说明还没提交不能读如果比最大活跃事务ID还大说明是未来事务更不能读。具体规则稍微绕但思路就是我只能看到在我查询之前就已经提交完的数据。4.3 当前读与快照读别搞混了MVCC的快照读是一般SELECT的行为不加锁读历史版本。但UPDATE、DELETE、SELECT ... FOR UPDATE走的是当前读读的是最新版本同时需要加锁。我遇到过不少开发踩过这坑事务里先SELECT判断数据状态然后UPDATE结果两条记录之间别的会话把数据改了UPDATE影响行数比你判断的还多程序却不知道。原因就是判断用的快照读更新用的当前读两者看到的数据版本不一样。解决办法是判断和更新统一用当前读比如第一步就SELECT ... FOR UPDATE锁住记录。4.4 undo log和redo log各自管什么undo log管回滚和MVCC版本链事务回滚时用它恢复旧值MVCC读旧版本时也用它。redo log管持久性事务提交时把物理修改记录追加到redo log文件崩溃恢复时重放它。这两者的分工就是原子性和隔离靠undo持久性靠redo。5. 实战把事务用对不光只是加个注解理清了底层机制再来看看业务代码里最常遇到的实际问题。我以Java/Spring应用举例毕竟这是MySQL最主流的搭档。5.1 事务的传播行为团队翻车重灾区一个事务方法调用另一个事务方法传播行为决定了它们怎么协作。REQUIRED是默认值如果当前已有事务就加入没有就新建。REQUIRES_NEW则无论有没有都新开一个独立事务。很多问题出在用REQUIRES_NEW时没注意外层抛异常会连内层一起回滚。举个例子一个批量群发通知任务主事务处理业务数据单个通知发送失败不应该让整个批量任务全部失败有些人会在这里用REQUIRES_NEW让每个通知独立事务这个思路本身没错。但要注意通知内容的数据源如果也在主事务里修改REQUIRES_NEW事务一开启默认读不到主事务未提交的修改查出来就是旧数据。这种问题排查起来比较费劲需要同时考虑事务隔离和传播两个维度。还有NESTED和REQUIRED的差异NESTED利用savepoint实现部分回滚可以只回滚内层事务外层不受影响。但MySQL对嵌套事务的支持并没有那么直观性能也不好我个人建议能不用就不用从业务逻辑上拆开更稳妥。5.2 事务失效的7个常见场景给方法加个Transactional注解就万事大吉远不是这样。事务失效在面试题里是高频考点在实际开发中更是坑中之坑。第一方法必须走Spring代理。同类内部方法调用this调用不走代理注解直接失效。解决方案是注入自身、拆到别的Bean里或者用TransactionTemplate编程式事务。第二异常被吞。Spring默认只回滚运行时异常和Error检查异常比如IOException默认不回滚。你在catch里把异常吞了或者抛了个检查异常事务就静默提交了。设计上应该让异常尽量往外抛配置rollbackFor明确指定要回滚的异常类型。第三方法非public。Spring的声明式事务基于AOP非public方法无法被代理拦截注解形同虚设。第四数据库引擎不是InnoDB。如果是MyISAM事务特性全部不支持注解白搭。检查表引擎是排查第一步。第五自增主键、DDL、TRUNCATE等操作会隐式提交事务它们没法回滚。在事务里执行这类语句之前的修改就提前提交了。第六多数据源情况下事务管理器没配好一个事务跨多个数据源但只对某一个生效另一个库没事务保护。第七传播行为设置不当导致事务边界不符合预期比如NOT_SUPPORTED会让方法在无事务状态下执行。5.3 分布式事务MySQL事务救不了跨库跨服务如果事务跨越多个数据库甚至多个微服务MySQL的单库事务就不够用了。常见的方案有两阶段提交、TCCTry-Confirm-Cancel、SAGA、本地消息表等。两阶段提交有XA协议实现但协调者单点且同步阻塞问题明显高并发场景不太适合。TCC空回滚、悬挂、幂等三大难题需要精心设计实现成本比较高。我建议优先级是这样优先从业务设计上避免分布式事务比如通过聚合服务、分区键把跨库操作变成本地操作不行再考虑最终一致性比如本地消息表加MQ让两个系统通过消息异步对齐状态最后才考虑强一致的分布式事务框架。这里引出一个重要经验能用单库事务解决的业务绝对不要引入分布式事务框架。分布式事务性能开销、运维复杂度、故障排查难度全都上了一个量级。很多时候看起来不得不分布式的场景重新梳理业务边界后会发现有更廉价的替代方案。5.4 事务里的SQL隐藏操作事务里的SQL也不像看起来那么简单。比如不恰当的SELECT锁定了过多行事务迟迟不提交锁就迟迟不释放后续所有相关操作都在排队。长事务是锁和死锁问题的温床。一个事务执行时间太长持有的锁就越多并发阻塞的概率越大。我见过最典型的场景是一个事务方法里调了外部HTTP接口网络超时拖了30秒整个表的数据更新都被卡住。事务里绝对不要调用远程服务这是教科书级别的禁忌。批量更新时也要注意锁的顺序两个事务都按订单先、库存后的顺序操作就不会死锁相反一个先订单后库存另一个先库存后订单非常容易形成死锁等待。尽量保持所有事务访问资源的顺序一致。6. 常见的锁等待、死锁和隔离问题排查写再多理论不如动手排查一次。这里把我处理过的几个典型线上问题整理出来。6.1 死锁了怎么定位和预防死锁的报错信息通常是Deadlock found when trying to get lock事务会被回滚应用层如果没捕获重试就会直接抛异常。定位死锁的命令是SHOW ENGINE INNODB STATUS可以查看最近一次死锁的详细信息包括两个事务持有的锁和等待的锁。预防死锁的核心手段第一所有事务按固定顺序访问资源第二尽量缩短事务时间减少锁持有时间第三合理设置索引让锁落在少量记录上而不是全表扫描导致锁了过多行第四隔离级别能低就低锁得越少死锁概率越小。应用层做好死锁重试也是生产环境必备。捕获到死锁异常后延迟几十毫秒重试整个事务很多临时性死锁就能自动消化掉不需要人工介入。6.2 锁等待超时多半是长事务Lock wait timeout exceeded这个错误很常见。它表示事务等待锁超过了innodb_lock_wait_timeout配置的阈值默认50秒。排查思路是查当前有哪些事务在跑各自在等什么锁。用information_schema.INNODB_TRX查看正在运行的事务用sys.innodb_lock_waits查看锁等待关系能快速找到阻塞者和被阻塞者。常规手段是用KILL结束阻塞事务或回滚掉超长事务但根治还是要看业务代码里为什么事务开了那么久。6.3 幻读在特定场景下还是会出现InnoDB的可重复读确实解决了幻读但只针对通过索引条件锁定的范围。如果查询条件没走索引InnoDB只能锁全表或锁大量行间隙锁的精确性就大打折扣。另一种情况是读已提交级别下间隙锁根本不起作用幻读自然会出现。所以排查幻读问题时先确认两件事隔离级别是什么SQL是否走了合适的索引。如果一个事务里先查了一部分行判空然后插入新数据结果另一个事务刚好插入了一行导致唯一索引冲突这就属于标准的幻读解决办法是按条件设计好唯一索引或者在业务逻辑里用锁或重试机制兜底。6.4 隔离级别变了还看到旧数据快照问题可重复读下事务内第一次SELECT创建了Read View之后整个事务都用这个快照。如果你之后想看到其他事务新提交的数据得重新开启事务或者使用当前读比如SELECT ... FOR UPDATE。这不是Bug是MVCC的正常表现但很多人一开始不习惯。我的习惯是事务里既要判断数据状态又要更新数据时统一使用当前读避免快照读和当前读混用。简单说凡是判断后还要更新的场景第一步就上锁。7. 事务的监控和优化建议既然事务这么容易出问题生产环境就应该有对应的监控手段和优化策略。7.1 监控什么指标数据库连接数、活跃事务数、锁等待时间这三个指标能反映事务的健康度。performance_schema里的事务表和锁等待表都很有用可以定时采集出报表。经常被忽略的是长事务监控。线上事务超过几秒就算异常需要自动预警通知。很多慢查询的根因就是事务锁等待而不是SQL本身的问题但只看慢查询日志根本看不出来必须依赖事务监控才能发现。7.2 优化事务代码的几个原则事务开启时间要晚提交要早。事务外的准备工作尽量放在事务外做远程调用、耗时操作绝对不能在里面。事务内SQL尽量少查询和更新的数据量尽量小批量操作考虑分批提交。另外要特别注意一个事务的边界就是锁的范围边界。事务里的每一个SELECT都可能带锁像SELECT ... FOR UPDATE尤其慎重确认确实需要锁住更新才用。autocommit默认开启是好事业务代码里手动关闭很容易因为忘记提交酿成大问题。7.3 JDBC层面的实操细节Java应用通过JDBC连接MySQL时有一个连接配置项很关键rewriteBatchedStatementstrue。批量新增、批量更新时没有这个参数MySQL不会真正走批量执行一条条执行导致事务内部IO开销巨大。加了之后性能提升非常明显尤其是大数据量写入场景。事务和连接池的关系也值得注意连接池里每个连接都是独立事务上下文Transactional方法结束时连接就归还给池子下一个请求拿到的是干净还是残留状态完全由连接池的清理机制决定。大部分连接池配置了事务回滚和清理但你要是自己管理连接就要小心上一段事务没提交就归还连接的情况。8. 最终建议和我在实战中的感受回到最开始的问题事务到底怎么用才叫用得好我的答案很直接事务是保证数据一致性的工具不是随便加的注解。每个事务都要想清楚三件事——事务的边界在哪里事务里有没有不该做的操作事务失败后怎么恢复。这半年处理过的线上事故一半以上都能归结到事务使用不当长事务拖死整个库的事务提交、跨服务调用放在事务里导致锁超时、异常被吞导致脏数据悄悄落库、分布式事务设计过度导致系统复杂度爆炸。每次处理完我都觉得如果团队对事务的理解能更扎实一点这些事故里至少有一大半可以完全避免。如果你现在刚开始系统学MySQL事务我建议你按照这个顺序来先自己搭一套环境把四种隔离级别的行为都实验一遍然后模拟并发读写同一批数据用SHOW ENGINE INNODB STATUS观察锁和死锁日志再拿自己项目的业务逻辑试着在代码里找一找哪些地方用了事务、哪些地方其实应该用事务但没用。这套流程走下来你对事务的理解一定比死记硬背知识点要深刻得多。MySQL的事务体系博大但核心的基石就是这篇文章讲的这些东西。把ACID、隔离级别、MVCC和锁这几个概念串起来实际操作时多想一想这个操作会不会锁住别人这个事务会不会太久很多坑就可以躲过去了。