MySQL - 事务、redo/undo 日志与 MVCC

一句话概括这几部分内容的关系:事务要满足 ACID,持久性靠 redo 日志实现,原子性靠 undo 日志实现,undo 日志为了支持回滚顺带记下的"旧版本"又被拿来实现了 MVCC,而 MVCC 管不到的那部分隔离性(当前读)则要靠锁来兜底。这篇文章就按这条线索,把实现一个事务所需要的各个设计串起来讲,重点放在每一步"为什么要这么设计"上。

目录

  1. [事务与 ACID:一个转账的例子](#事务与 ACID:一个转账的例子 "#sec-1")
  2. [持久性怎么保证:redo 日志](#持久性怎么保证:redo 日志 "#sec-2")
  3. [原子性怎么保证:undo 日志](#原子性怎么保证:undo 日志 "#sec-3")
  4. 一个意外的副产品:版本链
  5. 隔离性问题清单:从脏写到幻读
  6. [MySQL 的四种隔离级别](#MySQL 的四种隔离级别 "#sec-6")
  7. [MVCC 的核心:ReadView 与可见性判断](#MVCC 的核心:ReadView 与可见性判断 "#sec-7")
  8. [RC 与 RR 的本质区别](#RC 与 RR 的本质区别 "#sec-8")
  9. 隔离性的另一半:当前读与锁
  10. 版本链什么时候真正清理:purge
  11. [串起来看:一条 UPDATE 语句背后都发生了什么](#串起来看:一条 UPDATE 语句背后都发生了什么 "#sec-11")

一、事务与 ACID:一个转账的例子

现实世界中有很多"要么全做、要么全不做"的操作。账户 A 转账给账户 B,本质是两条 UPDATE:

sql 复制代码
UPDATE account SET balance = balance - 10 WHERE id = 1;
UPDATE account SET balance = balance + 10 WHERE id = 2;

如果只执行了第一条,服务器就崩溃了,A 的钱被扣了,B 却没收到------这是现实世界完全不允许出现的中间状态。为了让"一组数据库操作"符合现实世界的状态转换规则,需要满足四条规则,合起来正好是 ACID:

  • 原子性(Atomicity):一组操作要么全部生效,要么全部不生效,不存在只做一半的情况。
  • 一致性(Consistency):操作完成后,数据必须符合业务定义的约束(比如余额不能为负)。这是一个结果性的要求,靠原子性、隔离性共同保证,再加上业务代码自己兜底(MySQL 的 CHECK 约束长期形同虚设,复杂的一致性规则最终还是要交给业务代码校验)。
  • 隔离性(Isolation):多个并发的操作互不干扰,效果上等价于依次串行执行。
  • 持久性(Durability):一旦提交,修改就必须永久生效,哪怕紧接着断电重启。

把需要满足 ACID 的一个或多个数据库操作打包在一起,就是一个事务 (transaction)。MySQL 里用 BEGIN/START TRANSACTION 开启事务,COMMIT 提交,ROLLBACK 回滚;不显式开启事务时,autocommit 默认为开,每条语句自成一个事务。

事务的生命周期本身也需要一套状态设计来跟踪 :开启后处于活动的 状态(语句正在执行);最后一条操作在内存中执行完、但还没刷盘时,进入部分提交的 状态;如果一切顺利,刷盘完成后进入已提交 状态;如果执行过程中出错,或者手动执行了 ROLLBACK,则进入失败的 状态,回滚完成后进入中止的状态。只有到达"已提交"或"中止",一个事务的生命周期才算真正结束。

如果一个事务里写了好多条语句,只想撤销最近几步而不是推倒重来,还可以用保存点(SAVEPOINT)在事务执行过程中打几个标记,回滚时指定回到哪个保存点:

sql 复制代码
BEGIN;
UPDATE orders SET status = 'paid' WHERE id = 2001;
SAVEPOINT s1;
UPDATE orders SET status = 'shipped' WHERE id = 2001;  -- 这一步改错了
ROLLBACK TO s1;  -- 只撤销到 s1,前面 status = 'paid' 的修改还在

这四条规则里,原子性和持久性都要求"数据库得记住点什么,才能在意外发生时兜得住底"------这正是 redo 日志和 undo 日志存在的原因:redo 保证持久性,undo 保证原子性。下面先看持久性怎么靠 redo 日志实现。

二、持久性怎么保证:redo 日志

InnoDB 的每次读写都发生在 Buffer Pool 里的内存页上,修改不会立刻同步到磁盘。问题来了:如果事务提交后,修改过的页还没来得及刷盘,系统就崩溃了,这次修改岂不是白改了?

最直接的办法是:事务提交前,把它改过的所有页整页刷盘。但这样做代价很大:

  • 哪怕只改了一个字节,也要把整个 16KB 的页刷下去,太浪费。
  • 一个事务改的页往往不相邻,刷盘等于一堆随机 IO,比顺序 IO 慢得多。

所以 InnoDB 换了个思路:不刷页面本身,只记录"改了什么" ------比如"某表空间的第 100 号页,偏移量 1000 处的字节,从 1 改成了 2"。这段极简的记录就是 redo 日志。事务提交时只需要把 redo 日志刷盘(体积小、顺序写),不需要刷整个数据页;系统崩溃重启后,照着 redo 日志把该做的修改重新做一遍,就能恢复到崩溃前的状态。

几个关键设计点:

  • redo 日志分好多种类型 :简单的(比如某个整数字段改了值)是纯物理记录;复杂的(比如往索引页里插入一条记录,会牵扯页目录、页头统计信息等一大堆连带修改)则是记录"调用哪个函数、传什么参数",恢复时重新跑一遍这个函数------这叫逻辑日志,比把所有被改动的字节都记一遍省空间得多。
  • 以 mtr(mini-transaction)为最小原子单位:一次对底层页面的原子访问(比如一次插入操作可能牵扯多个页面)产生的一组 redo 日志,要么全部生效,要么一条也不算,靠在组尾加一条特殊的结束标记来实现。这保证了崩溃恢复不会把一次操作恢复成"做了一半"的状态。
  • redo 日志先写内存里的 log buffer,再刷盘,写盘的时机包括:log buffer 快满了、事务提交时、后台线程每秒定时刷、以及做 checkpoint 的时候。
  • redo 日志文件是循环写的 (写满最后一个文件就转回第一个继续写),所以必须有一套机制知道"哪些 redo 日志对应的脏页已经刷盘、可以被覆盖了"------这就是 checkpoint:不断把"已经可以丢弃的 redo 日志"往前推进,崩溃恢复时只需要从最近一次 checkpoint 的位置开始回放,不用从头读。

可以调节的持久性强度: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 加锁配合,具体怎么配合放到"隔离性的另一半"那节讲)。可以用下面的语句查看或修改隔离级别:

sql 复制代码
SELECT @@transaction_isolation;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

READ UNCOMMITTED 直接读最新版本,不需要版本链;SERIALIZABLE 靠加锁解决一切,也不需要版本链。真正要靠版本链 + 可见性判断来实现的,只有 READ COMMITTED 和 REPEATABLE READ 这两档------这就是 MVCC 登场的地方。

七、MVCC 的核心:ReadView 与可见性判断

面对一条记录的版本链,READ COMMITTED / REPEATABLE READ 级别的事务要解决的问题是:这一串历史版本里,到底哪一个是我现在应该看到的?

InnoDB 给出的解法是 ReadView------一个"快照",记录了生成这个快照时刻的系统状态:

  • m_ids:生成快照那一刻,所有还没提交的读写事务 id 列表。
  • min_trx_id:m_ids 中最小的那个 id。
  • max_trx_id:下一个将要分配出去的事务 id(不是 m_ids 里的最大值,而是"目前最大已分配 id + 1")。
  • creator_trx_id:生成这个 ReadView 的事务自己的 id。

有了 ReadView,判断版本链上某个版本是否可见,就是四条规则依次判断:

  1. 版本的 trx_id 等于 creator_trx_id------是自己改的,可见。
  2. 版本的 trx_id 小于 min_trx_id------生成这个版本时,那个事务早就提交了,可见。
  3. 版本的 trx_id 大于等于 max_trx_id------这个版本是快照生成之后才诞生的事务改出来的,不可见。
  4. 版本的 trx_id 落在 min_trx_id 和 max_trx_id 之间------再看它在不在 m_ids 里:在,说明生成快照那一刻这个事务还没提交,不可见;不在,说明已经提交了,可见。

如果当前版本不可见,就顺着 roll_pointer 跳到上一个版本,重复上面的判断,直到找到一个可见版本,或者版本链走到头(意味着这条记录对当前事务完全不可见)。

举个例子 。假设 orders 表里 id=2001 的订单最初由事务 50 插入,status='pending';随后事务 120 把它先后改成 'paid''shipped' 并提交;再之后事务 260(还没提交)又改成了 'delivered'。这条记录的版本链(从新到旧)就是:

ini 复制代码
trx_id=260  status='delivered'   (未提交)
   ↓ roll_pointer
trx_id=120  status='shipped'
   ↓ roll_pointer
trx_id=120  status='paid'
   ↓ roll_pointer
trx_id=50   status='pending'

如果此时有个事务在 260 提交前生成了 ReadView(m_ids=260,min_trx_id=260),去读这条记录:'delivered' 版本的 trx_id=260 落在 m_ids 里,不可见,跳到下一个版本;'shipped' 的 trx_id=120 小于 min_trx_id=260,可见------所以读到的是 '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 。像 INSERTUPDATEDELETE,以及显式加锁的 SELECT ... FOR UPDATE / SELECT ... LOCK IN SHARE MODE,都必须读到最新的、已提交的 数据才有意义------不能拿着一个过时的余额去做加减法。这种"必须读最新版本"的读取方式称为当前读 ,和依赖 ReadView 的快照读相对。

当前读没法靠"多看一个历史版本"来解决冲突,只能靠------这正好回答了第五节留下的问题:脏写之所以不属于版本链能解决的范畴,是因为它发生在两个当前读操作之间,InnoDB 通过给记录加锁,保证同一时刻只有一个事务能修改它,另一个事务想改就必须排队等锁。

这里还有一处呼应前文的地方:MySQL 的 REPEATABLE READ 之所以能比 SQL 标准定义得更强、连幻读都能避免,靠的正是锁和 MVCC 的配合 ------对于当前读(比如 SELECT ... FOR UPDATEINSERT),InnoDB 不只锁住已有的记录,还会锁住记录之间的"间隙"(gap lock,和记录锁合起来称为 next-key lock),这样别的事务就没法在间隙里插入新记录,从而堵住了幻读的口子。锁的具体分类和加锁规则内容很多,值得单独写一篇,这里只需要记住一个结论:MVCC 负责快照读的可见性,锁负责当前读的正确性和并发安全,两者配合起来才是 REPEATABLE READ 级别完整的隔离性保证

十、版本链什么时候真正清理:purge

版本链不能无限增长,也不能删得过早------删早了,正在使用这个版本的事务就没法读了。所以:

  • insert undo 在事务提交后就可以直接释放(前面讲 undo 日志时说过,只有回滚才用得上)。
  • update undo 以及被打上删除标记、还没真正删除的记录,要等到系统中所有可能还需要访问它的 ReadView 都已经过期 ------也就是没有任何活跃事务的快照还可能引用到这个版本------才会被后台的 purge 线程真正清理掉。

这也是为什么长时间不提交的事务是个隐患:只要它的 ReadView 还挂着,版本链上一大堆本该清理的旧版本就都得留着,undo 日志占用的空间会持续膨胀。

十一、串起来看:一条 UPDATE 语句背后都发生了什么

最后把前面几节串成一条完整的时间线,假设一条语句:

sql 复制代码
UPDATE orders SET status = 'shipped' WHERE id = 2001;
  1. 加载页面:如果 id=2001 所在的页不在 Buffer Pool 里,先从磁盘加载进来。
  2. 记 undo 日志:修改前,先把这条记录当前的值(包括旧的 trx_id、roll_pointer)写进一条 undo 日志,本事务的 roll_pointer 指向它------版本链在这一步悄悄延长了一环。
  3. 改内存页 + 记 redo 日志:在 Buffer Pool 里把 status 改成 'shipped',同时记一条 redo 日志(记的是"页面某处改成了什么",不是整页刷盘)。这一步保证了持久性。
  4. 加锁(这是一次当前读操作):UPDATE 本身要读到最新数据才能改,所以这条记录会被加锁,避免别的事务同时改它导致脏写。
  5. 事务提交 :把本次事务产生的 redo 日志刷盘(不需要刷数据页本身,刷盘的强度还受 innodb_flush_log_at_trx_commit 控制),事务进入"已提交"状态;这条记录新版本的 trx_id 变成本事务的 id。
  6. 其他事务读它:别的事务用 READ COMMITTED/REPEATABLE READ 级别做快照读时,靠各自的 ReadView 判断该看哪个版本------刚提交的这次修改,是不是对它可见,取决于它的 ReadView 生成时机;如果别的事务是当前读(比如也要 UPDATE 这条记录),则必须等本事务的锁释放,直接读到最新值。
  7. 旧版本终将清理:等到没有任何 ReadView 可能还引用着更旧的那个版本,purge 线程才会把它真正清理掉。

redo 保证了这次修改不会因为崩溃而丢失,undo 保证了如果事务没提交完就能整个撤销,undo 日志顺带留下的版本链变成了 MVCC 实现快照读的基础,而锁则补上了当前读和写写冲突这一半------这就是事务的各项设计真正连在一起的地方。

相关推荐
用户71333585156242 小时前
MySQL - InnoDB 的 Buffer Pool
mysql
L1624764 小时前
MySQL 全环境生产快速安装 + 完整配置手册(整合国产银河麒麟适配 + 主从补充版)
数据库·mysql
前端世界5 小时前
MySQL 基础入门(学习第4天)——约束与 DML 操作详解
数据库·学习·mysql
Vect__7 小时前
MySQL 数据类型和约束:从字段设计到表结构建模
数据库·mysql
Mico189 小时前
MySQL 8.0.35 基于GTID 主从复制安装增强半同步复制
android·mysql·adb
CodexDave21 小时前
MySQL事务隔离级别与MVCC机制解析
前端·数据库·mysql·nginx·性能优化·负载均衡
王琦03181 天前
生产环境中使用通用二进制包安装
mysql
蓝田~1 天前
MySQL慢查询怎么优化?B+Tree索引原理+MVCC读不阻塞写+EXPLAIN执行计划,从5秒到0.05秒
数据库·mysql
Herbert_hwt1 天前
MySQL学习前言:关于MySQL的历史与当下学习的必要性
mysql