MySQL - 事务、redo/undo 日志与 MVCC 📅 2026/7/26 7:55:33 一句话概括这几部分内容的关系事务要满足 ACID持久性靠 redo 日志实现原子性靠 undo 日志实现undo 日志为了支持回滚顺带记下的旧版本又被拿来实现了 MVCC而 MVCC 管不到的那部分隔离性当前读则要靠锁来兜底。这篇文章就按这条线索把实现一个事务所需要的各个设计串起来讲重点放在每一步为什么要这么设计上。目录事务与 ACID一个转账的例子持久性怎么保证redo 日志原子性怎么保证undo 日志一个意外的副产品版本链隔离性问题清单从脏写到幻读MySQL 的四种隔离级别MVCC 的核心ReadView 与可见性判断RC 与 RR 的本质区别隔离性的另一半当前读与锁版本链什么时候真正清理purge串起来看一条 UPDATE 语句背后都发生了什么一、事务与 ACID一个转账的例子现实世界中有很多要么全做、要么全不做的操作。账户 A 转账给账户 B本质是两条 UPDATEUPDATEaccountSETbalancebalance-10WHEREid1;UPDATEaccountSETbalancebalance10WHEREid2;如果只执行了第一条服务器就崩溃了A 的钱被扣了B 却没收到——这是现实世界完全不允许出现的中间状态。为了让一组数据库操作符合现实世界的状态转换规则需要满足四条规则合起来正好是 ACID原子性Atomicity一组操作要么全部生效要么全部不生效不存在只做一半的情况。一致性Consistency操作完成后数据必须符合业务定义的约束比如余额不能为负。这是一个结果性的要求靠原子性、隔离性共同保证再加上业务代码自己兜底MySQL 的 CHECK 约束长期形同虚设复杂的一致性规则最终还是要交给业务代码校验。隔离性Isolation多个并发的操作互不干扰效果上等价于依次串行执行。持久性Durability一旦提交修改就必须永久生效哪怕紧接着断电重启。把需要满足 ACID 的一个或多个数据库操作打包在一起就是一个事务transaction。MySQL 里用BEGIN/START TRANSACTION开启事务COMMIT提交ROLLBACK回滚不显式开启事务时autocommit默认为开每条语句自成一个事务。事务的生命周期本身也需要一套状态设计来跟踪开启后处于活动的状态语句正在执行最后一条操作在内存中执行完、但还没刷盘时进入部分提交的状态如果一切顺利刷盘完成后进入已提交状态如果执行过程中出错或者手动执行了ROLLBACK则进入失败的状态回滚完成后进入中止的状态。只有到达已提交或中止一个事务的生命周期才算真正结束。如果一个事务里写了好多条语句只想撤销最近几步而不是推倒重来还可以用保存点SAVEPOINT在事务执行过程中打几个标记回滚时指定回到哪个保存点BEGIN;UPDATEordersSETstatuspaidWHEREid2001;SAVEPOINTs1;UPDATEordersSETstatusshippedWHEREid2001;-- 这一步改错了ROLLBACKTOs1;-- 只撤销到 s1前面 status paid 的修改还在这四条规则里原子性和持久性都要求数据库得记住点什么才能在意外发生时兜得住底——这正是 redo 日志和 undo 日志存在的原因redo 保证持久性undo 保证原子性。下面先看持久性怎么靠 redo 日志实现。二、持久性怎么保证redo 日志InnoDB 的每次读写都发生在 Buffer Pool 里的内存页上修改不会立刻同步到磁盘。问题来了如果事务提交后修改过的页还没来得及刷盘系统就崩溃了这次修改岂不是白改了最直接的办法是事务提交前把它改过的所有页整页刷盘。但这样做代价很大哪怕只改了一个字节也要把整个 16KB 的页刷下去太浪费。一个事务改的页往往不相邻刷盘等于一堆随机 IO比顺序 IO 慢得多。所以 InnoDB 换了个思路不刷页面本身只记录改了什么——比如某表空间的第 100 号页偏移量 1000 处的字节从 1 改成了 2。这段极简的记录就是redo 日志。事务提交时只需要把 redo 日志刷盘体积小、顺序写不需要刷整个数据页系统崩溃重启后照着 redo 日志把该做的修改重新做一遍就能恢复到崩溃前的状态。几个关键设计点redo 日志分好多种类型简单的比如某个整数字段改了值是纯物理记录复杂的比如往索引页里插入一条记录会牵扯页目录、页头统计信息等一大堆连带修改则是记录调用哪个函数、传什么参数恢复时重新跑一遍这个函数——这叫逻辑日志比把所有被改动的字节都记一遍省空间得多。以 mtrmini-transaction为最小原子单位一次对底层页面的原子访问比如一次插入操作可能牵扯多个页面产生的一组 redo 日志要么全部生效要么一条也不算靠在组尾加一条特殊的结束标记来实现。这保证了崩溃恢复不会把一次操作恢复成做了一半的状态。redo 日志先写内存里的 log buffer再刷盘写盘的时机包括log buffer 快满了、事务提交时、后台线程每秒定时刷、以及做 checkpoint 的时候。redo 日志文件是循环写的写满最后一个文件就转回第一个继续写所以必须有一套机制知道哪些 redo 日志对应的脏页已经刷盘、可以被覆盖了——这就是checkpoint不断把已经可以丢弃的 redo 日志往前推进崩溃恢复时只需要从最近一次 checkpoint 的位置开始回放不用从头读。版本提示本文讲的 redo/undo/MVCC/隔离级别机制在 MySQL 5.7 和 8.0 上是一致的。一处运维层面的变化redo 日志的总大小5.7 由innodb_log_file_size × innodb_log_files_in_group决定8.0.30 起改用单个innodb_redo_log_capacity配置且支持在线动态调整不用再重启。可以调节的持久性强度innodb_flush_log_at_trx_commit严格来说事务提交时必须把 redo 日志刷盘这条规则本身也是可以松动的——毕竟每次提交都要等一次磁盘 IO对性能影响不小。InnoDB 用一个系统变量把这个权衡交给使用者自己决定1默认提交时同步刷盘完全保证持久性性能代价最大。0提交时不刷盘交给后台线程每秒刷一次如果提交后、后台线程刷盘前系统崩溃这次提交的修改会丢失。2提交时写入操作系统缓存但不强制刷盘只要操作系统不崩溃哪怕 MySQL 进程挂了数据也不会丢比 0 更安全一些。这是一个很典型的用多少持久性换多少性能的调节旋钮值不值得调取决于业务能不能接受最后一秒的提交可能丢失这种风险。一句话总结 redo 日志的设计思路用最小成本的改动记录替代整页刷盘把随机 IO 变成顺序 IO同时保证记录本身要么完整生效、要么完全不算并且把多强的持久性变成一个可以按需调节的旋钮。持久性说完了接下来看原子性靠什么保证。三、原子性怎么保证undo 日志持久性解决了崩溃后已提交的修改不丢但原子性还要解决另一半问题“没提交完就中止的修改怎么恢复原状”。思路很朴素就像悔棋插入了一条记录回滚就是删掉它删除了一条记录回滚就是插回去更新了一条记录回滚就是把值改回旧值。所以每次改动记录之前都要先把回滚所需的信息记下来——这就是undo 日志。三种操作对应的 undo 日志形态不同INSERT对应的 undo 日志只需要记主键值——回滚时照着主键删掉就行。DELETE并不会立刻把记录真删掉而是分两阶段先把记录打上删除标记delete mark事务提交后才由后台线程真正清理。这个先标记、后清理的设计是专门给 MVCC 让路的下一节细说。UPDATE分两种情况如果没改主键且改动前后各列占用空间不变可以原地更新否则要先删旧记录再插新记录。如果改的是主键值则统一按旧记录打删除标记 插入一条新记录处理会产生两条 undo 日志。每条聚簇索引记录都有两个隐藏列专门配合 undo 日志trx_id最近一次改动这条记录的事务 id。roll_pointer一个指针指向这条记录被改动前生成的那条 undo 日志。trx_id 这个事务 id 是怎么来的也值得说一说。事务要改动记录时InnoDB 才会给它分配一个全局唯一、递增的事务 id填进 trx_id 列——这个 id 后面会是 ReadView 判断版本可见性的核心依据第七节会用到。这里有两处设计值得注意按需分配而不是一开始就分配。开启事务BEGIN时不会立刻分配 id只有第一次真正执行 INSERT/DELETE/UPDATE 时才分配。这意味着一个只读事务或者从头到尾只有 SELECT 的事务很可能压根不会有事务 id——这也是为什么后面 ReadView 的m_ids只需要跟踪活跃的读写事务没改过数据的事务自然不用关心。生成方式和记录的 row_id 隐藏列思路一样服务器维护一个全局递增变量每分配一次就自增每当这个变量的值是 256 的倍数就持久化一次写到系统表空间某个页面的 Max Trx ID 属性中重启时把持久化的值加上 256 再继续用。这样设计是拿一点 id 浪费换性能不用每次分配都同步刷盘把刷盘频率降到 1/256。有了 trx_id 和 roll_pointer 打底undo 日志还能再分两大类这个分类直接决定了它们能不能被立刻清理insert undo只在事务回滚时有用事务一旦提交就可以直接释放。update undo覆盖 delete、update不能在事务提交后立刻释放因为它们还要留着给 MVCC 用——这是本文最关键的一个连接点。顺带一提为了让高并发事务都能快速找到地方写 undo 日志InnoDB 设计了多个回滚段每个事务按需分配对应的存储位置写入方式也是顺序追加、能重用则重用。这部分更偏工程实现细节这里不展开。四、一个意外的副产品版本链现在把 trx_id、roll_pointer 和 undo 日志放到一起看每次修改一条记录旧版本不会消失而是被写进 undo 日志然后用 roll_pointer 指针串起来。多次修改之后这些 undo 日志就通过 roll_pointer 连成了一条链表——版本链。链表头是当前最新值顺着 roll_pointer 往回找能找到这条记录从诞生到现在的每一个历史版本以及每个版本是被哪个事务trx_id改出来的。设计 undo 日志的本意只是为了回滚但记录旧版本 用指针串起来这个结构天然具备了另一种能力只要知道该看哪个版本不同事务就可以在同一条记录上看到不同的内容互不干扰。这正是 MVCC多版本并发控制的物理基础——undo 日志不是为 MVCC 设计的但 MVCC 是站在 undo 日志的肩膀上实现的。五、隔离性问题清单从脏写到幻读有了版本链我们已经能回答如果只有一个事务在跑该读哪个版本这个问题。但现实中事务是并发执行的不加约束的并发可能会踩几个坑按严重程度从高到低排列脏写一个事务改了另一个未提交事务改过的数据。如果后者回滚前者的修改也跟着凭空消失。这个问题不属于该看哪个版本的范畴——它发生在写操作之间版本链帮不上忙只能靠加锁解决改动一条记录后锁住它直到本事务提交后面讲当前读与锁那节会细说。脏读读到了另一个未提交事务改过的数据。如果对方之后回滚就等于读到了一个从来没真正存在过的值。不可重复读同一个事务里两次读同一条记录因为期间别的事务提交了修改两次读到的值不一样。幻读同一个事务里两次按相同条件查询第二次多出了别的事务新插入、且已提交的记录。后三种问题都是读到了不该读到的版本正是版本链和 MVCC 要解决的目标脏写则提前预告了一件事——版本链不是万能的写操作之间的冲突还得靠锁。六、MySQL 的四种隔离级别SQL 标准定义了四档隔离级别级别越低、允许发生的问题越多隔离级别脏读不可重复读幻读READ UNCOMMITTED可能可能可能READ COMMITTED不会可能可能REPEATABLE READ不会不会可能MySQL 里实际不会SERIALIZABLE不会不会不会MySQL 默认使用REPEATABLE READ而且比 SQL 标准定义得更强——它在 RR 级别下也能规避幻读靠的是 MVCC 加锁配合具体怎么配合放到隔离性的另一半那节讲。可以用下面的语句查看或修改隔离级别SELECTtransaction_isolation;SETSESSIONTRANSACTIONISOLATIONLEVELREPEATABLEREAD;READ UNCOMMITTED直接读最新版本不需要版本链SERIALIZABLE靠加锁解决一切也不需要版本链。真正要靠版本链 可见性判断来实现的只有 READ COMMITTED 和 REPEATABLE READ 这两档——这就是 MVCC 登场的地方。七、MVCC 的核心ReadView 与可见性判断面对一条记录的版本链READ COMMITTED / REPEATABLE READ 级别的事务要解决的问题是这一串历史版本里到底哪一个是我现在应该看到的InnoDB 给出的解法是ReadView——一个快照记录了生成这个快照时刻的系统状态m_ids生成快照那一刻所有还没提交的读写事务 id 列表。min_trx_idm_ids 中最小的那个 id。max_trx_id下一个将要分配出去的事务 id不是 m_ids 里的最大值而是目前最大已分配 id 1。creator_trx_id生成这个 ReadView 的事务自己的 id。有了 ReadView判断版本链上某个版本是否可见就是四条规则依次判断版本的 trx_id 等于 creator_trx_id——是自己改的可见。版本的 trx_id 小于 min_trx_id——生成这个版本时那个事务早就提交了可见。版本的 trx_id 大于等于 max_trx_id——这个版本是快照生成之后才诞生的事务改出来的不可见。版本的 trx_id 落在 min_trx_id 和 max_trx_id 之间——再看它在不在 m_ids 里在说明生成快照那一刻这个事务还没提交不可见不在说明已经提交了可见。如果当前版本不可见就顺着 roll_pointer 跳到上一个版本重复上面的判断直到找到一个可见版本或者版本链走到头意味着这条记录对当前事务完全不可见。举个例子。假设orders表里 id2001 的订单最初由事务 50 插入statuspending随后事务 120 把它先后改成paid、shipped并提交再之后事务 260还没提交又改成了delivered。这条记录的版本链从新到旧就是trx_id260 statusdelivered 未提交 ↓ roll_pointer trx_id120 statusshipped ↓ roll_pointer trx_id120 statuspaid ↓ roll_pointer trx_id50 statuspending如果此时有个事务在 260 提交前生成了 ReadViewm_ids[260]min_trx_id260去读这条记录delivered版本的 trx_id260 落在 m_ids 里不可见跳到下一个版本shipped的 trx_id120 小于 min_trx_id260可见——所以读到的是shipped事务 260 那次还没提交的修改看不到。八、RC 与 RR 的本质区别READ COMMITTED 和 REPEATABLE READ 用的是同一套 ReadView 机制唯一的区别是ReadView 什么时候生成。READ COMMITTED每次执行普通 SELECT 之前都重新生成一个 ReadView。所以只要别的事务在这期间提交了新的修改下一次 SELECT 就能立刻看见——这正是不可重复读会在 RC 级别下发生的原因。REPEATABLE READ只在事务内第一次执行普通 SELECT 时生成一个 ReadView之后同一事务里的所有查询都复用这一个 ReadView。所以哪怕期间别的事务提交了修改本事务反复查询看到的都是同一个版本——这就是可重复读名字的由来。同一个版本链同一套判断规则仅仅因为快照什么时候拍不同就撑起了两个不同的隔离级别这也是 MVCC 这套设计比较精妙的地方不需要为每个隔离级别单独设计一套机制只需要控制 ReadView 的生成时机。不过 MVCC 能管的只是读操作第五节留下的脏写问题还没解决——这就要说到隔离性的另一半了。九、隔离性的另一半当前读与锁MVCC 解决了 READ COMMITTED / REPEATABLE READ 下普通 SELECT 该看哪个版本但这里有一个前提MVCC 只管普通的 SELECT。像INSERT、UPDATE、DELETE以及显式加锁的SELECT ... FOR UPDATE/SELECT ... LOCK IN SHARE MODE都必须读到最新的、已提交的数据才有意义——不能拿着一个过时的余额去做加减法。这种必须读最新版本的读取方式称为当前读和依赖 ReadView 的快照读相对。当前读没法靠多看一个历史版本来解决冲突只能靠锁——这正好回答了第五节留下的问题脏写之所以不属于版本链能解决的范畴是因为它发生在两个当前读操作之间InnoDB 通过给记录加锁保证同一时刻只有一个事务能修改它另一个事务想改就必须排队等锁。这里还有一处呼应前文的地方MySQL 的 REPEATABLE READ 之所以能比 SQL 标准定义得更强、连幻读都能避免靠的正是锁和 MVCC 的配合——对于当前读比如SELECT ... FOR UPDATE、INSERTInnoDB 不只锁住已有的记录还会锁住记录之间的间隙gap lock和记录锁合起来称为 next-key lock这样别的事务就没法在间隙里插入新记录从而堵住了幻读的口子。锁的具体分类和加锁规则内容很多值得单独写一篇这里只需要记住一个结论MVCC 负责快照读的可见性锁负责当前读的正确性和并发安全两者配合起来才是 REPEATABLE READ 级别完整的隔离性保证。十、版本链什么时候真正清理purge版本链不能无限增长也不能删得过早——删早了正在使用这个版本的事务就没法读了。所以insert undo在事务提交后就可以直接释放前面讲 undo 日志时说过只有回滚才用得上。update undo以及被打上删除标记、还没真正删除的记录要等到系统中所有可能还需要访问它的 ReadView 都已经过期——也就是没有任何活跃事务的快照还可能引用到这个版本——才会被后台的purge 线程真正清理掉。这也是为什么长时间不提交的事务是个隐患只要它的 ReadView 还挂着版本链上一大堆本该清理的旧版本就都得留着undo 日志占用的空间会持续膨胀。十一、串起来看一条 UPDATE 语句背后都发生了什么最后把前面几节串成一条完整的时间线假设一条语句UPDATEordersSETstatusshippedWHEREid2001;加载页面如果 id2001 所在的页不在 Buffer Pool 里先从磁盘加载进来。记 undo 日志修改前先把这条记录当前的值包括旧的 trx_id、roll_pointer写进一条 undo 日志本事务的 roll_pointer 指向它——版本链在这一步悄悄延长了一环。改内存页 记 redo 日志在 Buffer Pool 里把 status 改成 ‘shipped’同时记一条 redo 日志记的是页面某处改成了什么不是整页刷盘。这一步保证了持久性。加锁这是一次当前读操作UPDATE 本身要读到最新数据才能改所以这条记录会被加锁避免别的事务同时改它导致脏写。事务提交把本次事务产生的 redo 日志刷盘不需要刷数据页本身刷盘的强度还受innodb_flush_log_at_trx_commit控制事务进入已提交状态这条记录新版本的 trx_id 变成本事务的 id。其他事务读它别的事务用 READ COMMITTED/REPEATABLE READ 级别做快照读时靠各自的 ReadView 判断该看哪个版本——刚提交的这次修改是不是对它可见取决于它的 ReadView 生成时机如果别的事务是当前读比如也要 UPDATE 这条记录则必须等本事务的锁释放直接读到最新值。旧版本终将清理等到没有任何 ReadView 可能还引用着更旧的那个版本purge 线程才会把它真正清理掉。redo 保证了这次修改不会因为崩溃而丢失undo 保证了如果事务没提交完就能整个撤销undo 日志顺带留下的版本链变成了 MVCC 实现快照读的基础而锁则补上了当前读和写写冲突这一半——这就是事务的各项设计真正连在一起的地方。