一句话概括这几部分内容的关系:事务要满足 ACID,持久性靠 redo 日志实现,原子性靠 undo 日志实现,undo 日志为了支持回滚顺带记下的"旧版本"又被拿来实现了 MVCC,而 MVCC 管不到的那部分隔离性(当前读)则要靠锁来兜底。这篇文章就按这条线索,把实现一个事务所需要的各个设计串起来讲,重点放在每一步"为什么要这么设计"上。
目录
- [事务与 ACID:一个转账的例子](#事务与 ACID:一个转账的例子 "#sec-1")
- [持久性怎么保证:redo 日志](#持久性怎么保证:redo 日志 "#sec-2")
- [原子性怎么保证:undo 日志](#原子性怎么保证:undo 日志 "#sec-3")
- 一个意外的副产品:版本链
- 隔离性问题清单:从脏写到幻读
- [MySQL 的四种隔离级别](#MySQL 的四种隔离级别 "#sec-6")
- [MVCC 的核心:ReadView 与可见性判断](#MVCC 的核心:ReadView 与可见性判断 "#sec-7")
- [RC 与 RR 的本质区别](#RC 与 RR 的本质区别 "#sec-8")
- 隔离性的另一半:当前读与锁
- 版本链什么时候真正清理:purge
- [串起来看:一条 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,判断版本链上某个版本是否可见,就是四条规则依次判断:
- 版本的 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 表里 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 。像 INSERT、UPDATE、DELETE,以及显式加锁的 SELECT ... FOR UPDATE / SELECT ... LOCK IN SHARE MODE,都必须读到最新的、已提交的 数据才有意义------不能拿着一个过时的余额去做加减法。这种"必须读最新版本"的读取方式称为当前读 ,和依赖 ReadView 的快照读相对。
当前读没法靠"多看一个历史版本"来解决冲突,只能靠锁------这正好回答了第五节留下的问题:脏写之所以不属于版本链能解决的范畴,是因为它发生在两个当前读操作之间,InnoDB 通过给记录加锁,保证同一时刻只有一个事务能修改它,另一个事务想改就必须排队等锁。
这里还有一处呼应前文的地方:MySQL 的 REPEATABLE READ 之所以能比 SQL 标准定义得更强、连幻读都能避免,靠的正是锁和 MVCC 的配合 ------对于当前读(比如 SELECT ... FOR UPDATE、INSERT),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;
- 加载页面:如果 id=2001 所在的页不在 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 实现快照读的基础,而锁则补上了当前读和写写冲突这一半------这就是事务的各项设计真正连在一起的地方。