
各位做后端开发和数据库运维的朋友肯定都被“事务”这个词折磨过。面试问烂了工作中也躲不掉尤其是那些涉及资金、订单、库存的系统MySQL事务原理直接决定了你写出来的SQL到底稳不稳。这篇我想把自己这些年对MySQL InnoDB引擎事务机制的理解从头到尾梳理一遍。不整那些浮在表面的概念直接深入到源码级别的实现思路把ACID怎么落地、隔离级别为什么有四种、MVCC到底怎么工作、redo log和binlog怎么配合以及你真正会被坑到的锁问题全部摊开来讲清楚。适合刚啃完八股文想深入原理的开发者也适合线上出过事故想搞明白为什么的运维兄弟。1. 先搞清楚事务到底在解决什么问题1.1 从一个“库存扣减异常”的现场说起我以前帮一个电商团队排查过问题。用户下单抢购商品库存一共就30件结果卖了35单出去。代码层面看逻辑完全没问题先查库存大于0就扣减然后生成订单。但线上就是超卖了。原因不复杂两个请求同时进来都查到库存是1然后都执行了扣减操作库存变成0但订单生成了两单。程序员的直觉会告诉你“加个锁就能解决”但继续深挖会发现所谓加锁只是最表层的手段。真正把这个事情兜底兜住的是数据库底层的事务机制——它要保证你在并发操作时数据的一致性和可靠性不会出问题。我们日常讨论MySQL事务原理本质就是在讨论一件事数据库怎么在并发环境下保证你看到的数据是真的、写进去的数据是对的、系统崩了之后数据还在。1.2 为什么说ACID是事务的“底线”ACID四个字母大家都会背但我发现很多人背完就忘因为没理解这四个属性其实是一层嵌一层的。原子性Atomicity一组操作要么全成功要么全失败。比如转账扣钱和加钱必须是一体的。你在代码里写十个update任何一步失败前面九步的效果必须被撤销。一致性Consistency事务执行前后数据要满足业务定义的约束。这个约束不只是数据库的约束更多是逻辑层面的。比如总金额不变、库存不为负。隔离性Isolation两个事务并发执行相互之间要“隔离”。隔离到什么程度由隔离级别决定。持久性Durability事务一旦提交结果就不能丢。哪怕机器断电数据也得在。在InnoDB引擎里原子性靠undo log持久性靠redo log隔离性靠锁和MVCC一致性则是前三者共同作用的结果外加约束和外键来兜底。理解这个底层分工很重要。很多人在调优时只盯着某一个点比如拼命加索引、调整锁粒度却不知道真正拖后腿的往往是日志写入策略和隔离级别的选择。等我把后面几个原理都讲完你会建立起一张完整的图。2. 隔离级别的演进从“脏读”到“幻读”2.1 四种隔离级别到底隔离了什么SQL标准定义了四种隔离级别MySQL全部支持但InnoDB的默认级别是可重复读Repeatable Read简称RR。这一点和很多数据库不一样Oracle的默认级别是读已提交Read CommittedRC。为什么MySQL要把默认级别设为RR后面讲完MVCC你就明白了。先看四种级别的定义和它们分别解决的并发问题隔离级别脏读不可重复读幻读读未提交Read Uncommitted可能可能可能读已提交Read Committed不会可能可能可重复读Repeatable Read不会不会可能InnoDB已解决串行化Serializable不会不会不会脏读事务A还没提交事务B就读到了A修改的数据。如果A回滚了B读到的就是“不存在”的数据。这是最恐怖的情况。不可重复读同一个事务内同一个查询语句执行两次结果不一样。原因是有别的事务提交了修改。幻读同一个事务内执行同一个范围查询第二次读到了新插入的行。就好比你查“库存小于10的商品列表”第一次查出3个商品另一个事务插入了第4个缺货商品你第二次查就看到了4个——多出来的那个就像幻觉一样。2.2 隔离级别带来的性能代价选串行化级别最省心所有事务排队执行完全不会并发问题。但数据库存在的意义就是高并发如果所有操作都排队一台数据库可能比单机应用还慢。事务的隔离级别是“安全”和“并发性能”之间的一个权衡。读未提交虽然快但不适合任何严肃业务串行化虽然稳但吞吐量直接崩。大多数人会在RC和RR之间做选择。这里有个很容易被忽略的实战点什么业务适合RR什么业务适合RC我的经验是如果业务里有大量“先查后改”的复杂逻辑比如根据查询结果决定接下来更新哪些行RR的稳定一致性读会让代码逻辑简单很多。如果业务以INSERT为主并发量极大而且对读到“一瞬间的旧数据”不敏感RC的并发能力和死锁率会更友好。3. MVCC多版本并发控制InnoDB“快照读”的基石3.1 隐藏列与undo log版本链在深入MVCC之前我们先搞清楚InnoDB在每行数据后面都藏了哪些秘密。一张正常的用户表你以为每行只存了业务字段实际上InnoDB还会在物理存储上额外维护几个隐藏列DB_TRX_ID最近一次修改这行数据的事务ID。DB_ROLL_PTR回滚指针指向undo log里这行数据的上一个版本。DB_ROW_ID如果没有指定主键InnoDB会用它来生成隐藏主键。为什么事务能回滚为什么InnoDB能提供一致性快照核心就靠undo log。当你要更新一行记录时InnoDB不会直接把旧值扔掉而是先把旧值写入undo log然后生成一个新版本。旧值和新值通过DB_ROLL_PTR串在一起形成一条版本链。举一个具体的例子。假设有一行数据ID1余额100。事务A把它改成90事务B把它改成80版本链最新版本在最前由索引指向 版本3余额80trx_id事务B - 版本2余额90trx_id事务A - 版本1余额100trx_id最初插入这条链子上每个版本都记录了是哪个事务修改的。后面要讲的Read View读取视图就是拿着某个时刻的“可见性标准”从这条链上从上往下找找到第一个“对当前事务可见”的版本。3.2 Read View的创建时机与可见性判断Read View是MVCC里最核心的概念。它本质上是一个数组加两个ID在特定时刻生成的一份“事务快照视图”。Read View里主要包含四个信息creator_trx_id创建这个Read View的事务ID。up_limit_id当前活跃事务列表中最小的事务ID可以理解为“未提交事务的最小ID”。low_limit_id当前尚未分配的事务ID的上限通常是最新事务ID 1。trx_ids生成Read View时系统中正在活跃未提交的事务ID列表。可见性判断规则可以总结成一句话拿着要读取的版本的事务ID跟Read View限制的区间做比较。具体逻辑是遍历版本链上的每一个版本取出它的trx_id如果trx_id等于creator_trx_id说明这个版本是本事务自己修改的可见。如果trx_id小于up_limit_id说明这个版本在快照创建前就已经提交了可见。如果trx_id大于或等于low_limit_id说明这个版本在快照创建后才有的事务生成不可见。如果trx_id介于up_limit_id和low_limit_id之间需要再判断它是否在trx_ids活跃列表中——如果在说明尚未提交不可见如果已经不在列表里说明已经提交可见。3.3 RR和RC的核心差异Read View的创建时机MVCC的可见性判断逻辑在不同隔离级别下有一个关键不同这个不同直接决定了事务内两次查询的一致性。在RC级别下每次执行快照读普通SELECT都会生成一个新的Read View。这意味着如果其他事务在这期间提交了修改你下一次SELECT会看到新数据这就是不可重复读。在RR级别下只在第一次执行快照读时生成Read View整个事务期间复用这一个快照。所以你事务内读100次看到的都是同一个版本快照天然具备可重复读能力。这个设计思路非常聪明。MySQL之所以把RR作为默认隔离级别就是因为它可以通过MVCC基本消除幻读问题同时不需要加锁并发性能远好于串行化。不过这里必须啰嗦一句RR下MVCC解决的是普通SELECT快照读下的幻读。如果事务里先走了SELECT ... FOR UPDATE或者UPDATE这类当前读加锁读那Read View就不起作用了幻读还是有可能发生需要靠锁机制来兜底。这一点在面试和实际踩坑中都是重灾区后面讲锁机制时会再强调。3.4 实操验证一个示例看清整个MVCC流程说了这么多用具体的时间线走一遍流程会清楚得多。假设初始数据是ID1的行的余额为100。事务A和事务B以及事务C依次执行时间线 t1: 事务A开始trx_id 100 t2: 事务A UPDATE 余额 SET 余额 50 WHERE id 1 t3: 事务B开始trx_id 101 t4: 事务B SELECT 余额 FROM 表 WHERE id 1 t5: 事务C开始trx_id 102 t6: 事务C UPDATE 余额 SET 余额 30 WHERE id 1 t7: 事务C COMMIT t8: 事务B SELECT 余额 FROM 表 WHERE id 1 t9: 事务A COMMIT在RR级别下事务B在t4第一次SELECT时生成Read View活跃事务列表是[100, 101]low_limit_id是102。此时版本链最新版本是事务Atrx_id100改的余额50100在活跃列表里不可见继续找老版本找到trx_id100的旧版本余额100可见。所以B第一次读到100。事务B在t8第二次SELECT时仍然复用t4生成的快照。此时版本链最新版本已经被事务C改成了30但事务C的trx_id102不小于low_limit_id102直接不可见。继续往前找版本2事务A的50事务A仍未提交活跃列表里还有100不可见。最终B读到的还是版本1的100。这就是“可重复读”。在RC级别下事务B在t8会重新生成一个Read View此时活跃列表只有[100]事务C已提交所以不在列表里。最早可见的版本就是事务C的30所以B读到30发生了不可重复读。这条逻辑特别重要你要是能把它讲清楚对MySQL事务原理的理解就已经超过大半的候选人了。4. 持久性的保障redo log与binlog的两阶段提交4.1 Write Ahead Log先写日志再改数据持久性说的是事务提交后数据不会丢。但很多人有个错误认知以为“事务提交”就是把数据刷到磁盘里的数据文件里。实际上InnoDB并不会每次提交都刷数据页。InnoDB的默认设计是在事务提交时必须先把本次修改对应的redo log写入磁盘落盘然后事务才算成功提交。这个策略叫Write Ahead LogWAL先写日志再改数据。为什么能这么搞因为redo log里记录了“把某页的某个位置改成什么值”这样的物理操作。即使宕机时数据页还没来得及刷盘启动后也能靠redo log把数据页恢复出来。而且由于redo log是顺序写的相比随机写数据文件速度快了一个数量级。这就是InnoDB写入性能高的底层保障。4.2 redo log的“环形空间”设计redo log不是无限增长的它使用一组固定大小的文件采用循环写入的方式。默认配置下有两个文件每个文件大小可以通过innodb_log_file_size控制。写入逻辑是有一个write pos当前写入的位置和一个checkpoint擦除的位置。写入到文件末尾就回到开头继续写。如果write pos追上了checkpoint说明日志文件满了必须先把checkpoint往前推强制把对应脏页刷到磁盘腾出空间。这意味着redo log的空间设置很关键。如果你把redo log设得太小事务一多就疯狂触发刷脏页性能断崖式下跌。设得太大宕机恢复时重放日志就会很久。我见过不少项目直接把innodb_log_file_size调成1G甚至2G然后恢复时间长达几十分钟。4.3 binlog与redo log的一致性两阶段提交的真相redo log是InnoDB引擎层的日志binlog是MySQL Server层的日志。前者用于崩溃恢复后者用于主从复制和时间点恢复。两个日志都必须记录本次事务如果顺序不对就会出现主从数据不一致。MySQL通过两阶段提交解决redo log和binlog的写一致性问题分三步prepare阶段先把事务的redo log写入磁盘状态标记为prepare。commit阶段写入binlog并落盘。完成阶段把redo log的状态改为commit。为什么要这么设计因为崩溃可能发生在任何一步如果在写完redo logprepare之后、写binlog之前崩溃重启后事务会回滚。如果binlog已经写入但redo log状态还没更新为commit时崩溃重启后会根据binlog里的内容把事务补提交commit保证主从库数据一致。这个逻辑有个很好记住的口诀binlog决定最终是否落定redo log决定崩溃后怎么恢复。两阶段提交做的事情就是让这两个日志在最终状态上保持一致。4.4 几个直接影响持久性的参数理解了WAL机制后有几个参数你会特别关注innodb_flush_log_at_trx_commit这个参数控制redo log的刷盘策略。设为1是每次事务提交都刷盘最安全设为2是刷到操作系统缓存不强制刷盘数据库崩溃不丢但操作系统断电可能丢设为0是交给后台线程刷可能丢失最近1秒内的事务。sync_binlog控制binlog多久刷一次盘。默认是1每提交一次事务就刷一次盘安全性最好但高并发下性能压力大。有些主从架构为了性能把这个值调成0或100代价是极端情况下binlog可能丢失从库会跟不上。你在生产环境做性能调优前必须想清楚能否承受日志丢失的代价。我不建议在涉及资金的系统上动这两个参数。5. 当前读与锁机制并发控制的另一半5.1 锁的分类与兼容性矩阵MVCC管的是快照读而当前读SELECT ... FOR UPDATE、UPDATE、DELETE必须加锁。InnoDB里的锁可以分成几类共享锁S锁允许其他事务加S锁不允许加X锁用于普通SELECT加LOCK IN SHARE MODE。排他锁X锁不允许其他事务加任何锁用于UPDATE、DELETE和SELECT ... FOR UPDATE。意向锁IS/IX锁表级别的“预告锁”表示一个事务准备在表中的某些行加S锁或X锁用于快速判断表级冲突。锁兼容性可以这样总结S锁和S锁兼容S锁和X锁冲突X锁和X锁冲突。只要理解了这一点大部分死锁问题就能看懂。5.2 记录锁、间隙锁与Next-Key LockInnoDB的行锁不是只锁一行如果你走的是范围查询且索引不是唯一索引锁会扩大到记录之间的“间隙”。这是MySQL解决RR级别幻读的主要手段。记录锁Record Lock锁住索引记录本身。间隙锁Gap Lock锁住两个索引记录之间的区间阻止其他事务在这个区间插入新记录。Next-Key Lock记录锁间隙锁的组合锁住从当前记录到下一个索引记录之间的前开后闭区间。举个例子表里有主键ID分别有1、5、10这三条记录。执行SELECT * FROM 表 WHERE id 3 FOR UPDATE时不仅5和10两条记录会被锁连5,10] 和10,正无穷这些间隙也会被锁其他事务无法在这间隙里插入新记录。这就是RR级别下防止幻读的锁机制。需要注意的是如果WHERE条件走的是唯一索引且精确匹配间隙锁不会再生效只锁匹配的那一条记录。这也是为什么很多慢查询优化会建议你让UPDATE尽量走唯一索引——能显著减少锁范围降低死锁概率。5.3 死锁的经典场景与排查手段死锁的经典场景是“交叉加锁”。比如事务A先锁了表X再锁表Y事务B先锁了表Y再锁表X两边都在等对方释放资源形成了循环等待。InnoDB的死锁检测机制会在事务等待超过阈值时回滚代价较小的事务并报出死锁错误。有一次线上报Deadlock found when trying to get lock; try restarting transaction我第一反应不是去看代码逻辑而是去拿死锁日志。定位死锁的路径很固定执行SHOW ENGINE INNODB STATUS查看LATEST DETECTED DEADLOCK部分。看日志里每个事务持有什么锁、正在等什么锁找出循环等待链。从业务逻辑上寻找“加锁顺序不一致”的地方统一所有事务的加锁顺序。实在没法统一的话考虑缩短事务时长减少持锁时间降低交叉加锁的概率。我见过不少死锁其实不是并发太高导致的而是事务太长。一个事务里夹杂了外部API调用、大量查询、业务计算锁的持有时间被拉得很长随便来一个并发就会撞上。6. 常见问题与排查技巧实录6.1 慢事务导致的锁等待堆积线上经常会出现“大量事务卡在等待锁”的现象。表面看是数据库慢实际是某个事务迟迟不提交持锁时间太长后面排队的事务全部被卡住。排查经验是去看information_schema.innodb_trx表重点看trx_state为RUNNING但执行时间很长的长事务以及trx_query显示的当前语句。我曾经定位到一个问题某业务模块在事务里调用了第三方审核接口一次调用要等几十秒。这个“外部调用必须出事务事务里只能做数据库操作”的教训我吃了一次亏之后就刻在脑子里了。6.2 大事务与undo膨胀事务中更新的行数特别多或者一个事务存活时间特别长undo log会不断累积。一方面回滚段会膨胀占用大量存储另一方面版本链越长一致性读时的可见性判断链条就越长查询性能会下降。优化思路有三板斧控制单事务的更新行数分批提交。严禁在事务中做长耗时操作。对undo表空间设置合理的retention时间防止过于频繁的purge和过度膨胀。6.3 主从延迟与binlog格式的选择有些项目从库延迟严重仔细排查后发现binlog格式设置成了row一条UPDATE语句涉及几万行binlog就会记录几万条变更事件主库提交很快但从库重放这些事件需要时间。解决方式有几种检查是否有大批量UPDATE缺少有效索引导致行级event膨胀。让从库的并行复制线程充分利用起来调大并行复制相关参数。评估业务能否接受混合格式binlog对UPDATE语句使用statement格式减少日志量。但从库上的数据一致性风险会上升。6.4 隔离级别选错引发的数据异常最后一个要敲黑板强调的是如果业务场景里有“同一份数据既要防覆盖又要低延迟”对隔离级别的选择一定要做并发评估。我见过一个典型的案例某业务在RC级别下两个并发请求同时读同一账户余额然后在应用层做计算后分别更新余额最终导致一次更新覆盖了另一次更新。后来改成RR级别配合先查后改时使用SELECT ... FOR UPDATE的写法彻底解决了这个覆盖问题。你在选隔离级别时不要只盯着“哪个更快”要先把数据正确性验证清楚再谈性能调优。最后再分享一个我踩过的经验每次调整事务相关参数比如binlog刷盘策略、redo log大小、隔离级别我都强制自己在测试环境跑一遍并发压测加故障模拟。先把机器断电再看看数据是不是完整的再上线。事务这个东西平时不出问题一出就是大问题提前演练永远比事后救火强。