事务与 MVCC:多版本并发控制原理拆解

📌 前置知识:这篇需要理解前两篇的 B+树结构和聚簇索引。核心概念——隐藏列、Undo Log、ReadView——都是在 B+树的聚簇索引叶子页上工作的。建议读到这里时回想前文 InnoDB 页结构中 User Records 的记录头信息。

0. 60 秒速览:用一句话记住 MVCC

先别管术语,用一个生活场景建立直觉。

想象你正在写一份共享文档(Google Docs / 腾讯文档)。你打开它时,看到的是当时那个版本。别人在你之后改了几版,你不会突然看到"文档变了"——除非你刷新。你写的部分,别人在你保存前也看不到。

MySQL 的 MVCC 就是这个机制:

每次修改不覆盖原数据,而是生成一个新版本。读的人看到的是"自己开始读那一刻"的版本快照,写的人不影响正在读的人。

flowchart LR
    subgraph "同一行数据 (id=1, age=25)"
        V3["版本3 age=30
DB_TRX_ID=300
(当前行)"] V2["版本2 age=28
DB_TRX_ID=200"] V1["版本1 age=25
DB_TRX_ID=100
(INSERT 原始版)"] end T1["事务A
开始读"] -->|"ReadView 快照
看到版本1"| V1 T2["事务B
修改两次"] --> V2 T2 --> V3 V3 -.->|"DB_ROLL_PTR
回滚指针"| V2 V2 -.->|"DB_ROLL_PTR"| V1 classDef data fill:#052e16,stroke:#16a34a,stroke-width:2px,color:#bbf7d0,font-weight:bold; classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:2px,color:#e5e7eb; classDef root fill:#0f172a,stroke:#3b82f6,stroke-width:2.5px,color:#bfdbfe,font-weight:bold; class V1,V2,V3 data; class T1,T2 root;

这张图里有 MVCC 的全部核心零件,读完这篇你会逐个认识它们:

零件一句话作用本篇小节
隐藏列(DB_TRX_ID / DB_ROLL_PTR)每行记录"谁改的"+“旧版本在哪”§3
Undo Log旧版本存在哪,串成版本链§4
ReadView读的时候"冻结"一份快照,判断哪个版本可见§5
版本链遍历从最新版本往回找"自己该看到的版本"§6

💡 忘记 MVCC 八股文的人,记住上面这张图就够了:一行数据有多个版本,读的人按快照挑版本,写的人只追加新版本。剩下的是细节。

1. 四种事务隔离级别:MySQL 到底在"隔离"什么

事务隔离级别解决的是 并发事务同时读写同一行数据 时的可见性问题。如果只有一个连接在操作数据库,根本不需要隔离级别——但现实的线上系统有几十上百个并发连接,读写冲突无处不在。

隔离级别定义了 一个事务能看到其他并发事务的哪些修改。SQL 标准定义了四种级别,从宽松到严格:

flowchart LR
    RU["🔓 READ UNCOMMITTED\n(读未提交)"] --> RC["🔒 READ COMMITTED\n(读已提交)"]
    RC --> RR["🔐 REPEATABLE READ\n(可重复读)"]
    RR --> SR["🔑 SERIALIZABLE\n(串行化)"]
    RU_label["脏读❌ 不可重复读❌ 幻读❌"] -.-> RU
    RC_label["不可重复读❌ 幻读❌"] -.-> RC
    RR_label["幻读⚠(部分解决)"] -.-> RR
    SR_label["全部解决✅"] -.-> SR

classDef startEnd fill:#701a4c,stroke:#e11d48,stroke-width:2px,color:#fce7f3,font-weight:bold;
classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#e5e7eb;
classDef highlight fill:#450a0a,stroke:#dc2626,stroke-width:1.5px,color:#fecaca,font-weight:bold;
classDef data fill:#052e16,stroke:#16a34a,stroke-width:1.5px,color:#bbf7d0,font-weight:bold;

    class RU startEnd
    class RC process
    class RR highlight
    class SR data
隔离级别脏读 (Dirty Read)不可重复读 (Non-Repeatable Read)幻读 (Phantom Read)
READ UNCOMMITTED✅ 可能✅ 可能✅ 可能
READ COMMITTED (RC)❌ 不会✅ 可能✅ 可能
REPEATABLE READ (RR)❌ 不会❌ 不会⚠ 部分避免
SERIALIZABLE❌ 不会❌ 不会❌ 不会

三种并发问题的定义

脏读(Dirty Read):读到其他事务 未提交 的修改。事务 A 修改某行但未提交,事务 B 读到了这个未提交的值——如果事务 A 回滚了,事务 B 读到的数据就是"脏"的、从来没有真正存在过的。

不可重复读(Non-Repeatable Read):同一个事务内,同一条记录的两次读取结果不一致。事务 A 读某行(age=25),事务 B 修改该行并提交(age=30),事务 A 再读同一条(age=30)。两次读的版本不一样。

幻读(Phantom Read):同一个事务内,同一条查询的两次执行结果集行数不同。事务 A 查询 WHERE age > 20 (返回 10 行),事务 B 插入一行 age=30 并提交,事务 A 再查 WHERE age > 20 (返回 11 行)。数据"多出来了",像幻象一样。

⚠️ 新手提示:不可重复读和幻读很多人区分不清楚。区分的关键是——不可重复读是 同一条记录的内容变了(UPDATE 导致),幻读是 结果集的行数变了(INSERT/DELETE 导致)。MVCC 的 ReadView 机制在 RR 下能解决不可重复读,但幻读需要 Next-Key Lock 配合才能彻底解决——这是下篇的锁机制要讲的。

1.1 用实际 SQL 演一遍:三种并发问题到底长什么样

光看定义容易晕,直接看两个事务交错执行会"看见"什么。假设有一张表 user(id, name, age),初始数据 id=1, name='张三', age=25

场景一:脏读(READ UNCOMMITTED 下发生)

时刻事务 A事务 BA 看到什么
t1BEGIN
t2BEGIN
t3UPDATE user SET age=30 WHERE id=1 (未提交)
t4SELECT age FROM user WHERE id=130(B 还没提交!)
t5ROLLBACK (回滚了)
t6SELECT age FROM user WHERE id=125(B 回滚后)

A 在 t4 读到的 30,是 B 从未真正提交的值——B 回滚后这个 30 就像没存在过。读到没提交的数据 = 脏读

场景二:不可重复读(READ COMMITTED 下发生)

时刻事务 A事务 BA 看到什么
t1BEGIN
t2SELECT age FROM user WHERE id=125
t3BEGIN
t4UPDATE user SET age=30 WHERE id=1 (提交)
t5SELECT age FROM user WHERE id=130(同一条记录变了!)

同一个事务 A 里,同一条记录两次读到不同值(25 → 30)。同一条记录内容变了 = 不可重复读

场景三:幻读(REPEATABLE READ 下仍可能发生)

时刻事务 A事务 BA 看到什么
t1BEGIN
t2SELECT COUNT(*) FROM user WHERE age > 201
t3BEGIN
t4INSERT INTO user VALUES(2, '李四', 30)(提交)
t5SELECT COUNT(*) FROM user WHERE age > 202(多出一行!)

同一个事务 A 里,同一条查询两次返回不同行数(1 → 2)。结果集行数变了 = 幻读

💡 记不住三个名字?脏读 = 读了没提交的;不可重复读 = 同一条记录内容变了;幻读 = 结果集多出/少了行。前两个是"值的问题",幻读是"行数的问题"。

MySQL InnoDB 的默认隔离级别是 REPEATABLE READ。这个选择背后就是 MVCC 的设计——让 RR 在性能和一致性之间找到平衡。

2. MVCC 是什么:为什么要维护多个版本

MVCC(Multi-Version Concurrency Control,多版本并发控制)的核心思想一句话就能说清楚:读不阻塞写,写不阻塞读

在传统的锁并发控制(LBCC,Lock-Based Concurrency Control)中,要读一行数据需要加共享锁,要写一行需要加排他锁。读写冲突时,要么读在等写锁释放,要么写在等读锁释放——吞吐量被锁等待吃掉。

MVCC 的做法是:每次修改不覆盖原数据,而是生成一个新版本。读操作根据事务开始的时间,选择一个"应该看到"的版本,不需要加锁;写操作创建新版本后旧版本仍然保留,不影响正在进行的读。这样读写分离、互不阻塞。

把两种方案放在一起对比,原理立刻清晰:

flowchart LR
    subgraph LBCC["❌ 传统锁并发控制(LBCC)
读写互斥,排队等待"] direction TB R1["事务 B 要读
age=25"] -->|"加共享锁"| W1["事务 A 持有排他锁
正在写 age=30"] W1 -->|"锁冲突!
B 必须等 A 提交"| Q1["⏳ B 阻塞等待"] end subgraph MVCC["✅ 多版本并发控制(MVCC)
读写并行,互不干扰"] direction TB R2["事务 B 要读
age=25"] -->|"无需加锁
直接读旧版本"| V2["版本链上的 age=25
(Undo Log 中的旧版本)"] W2["事务 A 正在写
age=30"] -->|"生成新版本
不覆盖旧数据"| V3["数据页新版本 age=30"] end classDef reject fill:#450a0a,stroke:#dc2626,stroke-width:2px,color:#fecaca,font-weight:bold; classDef data fill:#052e16,stroke:#16a34a,stroke-width:2px,color:#bbf7d0,font-weight:bold; classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:2px,color:#e5e7eb; class R1,W1,Q1 process; class R2,W2 process; class V2,V3 data;

💡 左边是"一把锁管读写"——同一时刻只有一个人能碰这行数据;右边是"写的人造新版本,读的人看旧版本"——两个人各干各的,谁也不等谁。这就是 MVCC 为什么能提升并发吞吐的本质:把"读写互斥"变成"读写并行"。

MVCC 的"多版本"体现在 InnoDB 维护了三个机制:

  1. 隐藏列:每行数据有两个隐藏字段,记录最后一次修改的事务 ID 和指向旧版本的回滚指针
  2. Undo Log(回滚日志):旧版本数据存在 Undo Log 中,通过回滚指针串联成版本链
  3. ReadView(读视图):读操作创建一个"快照",记录当前活跃事务的集合。用这个快照判断版本链上的每个版本是否可见

接下来的三节逐个拆解这三个机制。

3. 隐藏列:每行数据自带的三个隐藏字段

InnoDB 在用户的每一行数据后面偷偷加了三个隐藏字段。建表时看不到它们,但它们真实地存在 16KB 页的 User Records 区域里。

📄 InnoDB 行记录格式(COMPACT 行格式)
📋 变长字段长度列表 — VARCHAR 等变长列的实际长度(2 字节/列)
📍 NULL 值位图 — 哪些列是 NULL(1 bit/可空列)
🔖 记录头信息(5 字节) — delete_flag / min_rec_flag / n_owned / next_record 偏移量
📝 用户列数据 — id / name / age / ...(用户定义的列)
🔑 DB_ROW_ID(6 字节) — 隐藏主键。用户如果没定义主键 + 无 UNIQUE NOT NULL 列,InnoDB 自动生成
🔄 DB_TRX_ID(6 字节)最近一次修改本行的事务 ID。MVCC 可见性判断的核心依据
DB_ROLL_PTR(7 字节)回滚指针。指向 Undo Log 中的上一个版本。如果本行被多次更新,这个指针把各版本串联起来

DB_TRX_ID 和 DB_ROLL_PTR 是 MVCC 的物理基础

  • DB_TRX_ID:每个事务有全局唯一的递增 ID。修改一行时,把当前事务的 ID 写入本行的 DB_TRX_ID 字段。读操作通过比较这个 ID 和 ReadView 中的活跃事务列表,判断该版本是否可见。
  • DB_ROLL_PTR:指向 Undo Log 中的旧版本。如果一行被更新了 5 次,就有 5 个版本通过 5 个回滚指针串联成版本链。

4. Undo Log:版本链是怎么串起来的

Undo Log 不只是一串"旧值"的集合——不同类型的操作产生不同类型和不同用途的 Undo 日志。

INSERT 操作:因为插入的行对其他事务不可见(在插入事务提交之前),所以 INSERT Undo Log 只需记录插入行的主键值。事务提交后,INSERT Undo Log 立即可以被回收

UPDATE 操作(分两种情况):

  • 不更新主键:UPDATE Undo Log 记录被修改列的 旧值。把当前行的 DB_TRX_ID 备份到 Undo Log,再把新的 DB_TRX_ID 写入行。同时将 DB_ROLL_PTR 指向刚写入的 Undo Log。
  • 更新了主键:等同于 DELETE(对旧主键行打 delete_flag)+ INSERT(新主键行)。

DELETE 操作(分两个阶段):

  • 阶段一 delete mark:只打 delete_flag = 1 ,不物理删除。记录 DELETE Undo Log。
  • 阶段二 purge:Purge 线程负责物理删除。条件是 undo log 对应的旧版本 不再被任何 ReadView 需要

下面用 HTML+CSS 展示一个更新操作形成的版本链:

🔗 版本链:某行被更新 3 次后形成的 Undo Log 链
📝 当前行(聚簇索引叶子页中的最新版本)
DB_TRX_ID = 300   |   DB_ROLL_PTR ──→ Undo Log #2
id=42   name='Charlie'   age=28
Undo Log #2(UPDATE Undo)
DB_TRX_ID = 200   |   DB_ROLL_PTR ──→ Undo Log #1
旧值:name='Bob'   age=25
Undo Log #1(UPDATE Undo)
DB_TRX_ID = 100   |   DB_ROLL_PTR ──→ Undo Log #0
旧值:name='Alice'   age=22
Undo Log #0(INSERT Undo)
DB_TRX_ID = 100   |   DB_ROLL_PTR = NULL(链尾)
这是插入该行的原始版本
🔍 可见性判断流程:ReadView → 读当前行的 DB_TRX_ID(300)→ 不可见?→ 沿 DB_ROLL_PTR 到 Undo Log #2 → 读 DB_TRX_ID(200)→ 不可见?→ Undo Log #1 → DB_TRX_ID(100)→ 可见!→ 返回 name='Alice' age=22

版本链的关键特征

  • 链尾始终是 INSERT Undo Log——这是该行的"出生证明"。之前的版本不存在。
  • PURGE 线程定期清理不再被任何 ReadView 需要的旧版本。如果某条 Undo Log 的 DB_TRX_ID 比所有活跃事务的 ID 都小(说明所有事务都能看到更新版本),这个 Undo Log 就安全了,可以被清理。
  • 长事务会阻止 Undo Log 清理。如果一个事务运行了很久,它的 ReadView 还是旧的——活跃事务 ID 列表里包含很多已经提交的事务。这些"已经提交但 ReadView 认为还不该看到"的事务产生的 Undo Log 不会清理,导致 Undo Log 膨胀。

4.1 Undo Log / Redo Log / Binlog:三个日志各管一件事

说到 Undo Log,很多人会把它和 Redo Log、Binlog 搞混——面试八股文里"日志三兄弟"经常一起考。先把三者分工用一张图钉死:

flowchart TB
    subgraph "事务执行一条 UPDATE"
        S["修改数据页"] --> U["写 Undo Log
记录旧值"] S --> R["写 Redo Log
记录物理修改"] S --> B["写 Binlog
记录逻辑操作"] end U -->|"作用1: 回滚
事务失败时撤销"| U1["ROLLBACK 恢复旧值"] U -->|"作用2: 版本链
MVCC 读历史版本"| U2["配合 DB_ROLL_PTR
构建多版本"] R -->|"作用: 崩溃恢复
断电后重放已提交修改"| R1["WAL + Checkpoint"] B -->|"作用: 主从复制
从库重放达到一致"| B1["ROW / STATEMENT / MIXED"] classDef data fill:#052e16,stroke:#16a34a,stroke-width:2px,color:#bbf7d0,font-weight:bold; classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:2px,color:#e5e7eb; classDef root fill:#0f172a,stroke:#3b82f6,stroke-width:2.5px,color:#bfdbfe,font-weight:bold; class S root; class U,R,B process; class U1,U2,R1,B1 data;
日志属于哪层记录什么核心用途能不能删
Undo LogInnoDB 存储引擎层修改前的旧值(逻辑)① 回滚未提交事务 ② MVCC 版本链事务提交后慢慢清
Redo LogInnoDB 存储引擎层修改后的物理页变化崩溃恢复(重放已提交修改)循环写,Checkpoint 后覆盖
BinlogMySQL Server 层修改的逻辑操作主从复制 + 时间点恢复按保留期归档

怎么记住三者的区别

  • Undo = 后悔药:改错了能回退(回滚 + 提供旧版本给 MVCC 读)
  • Redo = 保险单:断电了数据不丢(崩溃后重放)
  • Binlog = 录像带:记录整个操作过程,给从库"重播"(复制)

📌 面试最爱问的"两阶段提交"(Redo 和 Binlog 怎么保持一致)和"崩溃恢复流程"(Redo 重放 + Undo 回滚),在系列下一篇《MySQL 锁与日志系统》里有完整拆解。这篇只需要记住:MVCC 只用到 Undo Log;Redo 和 Binlog 是保证"持久性"和"复制"的另外两条线,和 MVCC 的"可见性"各管各的

5. ReadView:那一刻"谁在跑"决定了你能看到什么

ReadView 的核心数据结构简单但精妙。它是一个 事务在读取数据时创建的快照,记录了那一时刻"谁还在跑"。

📷 ReadView 结构(事务执行 SELECT 时创建)
creator_trx_id(6 字节)
创建该 ReadView 的事务 ID。判断可见性时:DB_TRX_ID == creator_trx_id → 自己修改的 → 始终可见
trx_ids(可变长度列表)
创建 ReadView 时,系统中 所有活跃事务(未提交) 的 ID 列表。如 [101, 105, 108, 112]——这四个事务还没 COMMIT,它们的修改当前不可见
min_trx_id(6 字节)
trx_ids 列表中的最小值。当前活跃事务中最早开始的。DB_TRX_ID < min_trx_id → 修改已提交 → 可见
max_trx_id(6 字节)
系统下一个将分配的事务 ID(当前最大事务 ID + 1)。DB_TRX_ID ≥ max_trx_id → 修改来自"未来"事务 → 不可见

可见性判断的完整规则(从一行数据的 DB_TRX_ID 开始逐条判断):

判断条件结论说明
DB_TRX_ID == creator_trx_id✅ 可见自己改的,自己当然能看到
DB_TRX_ID < min_trx_id✅ 可见修改该行的事务在 ReadView 创建前已提交
DB_TRX_ID >= max_trx_id❌ 不可见修改该行的事务在 ReadView 创建后才开始——“未来的修改”
DB_TRX_IDtrx_ids❌ 不可见修改该行的事务在 ReadView 创建时还未提交
min_trx_id ≤ DB_TRX_ID < max_trx_id 且不在 trx_ids✅ 可见修改该行的事务在 ReadView 创建时已提交(不在活跃列表 = 已提交)

把上面这张表画成决策流程,判断顺序一目了然——从版本链最新版本开始,逐条往下走:

flowchart TD
    A["读到一个版本的 DB_TRX_ID"] --> B{"DB_TRX_ID ==
creator_trx_id?"} B -->|"是"| VIS1["✅ 可见
自己改的"] B -->|"否"| C{"DB_TRX_ID
< min_trx_id?"} C -->|"是"| VIS2["✅ 可见
ReadView 创建前已提交"] C -->|"否"| D{"DB_TRX_ID
>= max_trx_id?"} D -->|"是"| INV1["❌ 不可见
'未来'事务的修改"] D -->|"否"| E{"DB_TRX_ID
在 trx_ids 中?"} E -->|"是"| INV2["❌ 不可见
ReadView 创建时未提交"] E -->|"否"| VIS3["✅ 可见
已提交(不在活跃列表)"] INV1 --> F["沿 DB_ROLL_PTR
找下一个旧版本"] INV2 --> F F --> A classDef condition fill:#2a1147,stroke:#a855f7,stroke-width:2px,color:#ede9fe,font-weight:bold; classDef reject fill:#450a0a,stroke:#dc2626,stroke-width:2px,color:#fecaca,font-weight:bold; classDef data fill:#052e16,stroke:#16a34a,stroke-width:2px,color:#bbf7d0,font-weight:bold; classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:2px,color:#e5e7eb; class A,F process; class B,C,D,E condition; class INV1,INV2 reject; class VIS1,VIS2,VIS3 data;

💡 判断口诀:先问"是不是自己"→ 再问"是不是够老(<min)"→ 再问"是不是太新(>=max)"→ 最后查"在不在活跃列表"。前两步直接放行,中间两步直接拒绝,最后一步查表定论。不可见就沿版本链往前找旧版本,直到找到可见的或走到链尾。

6. MVCC 完整流程:从 SELECT 到返回结果

这一节用一张完整的流程图串联前文所有知识点——SELECT 语句执行时,MVCC 从头到尾做了什么。

MVCC 完整流程图

流程分四步

第一步:创建 ReadView。RC 下每次 SELECT 都创建新的 ReadView(所以能看到别的事务刚提交的修改);RR 下只在事务第一次 SELECT 时创建(后续复用同一个 ReadView,保证一致性)。

第二步:B+树查找目标行。走聚簇索引或二级索引定位到聚簇索引叶子页中的最新行版本。

第三步:版本链遍历。读该行的 DB_TRX_ID,对照 ReadView 判断可见性。不可见?沿 DB_ROLL_PTR 跳到 Undo Log 中的上一个版本,继续判断,直到找到第一个可见版本或到达链表尾部。链表尾部就是 INSERT Undo——再往前就没有该行的任何版本了。

第四步:返回可见版本的数据。如果在版本链上找到了可见版本,返回那个版本对应的值。如果走到链尾还没找到可见版本(这种情况极少见——说明该行在 ReadView 创建之后才插入并且提交了),则该行对当前事务不可见,跳过这一行。

⚠️ 新手提示:REPEATABLE READ(RR)下的 MVCC 不是绝对意义上的"可重复读"——它只保证 已读过的行 不会变。如果事务 A 的 SELECT 还没扫到某个范围,事务 B 在该范围内插入新行并提交,事务 A 再次 SELECT 那个范围时能看到新行——这就是幻读。RR 的不彻底之处就在这里。彻底消除幻读要靠 Next-Key Lock(下篇讲)。

7. RC vs RR:ReadView 的创建时机决定了隔离级别

RC 和 RR 的隔离行为差异,根源在于 ReadView 创建的时机不同

RC vs RR ReadView 对比

READ COMMITTED(RC)每次 SELECT 都创建新的 ReadView。这意味着每次读都能看到最新的已提交版本——不同事务的修改一旦提交就对当前事务可见。优点是"读已提交"语义简单、Undo Log 压力小(旧版本很快被 Purge 回收)。缺点是同一个事务内对同一行的两次查询可能得到不同结果(不可重复读)。

REPEATABLE READ(RR)只在本事务第一次 SELECT 时创建 ReadView,后续所有读复用同一个。ReadView 在事务开始时"冻结"了一幅快照,之后其他事务的任何提交在当前事务中都不可见。优点是避免了不可重复读。缺点是 Undo Log 压力大——一个长事务的 ReadView 持有很旧的 trx_ids 列表,阻止 Purge 线程回收任何它认为"不可见"的版本的 Undo Log。

维度RC(读已提交)RR(可重复读)
ReadView 创建每次 SELECT事务内首次 SELECT
不可重复读可能不会
Undo Log 压力大(长事务致命)
适用场景报表统计、对一致性要求不高OLTP 业务(InnoDB 默认)

MVCC 无法替代锁的场景:MVCC 只解决"读-写"冲突,而 写-写 冲突 MVCC 不管。假设当前行版本的 age=25,事务 A 和事务 B 同时读到 age=25 并都想改为 26。如果只用 MVCC,两个事务都会创建各自的新版本,最终只有一个能成功——另一个会在 COMMIT 时被检查到冲突(这个检查不是 MVCC 做的,是锁机制做的,下篇详述)。

8. 总结

MVCC 是理解 MySQL 事务的核心。三句话总结其本质:

MVCC 通过维护多版本数据实现了"读写互不阻塞"。每次修改产生新版本而非覆盖旧版本,读操作通过 ReadView 快照选择可见版本,完全不需要锁。

Undo Log + DB_ROLL_PTR 组成版本链。行记录的隐藏列指向 Undo Log 中的旧版本,形成一个由新到旧的单向链表。ReadView 沿链表遍历,找到第一个"当时已提交"的版本。

ReadView 的创建时机是 RC 和 RR 唯一的分叉。RC 每次 SELECT 创建新视图、RR 在事务首次 SELECT 创建后复用——这一差异决定了脏读和不可重复读的表现。

下一篇讲 MySQL 锁与日志系统——LBCC 的锁类型(Record Lock / Gap Lock / Next-Key Lock)、Redo Log 和 Binlog 的两阶段提交、以及崩溃恢复怎么靠日志保证数据不丢。