MVCC 多版本并发控制深度工程实践:从 InnoDB 到 TiDB 的事务隔离实现

摘要:MVCC(Multi-Version Concurrency Control)是现代数据库实现高并发读写的基石技术。本文深入剖析 MySQL InnoDB 与 PostgreSQL 的 MVCC 内部实现机制,解读 undo log、xmin/xmax、快照隔离的本质差异,并延伸到分布式数据库 TiDB 与 CockroachDB 的 MVCC 工程演进。最后给出一套基于 MVCC 的工程决策框架,帮助你在面对幻写、Write Skew、Vacuum 失效等生产问题时做出正确判断。


一、为什么我们需要 MVCC

在数据库系统的早期,并发控制主要通过两阶段锁(2PL)实现:读操作加 S 锁,写操作加 X 锁。这个方案的致命问题是——读写互斥。在一个读多写少的 OLTP 系统中,大量 SELECT 被阻塞在写锁上,吞吐量急剧下降。

MVCC 的核心思路极具颠覆性:写操作不阻塞读操作,读操作也无需加锁。通过维护数据的多个版本,每个事务看到的是特定时间点的数据快照(Snapshot),从而实现真正的非阻塞读。

这三个问题的答案是理解 MVCC 的起点:

  1. 旧版本数据存在哪里?
  2. 事务如何确定自己应该看到哪个版本?
  3. 旧版本什么时候被清理?

接下来我们逐一拆解。

二、InnoDB 的 MVCC 实现:undo log 驱动的隐式版本链

2.1 隐藏列与版本链

InnoDB 在每一行记录中维护三个隐藏列:

  • DB_TRX_ID(6字节):最后修改该记录的事务 ID
  • DB_ROLL_PTR(7字节):指向 undo log 中旧版本记录的回滚指针
  • DB_ROW_ID(6字节):若无主键,自动生成的行 ID

当你执行 UPDATE 操作时,InnoDB 并不原地修改数据。它做三件事:

  1. 将当前行的旧版本写入 undo log
  2. 在当前行上更新数据,并将 DB_TRX_ID 设置为当前事务 ID
  3. 通过 DB_ROLL_PTR 将新旧版本链接起来

这就形成了一条版本链。例如初始一行数据为 (id=1, name='Alice', trx_id=10),事务 20 更新 name 为 'Bob',事务 30 再更新 name 为 'Carol':

[Carol, trx_id=30] → roll_ptr → [Bob, trx_id=20] → roll_ptr → [Alice, trx_id=10]

读操作沿着版本链回溯,直到找到对自己"可见"的版本为止。

2.2 Read View:一致性快照的判定器

InnoDB 在 RC(Read Committed)和 RR(Repeatable Read)隔离级别下使用 Read View 来判断版本可见性。Read View 在事务首次执行 SELECT 时(RR)或每次执行 SELECT 时(RC)生成。

Read View 内部维护四个关键字段:

  • m_ids:生成快照时活跃(未提交)的事务 ID 列表
  • min_trx_id:m_ids 中的最小事务 ID
  • max_trx_id:下一个待分配的事务 ID(不是当前最大活跃事务 ID)
  • creator_trx_id:创建该 Read View 的事务 ID

判定规则如下(对某行的 DB_TRX_ID 做判断):

if (trx_id == creator_trx_id) → 自身修改,可见
if (trx_id < min_trx_id) → 已提交的历史事务,可见
if (trx_id >= max_trx_id) → 快照开启后才启动的事务,不可见
if (trx_id in m_ids) → 快照开启时仍活跃的事务,不可见
else → 已提交的事务,可见

实践中这条规则可以用一个简单的心法记忆:快照启动前已提交的可看见,快照启动时仍活跃的不可见,快照启动后新来的不可见。

2.3 一个详细的可见性判断用例

假设有以下事务操作时序:

时刻 T1:  trx 10 启动, UPDATE SET name='Bob' (未提交)
时刻 T2:  trx 20 启动, 首次 SELECT (生成 Read View)
时刻 T3:  trx 10 提交
时刻 T4:  trx 30 启动, UPDATE SET name='Carol'

trx 20 在 T2 时刻生成的 Read View 中: - m_ids = [10, 20](如果 trx 20 还未开始执行则为 [10]) - min_trx_id = 10 - max_trx_id = 21(下一个将分配的值)

trx 20 读取该行时: - 当前行 trx_id = 10(trx 30 还未更新) - trx_id = 10 在 m_ids 中 → trx 10 在 trx 20 开启快照时仍活跃 - 因此不可见 → 沿 roll_ptr 找到 trx_id = NULL 的前身 → 返回 'Alice'

注意,即使 trx 10 在 T3 时刻已经提交,trx 20 后续仍然看到 'Alice'。这就是可重复读的保证。这一点在面试中经常被错误理解——有人以为提交后就可见,实际上可见性由快照锁定。

2.4 RC vs RR 的本质区别

只差一个"时机":

  • Repeatable Read:首次 SELECT 时生成 Read View,整个事务复用同一个快照
  • Read Committed:每次 SELECT 都生成新的 Read View

这意味着在 RC 下,同一个事务内前后两次 SELECT 可能看到不同的已提交结果(不可重复读),而 RR 下保证一致。

在 MySQL InnoDB 中,RR 也是默认隔离级别,而且在大多数场景下提供最好的并发性能平衡。

三、PostgreSQL 的 MVCC:xmin/xmax 与 Vacuum 的代价

PostgreSQL 的 MVCC 实现哲学与 InnoDB 截然不同——它直接在堆表中维护多版本,而非依赖 undo log。

3.1 元组级别的可见性标记

PostgreSQL 中每一行(Heap Tuple)的头部有两个关键字段:

  • xmin:插入该行版本的事务 ID(即 INSERT 的事务 ID)
  • xmax:删除/更新该行版本的事务 ID(更新 = 逻辑上的删除+插入)

事务的可见性判定使用 CLOG(Commit Log,又称 pg_xact)。PostgreSQL 并不为每个事务维护 Read View,而是通过对 xmin / xmax 逐一检查来判断可见性:

元组 T(t_xmin=10, t_xmax=0)  // 由 trx 10 插入,尚未删除

新事务 trx 20 读取时:
1. 检查 t_xmin=10,查询 pg_xact → trx 10 已提交 → T 的插入可见
2. 检查 t_xmax=0 → 无删除操作 → T 对 trx 20 可见

PostgreSQL 还维护了 pg_subtrans 子事务状态表和 shared buffer 中的 hint bits(committed/aborted),避免每次都要去查 CLOG。

3.2 Transaction ID Wraparound:Rollover 危机

PostgreSQL 的事务 ID 是 32 位无符号整数,最大值约 42 亿。MVCC 可见性判断依赖事务 ID 的有序性——如果你的快照开始时有一个活跃事务 ID = 2^30,然后又插入了一条 trx_id = 2^31 + 1000 的记录,由于事务 ID 回绕,旧事务可能误判新事务在它之前开始,导致"历史数据丢失"。

这个问题的解决方案是事务 ID 回绕保护机制(Transaction ID Wraparound Protection):

  • 每张表都有一个 .relfrozenxid 字段,记录该表中最老未冻结的 XID
  • 当 (CurrentXID - relfrozenxid) > 2 billion(一半)时,PostgreSQL 会强制触发激进 Vacuum(Aggressive Vacuum)
  • 激进 Vacuum 会在整个数据库范围内扫描,对老旧元组做 FREEZE(将 xmin 替换为特殊的 FrozenXID=2),保证它们的可见性对所有后续事务恒成立

如果 Vacuum 跟不上速度,PostgreSQL 最终会拒绝新事务,输出告警:

database "mydb" must be vacuumed within 1000000 transactions
HINT: To avoid a database shutdown, execute a database-wide VACUUM in "mydb".

这就是 DBA 最恐惧的事务 ID 回绕(XID Wraparound)事件。

3.3 VACUUM 的工程法则

PostgreSQL 的 VACUUM(非 FULL)只做三件事:

  1. 冻结旧元组:将 xmin ≤ oldestXmin 的元组标记为 Frozen
  2. 回收空间(Page-level):将已删除的 Dead Tuple 占用的空间标记为 Free Space Map(FSM)可用
  3. 更新可见性映射(VM):标记每个 block 是否存在仍需 VACUUM 的行,索引 Vacuum 可跳过无死行的 block

VACUUM 不会将空间交还 OS(除非全量 VACUUM FULL 带 ACCESS EXCLUSIVE 锁)。这意味着 PostgreSQL 的表会产生表膨胀(Table Bloat)——尤其是高更新频率的表。

生产环境的 Vacuum 策略建议:

-- 查看表膨胀情况
SELECT schemaname, relname,
       n_dead_tup, n_live_tup,
       round(n_dead_tup / NULLIF(n_live_tup, 0) * 100, 2) AS dead_pct
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;

-- 查看每张表的 oldest xmin
SELECT relname, age(relfrozenxid) AS xid_age
FROM pg_class
ORDER BY age(relfrozenxid) DESC
LIMIT 20;

-- 建议阈值
-- autovacuum_vacuum_scale_factor = 0.1(表超过10%死元组时触发)
-- 对高频更新表建议设置为 scale_factor=0.01, threshold=50

3.4 HOT 更新:PostgreSQL 的优化利器

频繁更新同一行时,传统做法会产生大量死元组,进而引发糟糕的索引维护成本(更新索引指向的新元组版本)。PostgreSQL 的 HOT(Heap-Only Tuple)更新 可以绕过大部分索引更新:

  • 条件:新版本与旧版本在同一 page 内,且不修改任何索引列
  • 效果:旧元组的 t_ctid 指向新元组,索引仍指向旧元组,查询时沿着 t_ctid chain 找到第一个可见版本
  • 优势:不触发表上任何索引的 update

可以通过 pgstattuple 扩展观察 HOT 更新的比例:

CREATE EXTENSION pgstattuple;
SELECT * FROM pgstatindex('idx_users_email');
-- 观察叶子节点中的 HOT chain 数量

HOT 更新的工程意义是——修改非索引列的高频_update_场景(如 last_login_time、counter 字段),应该把这些列拆到单独的窄表上,避免与频繁更新的索引列共存于同一元组。

四、Snapshot Isolation 与 Write Skew:MVCC 的盲区

4.1 Snapshot Isolation 的定义

大部分 MVCC 数据库默认提供 Snapshot Isization(SI),它有三个核心保证:

  1. 事务读取的是某个一致性快照(Snapshot Read)
  2. 只有无冲突的写入才能提交(First Committer Wins)
  3. 事务看到的数据在逻辑上像在单一时刻执行

SI 看似很完美,但存在一个著名的异常:Write Skew(幻写)。

4.2 Write Skew 经典例子

医院至少需要一名医生值班。当前 Alice 和 Bob 都在值班。他们同时决定请假:

事务 T1: IF (count(on_call) >= 2) THEN UPDATE alice SET on_call=false
事务 T2: IF (count(on_call) >= 2) THEN UPDATE bob SET on_call=false

在 SI 下:

  • T1 读取 on_call=true 计数 = 2,满足条件,更新 Alice
  • T2 读取 on_call=true 计数 = 2(T1 未提交,T2 看不到),满足条件,更新 Bob
  • 结果:两个都 off-call,违反业务约束

这种异常在 SI 下无法避免,只有 Serializable Snapshot Isolation(SSI)或显式锁(SELECT FOR UPDATE)才能解决。

解决方案之一:

-- 显式锁(悲观方案)
BEGIN;
SELECT count(*) FROM doctors WHERE on_call=true FOR UPDATE;
-- T2 会阻塞等待 T1 提交
UPDATE doctors SET on_call=false WHERE name='Alice';
COMMIT;

-- Serializable 隔离(乐观方案)
BEGIN ISOLATION LEVEL SERIALIZABLE;
IF (count(on_call) >= 2) THEN UPDATE alice SET on_call=false;
COMMIT;
-- 如果检测到异常,PostgreSQL 自动回滚并返回 serialization_failure

4.3 PostgreSQL 的 SSI 实现

PostgreSQL 9.1 引入了 Serializable Snapshot Isolation(SSI),它在 SI 的基础上增加了读写依赖图(Serialization Graph)的跟踪。当检测到 rw-dependence 构成的环时,回滚其中一个事务。

SSI 的原理:

  1. 写操作时读取的元组会被标记为 rw-conflict-in
  2. 元组写入后会被标记为 rw-conflict-out
  3. 如果两个活跃事务的 rw-conflict-in/out 边形成环 → 触发 SIReadLock 检测
  4. 如果检测到危险结构("pivot"点)→ 回滚其中一个牺牲事务

代价是 SSI 会导致更多的 serialization_failure,应用层必须实现重试逻辑。实践表明,低冲突场景下 SSI 性能与 SI 几乎相同,但高冲突场景下(>50% 事务冲突)重试成本会急剧上升。

五、InnoDB 下的 Gap Lock 与 Next-Key Lock

MySQL InnoDB 的 RR 隔离级别通过 Next-Key Lock 消除了幻读异常。这是一种组合锁,覆盖记录本身(Record Lock)和记录前的间隙(Gap Lock)。

索引:  10  20  30  40

SELECT * FROM t WHERE id BETWEEN 15 AND 35 FOR UPDATE;

锁定范围:
(10, 20]  → next-key lock on 20
(20, 30]  → next-key lock on 30
(30, 40)  → gap lock before 40

这意味着 insert id=25 会被阻塞,但 select id=25 不会(快照读无需锁)。

工程启示:在高并发的 MySQL 生产环境中,大量的 Gap Lock 和 Next-Key Lock 可能导致锁等待(SHOW ENGINE INNODB STATUS 可见)。通过以下方式降低锁竞争:

  1. 业务上尽量让更新命中精确索引记录(减少 Gap Lock 范围)
  2. 降低隔离级别到 RC(如果业务逻辑能容忍不可重复读)
  3. 大事务拆分为小事务,减少锁持有时间

六、分布式 MVCC:从 TiDB / CockroachDB 到 Calvin

分布式数据库的 MVCC 面临额外挑战:时间戳全局有序和跨节点事务可见性。

6.1 TiDB 的 Percolator 模型

TiDB 采用 Google Percolator 协议实现分布式 MVCC:

  • 数据按 Key-Value 模型存储在 TiKV 中
  • 写事务在 commit_ts 分配时全局有序(通过 TSO 或 HLC 时间戳源)
  • 读事务在 start_ts 确定的快照上执行
  • 每行数据维护 commit_ts 时间戳,读取时回滚到 ≤ start_ts 的最新版本

架构分层:

TiDB (SQL 层)   →   TiKV (KV 存储层, Raft 复制)
     │                        │
 TSO 时间戳戳         Raft Group (Region)
 两阶段提交          RocksDB 存储多版本

Percolator 的 Lock 列在事务未解决时保留在 KV 中,事务结束后由后台 GC Worker 清理。因此存在残留锁清理——如果一个事务崩溃,它的 Lock 列会残留,需要其他事务的锁清理过程来发现并 push/write rollback 清理。

6.2 CockroachDB 的并行提交与读刷新

CockroachDB 在 Percolator 基础上引入 Parallel Commits 优化:

传统 Percolator 的两阶段提交:

阶段 1 (Prewrite):  写 Primary Lock + Secondary Locks
阶段 2 (Commit):    写 Primary Commit timestamp, 异步清理 Secondary

Parallel Commits 在 Prewrite 时即写入 Commit Timestamp,利用异步的 Staging 状态(每个 Lock 列带上 commit_ts),使读取者能推断事务结果而不必等待显式 Commit 完成:

// 简化版伪逻辑
func (s *WriteIntentResolver) resolveWriteIntent(intent Lock) (resolved bool) {
    if intent.status == STAGED && intent.commit_ts != nil {
        // 推断已提交,异步完成提交
        return true
    }
    // 检查 Primary Lock 状态
    primary := readPrimaryLock(intent.primary_key)
    switch primary.status {
    case COMMITTED: return true   // 已提交
    case ABORTED:   return false  // 已回滚
    case PENDING:   return false  // 不确定,需等待或清理
    }
}

核心收益:事务延迟可从 2 次跨节点 RTT(Prewrite + Commit)降低到 1.x 次 RTT,P99 延迟显著下降。

6.3 HLC vs TSO:时间戳分配的工程取舍

TiDB 使用 TSO(Timestamp Oracle)通过单点分配递增时间戳,确保全序关系,但跨-region 请求会成为瓶颈。

CockroachDB 使用 HLC(Hybrid Logical Clock),结合物理时钟(NTP 同步)和逻辑计数器,实现无需中心化 TSO 的全序:

type HLC struct {
    wall_time int64  // 物理时钟(微秒)
    logical   int32   // 物理时间相同时的递增计数器
}

// 时间戳比较规则
func (h HLC) Less(other HLC) bool {
    if h.wall_time != other.wall_time {
        return h.wall_time < other.wall_time
    }
    return h.logical < other.logical
}

HLC 的理论上限:依赖 NTP 同步精度。CockroachDB 默认容忍 ±500ms 的时钟偏移(--max-offset=500ms),超出会拒绝写入以保证一致性权衡。

七、MVCC 的工程决策框架

7.1 选型矩阵

维度 InnoDB MVCC PG MVCC TiDB/CockroachDB MVCC Spanner TrueTime MVCC
版本存储 Undo Log(共享表空间) Heap Tuple(表内多版本) RocksDB 多版本 存储层内置
读锁污染 极少(回滚段消耗大时) 几乎无(读不阻塞写) 少(Lock 列残留风险) 极少
写锁放大 版本链维护、Purge 线程 VACUUM、膨胀、FrozenXID GC Worker、残留锁清理 False Waiting
旧版本清理 Purge Thread(异步) AUTOVACUUM(周期性) GC Worker(按 gc_life_time) GC Worker(异步)
历史读 不能(版本链随 Purge 消失) 不能(Vacuum 后消失) 能(tikv_gc_life_time 内) 能(历史窗口)
只读事务性能 最优(无锁快照读) 最优(无锁快照读) 同行 同行

7.2 工程 CheckList

以下基于生产实践经验总结的 MVCC 工程 checklist:

[A] Undo Log 管理(InnoDB)

  • 监控 innodb_history_list_length:如果持续增长说明 Purge 跟不上写量
  • 避免超大长事务:每次提交前 undo log 都在回滚段积累,长事务导致 purge lag
  • 定期 review 是否有长时间未提交的事务(information_schema.INNODB_TRX)
-- 监控 Purge Lag
SHOW ENGINE INNODB STATUS\G
---TRANSACTION 0, not started
Purge done for trx's n:o < 12345
History list length 2847  // 重点关注这个

[B] 膨胀与 VACUUM(PostgreSQL)

  • 对每张表单独调优 Autovacuum 参数(不要全局一刀切)
  • 高频小更新表(如 counters)单独拆表或使用 pg_repack 在线重组
  • 监控 pg_stat_user_tables.n_dead_tup 和 age(pg_class.relfrozenxid)
  • 超大表(>100GB)考虑分区(Partitioning),降低单次 Vacuum 成本

高膨胀表的急救方法:

-- 在线重组表(不阻塞读写,但需要额外磁盘空间)
-- 方案 1:pg_repack(需安装扩展)
SELECT pg_repack_table('big_table');

-- 方案 2:手动分批 DELETE + VACUUM(低峰期操作)
DO $$
DECLARE
    batch INT := 10000;
BEGIN
    LOOP
        DELETE FROM log_table WHERE id IN (
            SELECT id FROM log_table WHERE created_at < now() - interval '90 days'
            LIMIT batch
        );
        EXIT WHEN NOT FOUND;
        PERFORM pg_sleep(0.5);  -- 避免 IO 尖刺
        COMMIT;
    END LOOP;
END $$;

[C] 分布式 MVCC 调优(TiDB)

  • tikv_gc_life_time 默认 10m,长事务场景需调大(但会增加磁盘使用)
  • tidb_gc_concurrency 根据节点数配置,通常 = region_count / 1000
  • 监控 Residual Lock:select * from information_schema.tidb_trx where state='preliminary'

[D] 通用反模式与解决方案

反模式 后果 解决方案
长事务 + 大块写 Undo log 暴涨/历史链过长 拆小事务,每 5000 行提交一次
SELECT FOR UPDATE 在高 RR 下滥用 Next-Key Lock 导致死锁频发 评估是否可用 RC + 业务补偿
PostgreSQL 未调优 AUTOVACUUM XID Wraparound 风险 pg_hba.conf 设置 idle_in_transaction_session_timeout
在 PG 热点计数器用普通 UPDATE 膨胀严重,每行都在版本链末端 使用 UPSERT(pg 9.5+)或 insert ... on conflict update

八、源码级关键洞察

8.1 InnoDB Purge 线程如何推进 low water mark

Purge 调用栈(简化版):

srv_purge_worker_thread
  → trx_purge()
    → trx_purge_attach_undo_recs()    // 从 history list 收集 undo records
    → trx_purge_trx_id_threshold()    // 计算可清理的 low water mark
    → trx_undo_purge_clr_update()      // 清理 clustered index 上的 roll_ptr
    → trx_purge_free_purge_segs()      // 释放 undo pages

关键阈值:purge视图 的 low_limit = min(min_view_trx_id, max_view_trx_id)。所有 trx_id < low_limit 的 undo records 可以安全清理。如果活跃事务中包含一个 trx_id = 100 的 read view,则即使 99% 的事务都已经提交完毕,purge 也无法推进到 100 以下。这就是长事务导致 Undo 暴涨的根因。

8.2 PostgreSQL VACUUM 多阶段流程

PostgreSQL 的 vacuum() 函数执行流程:

vacuum()
  → vac_rel()  // 对每张表
    → heap_vacuum_rel()
      → vacuum_set_xid_limits()            // 计算 oldest xmin
      → lazy_scan_heap()
        → lazy_scan_prune_page()           // 清理 Dead Tuples (HOT chain defrag)
        → PageRepairFragmentation()        // Page 级 FSM 更新
      → lazy_vacuum_heap()                 // FSM 记录
      → IndexVacuumInfo()                  // 索引 cleanup
    → vacuum_update_datfrozenxid()         // 更新 pg_database

VACUUM 的"懒惰性"体现在——它不会清理不在可见性映射表(VM)中标记为"有死元组"的 block,因此减少了 IO 放大。但 VM 只有 1 bit/block,意味着 block 中哪怕只有一行死元组,该 block 就会被扫描。在高更新(如计数器 incr)场景下,一个 block 中多行数据被频繁更新,Vacuum 的效率会很高;在低更新率但数据量极大的场景下(如日志表),VM 过滤效果有限,每次 Vacuum 都要扫大量 block。

九、总结

MVCC 不是银弹,每一个具体实现都有其工程代价:

  • InnoDB:undo log 共享表空间、长事务 Purge 阻塞、回滚段大小直接影响可回溯性
  • PostgreSQL:VACUUS 压力、ID Wraparound、HOT Chain 查询放大、表膨胀
  • 分布式数据库:时间戳分配瓶颈、残留锁清理、GC 窗口与历史读的平衡

理解这些工程限制,才能在生产中做对选择:什么时候要换隔离级别,什么时候要拆分热点表,什么时候要调大 GC 窗口,什么时候必须上 Serializable。MVCC 不只是理论问题,它是一个需要在具体硬件拓扑和业务模式下反复调优的工程命题。

工程的核心观点是:

"MVCC 免费读了旧版本的数据,但代价是写和后台清理的终生负担。理解并及时测量这个负担,是 DBE(数据库工程师)的核心能力。"

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
0.389696s