土法炼钢兴趣小组的算法知识备份

MVCC 实现变体全解

目录

你在半夜三点被告警叫醒。数据库 CPU 100%,大量查询超时。DBA 抓了一把慢查询,发现是一个跑了 6 小时的分析任务。你以为 MVCC “读不阻塞写”就万事大吉了?那个长事务让 PostgreSQL 的 dead tuple 堆积到数百 GB,或者让 InnoDB 的 undo log 膨胀到撑爆磁盘。

MVCC(Multi-Version Concurrency Control,多版本并发控制)是现代关系型数据库的基石。但”MVCC”三个字母背后,PostgreSQL、InnoDB、TiDB 走的是三条截然不同的路。理解这些差异,不只是面试八股,而是在你需要排查线上 MVCC 相关故障时,能够直击问题本质的关键能力。

本文从隔离级别与可串行化理论出发,逐一拆解三大引擎的 MVCC 实现,讨论 Snapshot Isolation 的异常与 SSI 的修复,最后给出一个可运行的 mini MVCC 引擎和工程实战经验。

MVCC 版本链对比

一、并发控制问题:可串行化与隔离级别

为什么需要并发控制

数据库的核心价值之一是让多个事务”同时”操作共享数据,同时保证结果的正确性。这里的”正确性”有一个严格定义——可串行化(Serializability):并发执行的结果,必须等价于这些事务按某种串行顺序依次执行的结果。

但完全的可串行化代价极高。如果每个事务都必须排队串行执行,吞吐量将惨不忍睹。因此 SQL 标准定义了四个隔离级别,允许程序员在正确性与性能之间做取舍:

隔离级别 脏读 不可重复读 幻读 写偏斜
Read Uncommitted 可能 可能 可能 可能
Read Committed 不可能 可能 可能 可能
Repeatable Read 不可能 不可能 可能 可能
Serializable 不可能 不可能 不可能 不可能

两大并发控制流派

在 MVCC 出现之前,数据库主要依赖两种机制:

悲观锁(2PL,Two-Phase Locking):事务在读写数据前先加锁,分为扩张阶段(只加锁不放锁)和收缩阶段(只放锁不加锁)。2PL 能保证可串行化,但读写互相阻塞,高并发下锁争用严重。

乐观并发控制(OCC):事务在本地执行,提交时检查是否与其他事务冲突,冲突则回滚重试。OCC 在冲突少的场景表现出色,但冲突多时重试开销巨大。

-- 2PL 下的经典死锁场景
-- Transaction A                    -- Transaction B
BEGIN;                              BEGIN;
UPDATE accounts SET bal=bal-100     UPDATE accounts SET bal=bal+100
  WHERE id=1;  -- lock row 1         WHERE id=2;  -- lock row 2
UPDATE accounts SET bal=bal+100     UPDATE accounts SET bal=bal-100
  WHERE id=2;  -- wait for B          WHERE id=1;  -- wait for A
-- DEADLOCK!                        -- DEADLOCK!

MVCC 的出场正是为了打破这个僵局:通过保留数据的多个版本,让读操作去读”历史快照”而不是当前值,从而实现读不阻塞写、写不阻塞读。

二、MVCC 核心思想:版本链与快照读

基本原理

MVCC 的核心非常直观:

  1. 每次写操作不覆盖旧数据,而是创建一个新版本。
  2. 每个版本都标记了创建它的事务 ID(或时间戳)。
  3. 读操作根据自己的”快照”来决定看到哪个版本。

这样一来,一个正在读取旧版本的查询不需要对数据加锁,另一个事务可以同时写入新版本。两者互不干扰。

版本链的两种组织方式

所有 MVCC 实现都需要维护一条版本链(version chain),但链的组织方式有两种主要思路:

新到旧(Newest-to-Oldest,N2O):索引指向最新版本,沿着链可以找到更早的版本。InnoDB 采用此方式——聚簇索引直接存最新行,旧版本在 undo log 里通过 roll_ptr 串起来。优点是 OLTP 场景大多数读取都访问最新版本,一次就命中;缺点是读历史版本需要回溯整条链。

旧到新(Oldest-to-Newest,O2N):索引指向最旧的版本,新版本追加在链尾。PostgreSQL 的 HOT(Heap-Only Tuple)链就是这种方式。优点是追加写入开销低;缺点是读最新版本需要遍历整条链(虽然 PostgreSQL 通过索引直接指向最新 tuple 来优化这一点)。

快照的定义

什么是”快照”?不同系统的定义略有差异,但本质相同:

三、PostgreSQL:元组级版本控制

存储模型

PostgreSQL 的 MVCC 实现有一个鲜明的特征:新旧版本都存在堆表(heap)中。每一行的每一个版本都是一个独立的 tuple,占用实际的磁盘空间。

每个 tuple 头部包含关键的元数据:

// PostgreSQL tuple header 核心字段(简化)
typedef struct HeapTupleHeaderData {
    TransactionId t_xmin;    // 创建此 tuple 的事务 ID
    TransactionId t_xmax;    // 删除/更新此 tuple 的事务 ID(0 表示仍存活)
    CommandId     t_cid;     // 同一事务内的命令序号
    ItemPointerData t_ctid;  // 指向同一行的下一个版本(chain link)
} HeapTupleHeaderData;

UPDATE 操作流程

当 PostgreSQL 执行 UPDATE 时:

  1. 在旧 tuple 的 t_xmax 字段写入当前事务 ID,标记旧版本”被删除”。
  2. 在堆表中插入一条全新的 tuple,t_xmin 设为当前事务 ID,t_xmax 设为 0。
  3. 旧 tuple 的 t_ctid 指向新 tuple 的位置。
  4. 更新所有指向该行的索引条目(除非满足 HOT 优化条件)。
-- 假设当前事务 ID 为 500
UPDATE accounts SET balance = 500 WHERE id = 1;
-- 旧 tuple: xmin=300, xmax=500 (标记为被事务500删除)
-- 新 tuple: xmin=500, xmax=0, balance=500
-- 旧.t_ctid -> 新 tuple 的位置

可见性判断

PostgreSQL 的可见性规则是一组精心设计的条件判断。核心逻辑可以简化为:

// 简化的可见性判断伪代码
bool tuple_is_visible(HeapTuple tuple, Snapshot snapshot) {
    // 1. 创建事务必须已提交且对当前快照可见
    if (!xmin_committed(tuple->t_xmin))
        return false;
    if (xid_in_snapshot(tuple->t_xmin, snapshot))
        return false;  // 创建事务在快照时还活跃

    // 2. 如果没有被删除(xmax=0),则可见
    if (tuple->t_xmax == InvalidTransactionId)
        return true;

    // 3. 如果删除事务还未提交,则此版本仍可见
    if (!xmax_committed(tuple->t_xmax))
        return true;

    // 4. 如果删除事务在快照时还活跃,则此版本仍可见
    if (xid_in_snapshot(tuple->t_xmax, snapshot))
        return true;

    // 5. 否则,此版本已被一个已提交的事务删除,不可见
    return false;
}

Visibility Map 与 Index-Only Scan

每次判断可见性都要检查事务状态,开销不小。PostgreSQL 引入了 Visibility Map(VM)来优化:VM 为每个 heap page 维护一个 bit,标记该 page 上的所有 tuple 是否对所有当前和未来事务都可见(all-visible)。

当一个 page 被标记为 all-visible 时: - Index-Only Scan 可以直接返回索引中的数据,无需回表。 - VACUUM 可以跳过这些 page,减少 I/O。

-- 查看 visibility map 状态
SELECT * FROM pg_visibility('accounts');
-- blkno | all_visible | all_frozen | pd_all_visible
-- ------+-------------+------------+----------------
--     0 | t           | t          | t
--     1 | f           | f          | f

CLOG:事务状态的仲裁者

PostgreSQL 用 CLOG(Commit Log,现在也叫 pg_xact)来记录每个事务的状态:in-progress、committed、aborted、sub-committed。可见性判断最终要查询 CLOG 才能确定一个事务是否已提交。

为了避免每次都查 CLOG,PostgreSQL 在 tuple 头部维护了 hint bits(t_infomask),作为事务状态的缓存。第一次查询 CLOG 后,结果会回写到 tuple header 中,后续读取直接看 hint bits 即可。

四、InnoDB:Undo Log 版本链

存储模型

InnoDB 的 MVCC 走了一条与 PostgreSQL 完全不同的路:聚簇索引(B+tree 叶子节点)中只保存最新版本的行数据,旧版本存放在 undo log 中。

每一行有两个隐藏列:

// InnoDB 行记录的隐藏字段(概念模型)
struct innodb_row {
    uint64_t DB_TRX_ID;    // 最后修改此行的事务 ID
    uint64_t DB_ROLL_PTR;  // 指向 undo log 中上一个版本的指针
    uint64_t DB_ROW_ID;    // 如果没有主键,InnoDB 自动生成的行 ID
    // ... user columns ...
};

Undo Log 版本链的构建

当 InnoDB 执行 UPDATE 时:

  1. 将旧行数据的被修改列写入 undo log,形成一条 undo record。
  2. 在聚簇索引中原地更新行数据为新值。
  3. 将行的 DB_TRX_ID 更新为当前事务 ID。
  4. 将行的 DB_ROLL_PTR 指向刚才写入的 undo record。
  5. Undo record 自身也有一个指针指向更早的 undo record,形成链。
聚簇索引叶子节点:
  row: {trx_id=300, roll_ptr -> undo_log_A, balance=500}

undo_log_A:
  {trx_id=200, roll_ptr -> undo_log_B, balance=800}

undo_log_B:
  {trx_id=100, roll_ptr -> NULL, balance=1000}

ReadView 机制

InnoDB 的快照叫做 ReadView,核心数据结构包含:

// InnoDB ReadView 核心结构(简化)
struct ReadView {
    trx_id_t m_low_limit_id;   // 创建 ReadView 时尚未分配的最小事务 ID
                                // 大于等于此值的事务一定不可见
    trx_id_t m_up_limit_id;    // 创建 ReadView 时活跃事务列表中最小的事务 ID
                                // 小于此值的事务一定可见
    trx_id_t m_creator_trx_id; // 创建此 ReadView 的事务 ID(自己的修改可见)
    ids_t    m_ids;             // 创建 ReadView 时所有活跃事务 ID 的有序列表
};

可见性判断逻辑:

bool changes_visible(trx_id_t id, ReadView *view) {
    // 1. 是自己的事务
    if (id == view->m_creator_trx_id)
        return true;

    // 2. 事务 ID 小于所有活跃事务,一定已提交
    if (id < view->m_up_limit_id)
        return true;

    // 3. 事务 ID 大于等于尚未分配的 ID,一定不可见
    if (id >= view->m_low_limit_id)
        return false;

    // 4. 在 [m_up_limit_id, m_low_limit_id) 范围内,查活跃列表
    //    如果在列表中,说明当时还没提交,不可见
    //    如果不在列表中,说明已提交,可见
    return !binary_search(view->m_ids, id);
}

RC 与 RR 的区别

InnoDB 用 ReadView 的创建时机来区分两种隔离级别:

-- RC 模式下
BEGIN;
SELECT balance FROM accounts WHERE id=1;  -- ReadView_1, 看到 balance=1000
-- 此时另一个事务提交了 balance=800
SELECT balance FROM accounts WHERE id=1;  -- ReadView_2, 看到 balance=800
COMMIT;

-- RR 模式下
BEGIN;
SELECT balance FROM accounts WHERE id=1;  -- ReadView_1, 看到 balance=1000
-- 此时另一个事务提交了 balance=800
SELECT balance FROM accounts WHERE id=1;  -- 复用 ReadView_1, 仍看到 balance=1000
COMMIT;

二级索引与 MVCC

InnoDB 的二级索引不包含事务信息(不存 trx_id)。那二级索引怎么判断可见性?

  1. 二级索引页面头部有一个 max_trx_id 字段,记录修改过该页面的最大事务 ID。
  2. 如果 max_trx_id < ReadView.m_up_limit_id,说明该页上所有记录都可见,无需进一步检查。
  3. 否则,需要回表(通过主键去聚簇索引查找),在聚簇索引的行上做可见性判断。

这就是为什么 InnoDB 在 RR 级别下,覆盖索引扫描有时候不得不回表的原因。

五、TiDB/Percolator:分布式 MVCC

架构概览

TiDB 的 MVCC 实现继承自 Google 的 Percolator 论文,针对分布式场景做了关键适配。核心组件:

三个 Column Family

TiKV 使用 RocksDB 的三个 Column Family 来实现 MVCC:

CF_DEFAULT:  存储实际数据
  key = {row_key}{start_ts}
  value = row_data

CF_LOCK:     存储锁信息
  key = {row_key}
  value = {lock_type, primary_key, start_ts, ttl}

CF_WRITE:    存储提交记录
  key = {row_key}{commit_ts}
  value = {write_type, start_ts}

这种编码方式巧妙利用了 RocksDB 的有序特性:同一个 row_key 的所有版本在物理上相邻存放,且按时间戳降序排列,读取最新版本只需一次 Seek。

两阶段提交(Percolator 协议)

TiDB 的事务提交采用两阶段提交:

Prewrite 阶段

对事务涉及的每一个 key:
  1. 检查 CF_WRITE 中是否有 commit_ts > start_ts 的记录(写写冲突检测)
  2. 检查 CF_LOCK 中是否已有锁(锁冲突检测)
  3. 在 CF_DEFAULT 中写入数据: {key, start_ts} -> value
  4. 在 CF_LOCK 中写入锁: key -> {primary, start_ts, ...}

Commit 阶段

1. 向 TSO 申请 commit_ts
2. 提交 primary key:
   a. 在 CF_WRITE 写入: {primary_key, commit_ts} -> {PUT, start_ts}
   b. 删除 CF_LOCK 中 primary_key 的锁
3. 异步提交 secondary keys(同样写 CF_WRITE、删 CF_LOCK)

快照读

TiDB 的快照读非常简洁:

读取 key 在 snapshot_ts 时的值:
  1. 在 CF_WRITE 中 Seek {key, snapshot_ts},找到最大的 commit_ts <= snapshot_ts 的记录
  2. 从该记录中取出 start_ts
  3. 用 {key, start_ts} 去 CF_DEFAULT 中读取实际数据
  4. 如果遇到 CF_LOCK 中有锁,需要等待或检查锁是否过期

TSO 的瓶颈与优化

TSO 是全局单点,如何避免成为瓶颈?

  1. 批量分配:TiDB 的每个节点不是每次需要时间戳都去请求一次 TSO,而是批量预取一段时间戳区间。
  2. Pipeline:TSO 请求支持 pipeline,一个 RTT 内可以处理多个请求。
  3. 本地缓存:对于只读事务,可以使用 Stale Read 来跳过 TSO 访问。
// TSO 批量分配的简化逻辑
func (tso *TimestampOracle) GetTimestamp(ctx context.Context, count uint32) ([]Timestamp, error) {
    // 通过 gRPC 向 PD leader 批量申请 count 个时间戳
    resp, err := tso.pdClient.Tso(ctx, &pdpb.TsoRequest{Count: count})
    if err != nil {
        return nil, err
    }
    // 将物理时间戳 + 逻辑计数器组合为全局唯一时间戳
    physical := resp.GetTimestamp().GetPhysical()
    logical  := resp.GetTimestamp().GetLogical()
    // 返回 [physical<<18 | (logical-count+1), ..., physical<<18 | logical]
    return expandTimestamps(physical, logical, count), nil
}

六、Snapshot Isolation 与写偏斜异常

Snapshot Isolation 不是 Serializable

很多开发者有一个误解:“MVCC 的 Repeatable Read 就等于 Serializable”。这是错的。大多数 MVCC 数据库(包括 PostgreSQL 9.0 之前和 InnoDB 的 RR 级别)提供的其实是 Snapshot Isolation(SI),它严格弱于 Serializable。

SI 能防止脏读、不可重复读和幻读(在 MVCC 的意义上),但它允许一种叫做写偏斜(Write Skew)的异常。

写偏斜的经典案例

-- 约束:至少一名医生必须在岗
-- 初始状态:Alice 在岗,Bob 在岗

-- Transaction T1 (Alice)              -- Transaction T2 (Bob)
BEGIN;                                  BEGIN;
SELECT count(*) FROM doctors            SELECT count(*) FROM doctors
  WHERE on_call = true;                   WHERE on_call = true;
-- 结果: 2                              -- 结果: 2
-- "还有两个人,我可以下班"             -- "还有两个人,我可以下班"
UPDATE doctors SET on_call = false      UPDATE doctors SET on_call = false
  WHERE name = 'Alice';                   WHERE name = 'Bob';
COMMIT;                                 COMMIT;
-- 最终结果:没人在岗!违反约束!

在 SI 下,T1 和 T2 各自读到的快照都是一致的,写入的是不同的行,所以没有写写冲突。但联合效果违反了业务约束。

为什么 SI 漏掉了写偏斜

写偏斜的本质是:两个事务分别读了一组数据(read set),基于读到的结果做出决策,然后修改了不同的行(write set),但它们的 write set 和对方的 read set 有交集。

用图论的语言描述:在并发事务的依赖图中,如果存在一个环,且环中有两条连续的 rw-dependency(read-write 反依赖)边,这就是一个 SI 无法检测的异常。

T1 --[rw]--> T2    T1 读了 Bob 的数据,T2 后来修改了 Bob
T2 --[rw]--> T1    T2 读了 Alice 的数据,T1 后来修改了 Alice
形成 rw-rw 环,写偏斜发生

实际影响

写偏斜不是理论怪谈。以下场景都可能触发:

  1. 双重预订:两个用户同时查询剩余座位数,各自认为还有余量,同时预订。
  2. 余额转账:两个账户各自检查对方余额后同时转账。
  3. 唯一性约束:两个事务同时检查用户名是否存在,都发现不存在,都插入了同样的用户名。

七、SSI:可串行化快照隔离

PostgreSQL 9.1+ 的突破

2008 年,Cahill、Rohm 和 Fekete 发表了一篇里程碑式的论文《Serializable Isolation for Snapshot Databases》,提出了 SSI(Serializable Snapshot Isolation)算法。PostgreSQL 9.1 率先实现了这个算法,使得 SERIALIZABLE 隔离级别真正名副其实。

SSI 的核心思想:在 SI 的基础上,追踪事务之间的 rw-dependency(读写反依赖),如果检测到”危险结构”(两条连续的 rw-dependency 形成的 pivot 结构),就中止其中一个事务。

rw-dependency 追踪

SSI 维护两种关键数据结构:

// SSI 追踪的核心信息(概念模型)
struct SIReadLock {
    TransactionId reader;    // 读取数据的事务
    Relation      relation;  // 读取的表
    TupleId       tuple;     // 读取的具体 tuple(或谓词锁范围)
};

struct RWConflict {
    TransactionId reader;    // 先读的事务
    TransactionId writer;    // 后写的事务(且写了 reader 读过的数据)
};

当一个事务 T2 修改了 T1 之前读过的数据时,系统记录一条 rw-dependency: T1 -> T2(T1 rw-depends on T2,意思是 T1 读的数据被 T2 修改了)。

危险结构检测

SSI 要检测的危险结构是:

T1 --[rw]--> T2 --[rw]--> T3

其中 T1 的快照早于 T2 提交,T2 的快照早于 T3 提交

如果 T1 就是 T3(即形成长度为 2 的环),这就是写偏斜。如果 T1 != T3,也可能构成不可串行化调度。

当系统检测到这种结构时,它会选择中止 T2(pivot 事务),因为 T2 既是 T1 的 writer 又是 T3 的 reader。

-- SSI 在 PostgreSQL 中的实际行为
SET default_transaction_isolation = 'serializable';

-- Transaction T1                      -- Transaction T2
BEGIN;                                  BEGIN;
SELECT * FROM doctors                   SELECT * FROM doctors
  WHERE on_call = true;                   WHERE on_call = true;
UPDATE doctors SET on_call = false      UPDATE doctors SET on_call = false
  WHERE name = 'Alice';                   WHERE name = 'Bob';
COMMIT;  -- 成功                        COMMIT;
-- ERROR: could not serialize access due to read/write dependencies
-- among transactions

谓词锁(SIREAD Lock)

SSI 中的读锁不是普通的行锁,而是谓词锁(SIREAD lock)。当一个事务用 WHERE on_call = true 查询时,PostgreSQL 不只锁定返回的行,还记录这个谓词条件。这样即使后续有新的 tuple 满足该条件被插入,也能被检测到。

谓词锁的粒度会自动升级以控制内存:

tuple lock -> page lock -> relation lock

SSI 的代价

SSI 不是免费的:

  1. 内存开销:需要维护所有活跃事务的 SIREAD 锁和 rw-conflict 列表。
  2. 假阳性:SSI 是保守的,它可能中止一些实际上不会导致异常的事务。
  3. 重试负担:被中止的事务需要应用层重试。

但与传统的 2PL Serializable 相比,SSI 的优势是压倒性的:读仍然不阻塞写,吞吐量远高于 2PL。

八、垃圾回收:VACUUM、Purge 与长事务之殇

PostgreSQL 的 VACUUM

PostgreSQL 的旧版本 tuple 就地存放在堆表中。如果不清理,堆表会无限膨胀。VACUUM 是 PostgreSQL 的垃圾回收器,负责回收死 tuple 占用的空间。

VACUUM 有两种模式:

普通 VACUUM:扫描堆表,将死 tuple 标记为可重用空间(free space),但不归还给操作系统。表的物理大小不会缩小。

VACUUM FULL:重写整张表,移除所有死 tuple,表物理缩小。但需要 ACCESS EXCLUSIVE 锁,期间表完全不可用。

-- 查看表的死 tuple 统计
SELECT relname, n_live_tup, n_dead_tup,
       round(n_dead_tup::numeric / greatest(n_live_tup, 1) * 100, 2) as dead_pct
FROM pg_stat_user_tables
WHERE schemaname = 'public'
ORDER BY n_dead_tup DESC;

-- relname  | n_live_tup | n_dead_tup | dead_pct
-- ---------+------------+------------+----------
-- orders   |    5000000 |    3200000 |    64.00
-- accounts |     100000 |       1500 |     1.50

Autovacuum 的配置

PostgreSQL 的 autovacuum 守护进程自动在后台执行 VACUUM。关键配置参数:

-- 核心参数
autovacuum_vacuum_threshold = 50       -- 最少变化行数
autovacuum_vacuum_scale_factor = 0.2   -- 变化比例阈值
-- 触发条件: dead_tuples > threshold + scale_factor * n_live_tup

autovacuum_vacuum_cost_limit = 200     -- I/O 成本限制(节流)
autovacuum_vacuum_cost_delay = 2ms     -- 达到成本限制后的休眠时间

-- 对于大表,可以单独设置更激进的参数
ALTER TABLE orders SET (
    autovacuum_vacuum_scale_factor = 0.01,
    autovacuum_vacuum_cost_limit = 1000
);

InnoDB 的 Purge

InnoDB 的旧版本在 undo log 中,由专门的 purge 线程负责清理:

  1. Purge 线程维护一个全局的”最老活跃 ReadView”。
  2. 所有 trx_id 小于最老活跃 ReadView 的 m_up_limit_id 的 undo record,都可以安全清理。
  3. Purge 线程按事务提交顺序遍历 undo log,逐条清理过期的 undo record。
-- 查看 InnoDB purge 状态
SHOW ENGINE INNODB STATUS\G
-- ...
-- TRANSACTIONS
-- Trx id counter 12345678
-- Purge done for trx's n:o < 12345670  -- purge 已处理到此事务
-- History list length 1523              -- 待清理的 undo log 页数
-- ...

-- 查看 undo tablespace 大小
SELECT TABLESPACE_NAME, FILE_NAME, TOTAL_EXTENTS, EXTENT_SIZE
FROM information_schema.FILES
WHERE FILE_TYPE = 'UNDO LOG';

长事务:MVCC 的致命杀手

长事务是所有 MVCC 系统的公敌。无论是 PostgreSQL 还是 InnoDB,只要有一个长事务存在,其快照时间点之后产生的所有旧版本都无法被回收。

PostgreSQL 的症状: - dead tuple 堆积,表膨胀(table bloat)。 - Index 膨胀,扫描性能劣化。 - 极端情况下 Transaction ID Wraparound,导致数据库强制停机做 VACUUM。

InnoDB 的症状: - History list length 持续增长。 - Undo tablespace 膨胀。 - 读操作需要回溯更长的版本链,查询变慢。

-- PostgreSQL:找出长事务
SELECT pid, now() - xact_start AS duration, state, query
FROM pg_stat_activity
WHERE state != 'idle'
  AND xact_start < now() - interval '5 minutes'
ORDER BY duration DESC;

-- MySQL/InnoDB:找出长事务
SELECT trx_id, trx_started, now() - trx_started AS duration,
       trx_rows_locked, trx_rows_modified
FROM information_schema.innodb_trx
ORDER BY trx_started ASC;

TiDB 的 GC

TiDB 的垃圾回收由 GC Worker 执行,清理 safe_point 之前的旧版本:

-- 查看 GC safe point
SELECT VARIABLE_NAME, VARIABLE_VALUE
FROM mysql.tidb
WHERE VARIABLE_NAME IN ('tikv_gc_safe_point', 'tikv_gc_life_time');

-- tikv_gc_life_time 默认 10 分钟
-- 意味着 10 分钟前的旧版本可以被回收

九、Mini MVCC 引擎:C 语言实现

下面是一个完整的 mini MVCC 引擎实现,支持快照读、版本链和垃圾回收。代码可以直接编译运行。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <assert.h>

/* ========== 常量与类型 ========== */
#define MAX_TRANSACTIONS 64
#define MAX_ROWS         256
#define MAX_VERSIONS     1024
#define INVALID_TXN_ID   0

typedef uint64_t txn_id_t;

typedef enum {
    TXN_ACTIVE,
    TXN_COMMITTED,
    TXN_ABORTED
} txn_status_t;

/* 数据版本 */
typedef struct Version {
    txn_id_t       created_by;   /* 创建此版本的事务 ID */
    txn_id_t       deleted_by;   /* 删除此版本的事务 ID,0 表示未删除 */
    int            value;        /* 实际数据 */
    struct Version *next;        /* 指向同一行的下一个(更旧的)版本 */
} Version;

/* 行记录:指向版本链的头部(最新版本) */
typedef struct {
    int      key;
    Version *head;               /* 版本链头,newest first */
} Row;

/* 快照 */
typedef struct {
    txn_id_t active_txns[MAX_TRANSACTIONS];
    int      active_count;
    txn_id_t max_committed;      /* 快照时已提交的最大事务 ID */
} Snapshot;

/* 事务 */
typedef struct {
    txn_id_t     id;
    txn_status_t status;
    Snapshot     snapshot;
} Transaction;

/* MVCC 引擎 */
typedef struct {
    Row          rows[MAX_ROWS];
    int          row_count;
    Transaction  txns[MAX_TRANSACTIONS];
    int          txn_count;
    txn_id_t     next_txn_id;
    Version      version_pool[MAX_VERSIONS];
    int          version_used;
} MVCCEngine;

/* ========== 引擎初始化 ========== */
void engine_init(MVCCEngine *e) {
    memset(e, 0, sizeof(MVCCEngine));
    e->next_txn_id = 1;
}

/* ========== 版本分配 ========== */
static Version *alloc_version(MVCCEngine *e) {
    assert(e->version_used < MAX_VERSIONS);
    Version *v = &e->version_pool[e->version_used++];
    memset(v, 0, sizeof(Version));
    return v;
}

/* ========== 事务管理 ========== */
static Transaction *find_txn(MVCCEngine *e, txn_id_t id) {
    for (int i = 0; i < e->txn_count; i++) {
        if (e->txns[i].id == id) return &e->txns[i];
    }
    return NULL;
}

txn_id_t txn_begin(MVCCEngine *e) {
    assert(e->txn_count < MAX_TRANSACTIONS);
    Transaction *txn = &e->txns[e->txn_count++];
    txn->id = e->next_txn_id++;
    txn->status = TXN_ACTIVE;

    /* 构建快照:记录当前所有活跃事务 */
    txn->snapshot.active_count = 0;
    txn->snapshot.max_committed = 0;
    for (int i = 0; i < e->txn_count - 1; i++) {
        if (e->txns[i].status == TXN_ACTIVE) {
            txn->snapshot.active_txns[txn->snapshot.active_count++] = e->txns[i].id;
        } else if (e->txns[i].status == TXN_COMMITTED) {
            if (e->txns[i].id > txn->snapshot.max_committed)
                txn->snapshot.max_committed = e->txns[i].id;
        }
    }
    return txn->id;
}

bool txn_commit(MVCCEngine *e, txn_id_t id) {
    Transaction *txn = find_txn(e, id);
    if (!txn || txn->status != TXN_ACTIVE) return false;
    txn->status = TXN_COMMITTED;
    return true;
}

bool txn_abort(MVCCEngine *e, txn_id_t id) {
    Transaction *txn = find_txn(e, id);
    if (!txn || txn->status != TXN_ACTIVE) return false;
    txn->status = TXN_ABORTED;

    /* 清理该事务创建的版本 */
    for (int r = 0; r < e->row_count; r++) {
        Version *v = e->rows[r].head;
        Version *prev = NULL;
        while (v) {
            if (v->created_by == id) {
                /* 从链中移除 */
                if (prev) prev->next = v->next;
                else e->rows[r].head = v->next;
                Version *to_free = v;
                v = v->next;
                to_free->created_by = INVALID_TXN_ID;
                continue;
            }
            if (v->deleted_by == id) {
                v->deleted_by = INVALID_TXN_ID;
            }
            prev = v;
            v = v->next;
        }
    }
    return true;
}

/* ========== 可见性判断 ========== */
static bool is_txn_committed(MVCCEngine *e, txn_id_t id) {
    Transaction *txn = find_txn(e, id);
    return txn && txn->status == TXN_COMMITTED;
}

static bool is_in_snapshot(Snapshot *snap, txn_id_t id) {
    for (int i = 0; i < snap->active_count; i++) {
        if (snap->active_txns[i] == id) return true;
    }
    return false;
}

static bool version_visible(MVCCEngine *e, Version *v, Transaction *viewer) {
    /* 自己创建的版本,且没被自己删除 */
    if (v->created_by == viewer->id)
        return (v->deleted_by != viewer->id);

    /* 创建者必须已提交,且不在快照的活跃列表中 */
    if (!is_txn_committed(e, v->created_by))
        return false;
    if (v->created_by > viewer->snapshot.max_committed &&
        v->created_by >= viewer->id)
        return false;
    if (is_in_snapshot(&viewer->snapshot, v->created_by))
        return false;

    /* 如果版本已被删除 */
    if (v->deleted_by != INVALID_TXN_ID) {
        if (v->deleted_by == viewer->id)
            return false;
        if (is_txn_committed(e, v->deleted_by) &&
            !is_in_snapshot(&viewer->snapshot, v->deleted_by) &&
            v->deleted_by <= viewer->snapshot.max_committed)
            return false;
        /* 删除者未提交或在快照活跃列表中,此版本仍可见 */
    }
    return true;
}

/* ========== 数据操作 ========== */
static Row *find_row(MVCCEngine *e, int key) {
    for (int i = 0; i < e->row_count; i++) {
        if (e->rows[i].key == key) return &e->rows[i];
    }
    return NULL;
}

/* 快照读:返回 key 对该事务可见的值,found 表示是否找到 */
bool mvcc_read(MVCCEngine *e, txn_id_t txn_id, int key, int *value) {
    Transaction *txn = find_txn(e, txn_id);
    if (!txn) return false;

    Row *row = find_row(e, key);
    if (!row) return false;

    /* 沿版本链查找第一个可见的版本 */
    for (Version *v = row->head; v; v = v->next) {
        if (version_visible(e, v, txn)) {
            *value = v->value;
            return true;
        }
    }
    return false;
}

/* 写入:创建新版本 */
bool mvcc_write(MVCCEngine *e, txn_id_t txn_id, int key, int value) {
    Transaction *txn = find_txn(e, txn_id);
    if (!txn || txn->status != TXN_ACTIVE) return false;

    Row *row = find_row(e, key);
    if (!row) {
        assert(e->row_count < MAX_ROWS);
        row = &e->rows[e->row_count++];
        row->key = key;
        row->head = NULL;
    }

    /* 标记旧的可见版本为已删除 */
    for (Version *v = row->head; v; v = v->next) {
        if (version_visible(e, v, txn)) {
            if (v->deleted_by != INVALID_TXN_ID && v->deleted_by != txn_id) {
                return false;  /* 写写冲突 */
            }
            v->deleted_by = txn_id;
            break;
        }
    }

    /* 创建新版本,插入链头 */
    Version *new_v = alloc_version(e);
    new_v->created_by = txn_id;
    new_v->deleted_by = INVALID_TXN_ID;
    new_v->value = value;
    new_v->next = row->head;
    row->head = new_v;
    return true;
}

/* 删除 */
bool mvcc_delete(MVCCEngine *e, txn_id_t txn_id, int key) {
    Transaction *txn = find_txn(e, txn_id);
    if (!txn || txn->status != TXN_ACTIVE) return false;

    Row *row = find_row(e, key);
    if (!row) return false;

    for (Version *v = row->head; v; v = v->next) {
        if (version_visible(e, v, txn)) {
            if (v->deleted_by != INVALID_TXN_ID && v->deleted_by != txn_id)
                return false;
            v->deleted_by = txn_id;
            return true;
        }
    }
    return false;
}

/* ========== 垃圾回收 ========== */
void mvcc_gc(MVCCEngine *e) {
    /* 找出最小的活跃事务 ID */
    txn_id_t min_active = e->next_txn_id;
    for (int i = 0; i < e->txn_count; i++) {
        if (e->txns[i].status == TXN_ACTIVE && e->txns[i].id < min_active)
            min_active = e->txns[i].id;
    }

    for (int r = 0; r < e->row_count; r++) {
        Version *v = e->rows[r].head;
        Version *prev = NULL;
        bool found_visible = false;

        while (v) {
            bool can_gc = false;

            /* 被已提交事务删除,且删除事务 ID < min_active */
            if (v->deleted_by != INVALID_TXN_ID &&
                is_txn_committed(e, v->deleted_by) &&
                v->deleted_by < min_active) {
                can_gc = true;
            }
            /* 被中止事务创建的版本 */
            if (v->created_by != INVALID_TXN_ID) {
                Transaction *creator = find_txn(e, v->created_by);
                if (creator && creator->status == TXN_ABORTED)
                    can_gc = true;
            }

            if (can_gc && found_visible) {
                if (prev) prev->next = v->next;
                else e->rows[r].head = v->next;
                v->created_by = INVALID_TXN_ID;
                v = (prev ? prev->next : e->rows[r].head);
                continue;
            }

            if (v->created_by != INVALID_TXN_ID &&
                is_txn_committed(e, v->created_by))
                found_visible = true;

            prev = v;
            v = v->next;
        }
    }
}

/* ========== 测试用例 ========== */
static void test_snapshot_isolation(void) {
    printf("=== Test: Snapshot Isolation ===\n");
    MVCCEngine engine;
    engine_init(&engine);

    /* T1 写入初始数据 */
    txn_id_t t1 = txn_begin(&engine);
    mvcc_write(&engine, t1, 1, 1000);
    mvcc_write(&engine, t1, 2, 2000);
    txn_commit(&engine, t1);

    /* T2 开始,拍快照 */
    txn_id_t t2 = txn_begin(&engine);

    /* T3 修改 key=1 并提交 */
    txn_id_t t3 = txn_begin(&engine);
    mvcc_write(&engine, t3, 1, 1500);
    txn_commit(&engine, t3);

    /* T2 应该看到旧值(快照隔离) */
    int val;
    bool found = mvcc_read(&engine, t2, 1, &val);
    assert(found && val == 1000);
    printf("  T2 reads key=1: %d (expected 1000, snapshot isolation)\n", val);

    /* T2 也应该看到 key=2 的原始值 */
    found = mvcc_read(&engine, t2, 2, &val);
    assert(found && val == 2000);
    printf("  T2 reads key=2: %d (expected 2000)\n", val);

    /* 新事务 T4 应该看到 T3 的修改 */
    txn_id_t t4 = txn_begin(&engine);
    found = mvcc_read(&engine, t4, 1, &val);
    assert(found && val == 1500);
    printf("  T4 reads key=1: %d (expected 1500, sees T3's commit)\n", val);

    txn_commit(&engine, t2);
    txn_commit(&engine, t4);
    printf("  PASSED\n\n");
}

static void test_write_conflict(void) {
    printf("=== Test: Write-Write Conflict Detection ===\n");
    MVCCEngine engine;
    engine_init(&engine);

    txn_id_t t1 = txn_begin(&engine);
    mvcc_write(&engine, t1, 1, 100);
    txn_commit(&engine, t1);

    txn_id_t t2 = txn_begin(&engine);
    txn_id_t t3 = txn_begin(&engine);

    /* T2 修改 key=1 */
    bool ok = mvcc_write(&engine, t2, 1, 200);
    assert(ok);
    printf("  T2 writes key=1=200: success\n");

    /* T3 也试图修改 key=1,应该检测到冲突 */
    ok = mvcc_write(&engine, t3, 1, 300);
    assert(!ok);
    printf("  T3 writes key=1=300: conflict detected (expected)\n");

    txn_commit(&engine, t2);
    txn_abort(&engine, t3);
    printf("  PASSED\n\n");
}

static void test_abort_cleanup(void) {
    printf("=== Test: Abort Cleanup ===\n");
    MVCCEngine engine;
    engine_init(&engine);

    txn_id_t t1 = txn_begin(&engine);
    mvcc_write(&engine, t1, 1, 100);
    txn_commit(&engine, t1);

    /* T2 修改后中止 */
    txn_id_t t2 = txn_begin(&engine);
    mvcc_write(&engine, t2, 1, 999);
    txn_abort(&engine, t2);

    /* T3 应该仍看到 T1 的值 */
    txn_id_t t3 = txn_begin(&engine);
    int val;
    bool found = mvcc_read(&engine, t3, 1, &val);
    assert(found && val == 100);
    printf("  T3 reads key=1: %d (expected 100, T2 was aborted)\n", val);
    txn_commit(&engine, t3);
    printf("  PASSED\n\n");
}

static void test_gc(void) {
    printf("=== Test: Garbage Collection ===\n");
    MVCCEngine engine;
    engine_init(&engine);

    txn_id_t t1 = txn_begin(&engine);
    mvcc_write(&engine, t1, 1, 100);
    txn_commit(&engine, t1);

    txn_id_t t2 = txn_begin(&engine);
    mvcc_write(&engine, t2, 1, 200);
    txn_commit(&engine, t2);

    txn_id_t t3 = txn_begin(&engine);
    mvcc_write(&engine, t3, 1, 300);
    txn_commit(&engine, t3);

    /* 所有事务都已提交,GC 应该清理旧版本 */
    int version_count = 0;
    Row *row = find_row(&engine, 1);
    for (Version *v = row->head; v; v = v->next) version_count++;
    printf("  Before GC: %d versions for key=1\n", version_count);

    mvcc_gc(&engine);

    version_count = 0;
    for (Version *v = row->head; v; v = v->next) {
        if (v->created_by != INVALID_TXN_ID) version_count++;
    }
    printf("  After GC:  %d versions for key=1\n", version_count);
    printf("  PASSED\n\n");
}

int main(void) {
    printf("Mini MVCC Engine Test Suite\n");
    printf("==========================\n\n");

    test_snapshot_isolation();
    test_write_conflict();
    test_abort_cleanup();
    test_gc();

    printf("All tests passed.\n");
    return 0;
}

编译与运行:

gcc -std=c11 -Wall -Wextra -o mini_mvcc mini_mvcc.c
./mini_mvcc

预期输出:

Mini MVCC Engine Test Suite
==========================

=== Test: Snapshot Isolation ===
  T2 reads key=1: 1000 (expected 1000, snapshot isolation)
  T2 reads key=2: 2000 (expected 2000)
  T4 reads key=1: 1500 (expected 1500, sees T3's commit)
  PASSED

=== Test: Write-Write Conflict Detection ===
  T2 writes key=1=200: success
  T3 writes key=1=300: conflict detected (expected)
  PASSED

=== Test: Abort Cleanup ===
  T3 reads key=1: 100 (expected 100, T2 was aborted)
  PASSED

=== Test: Garbage Collection ===
  Before GC: 3 versions for key=1
  After GC:  1 versions for key=1
  PASSED

All tests passed.

十、对比表:PostgreSQL vs InnoDB vs TiDB

架构对比

维度 PostgreSQL InnoDB TiDB (TiKV)
版本存储位置 堆表(heap)内 聚簇索引 + undo log RocksDB CF_DEFAULT
版本链方向 O2N(旧到新) N2O(新到旧) 按时间戳编码的 KV
最新版本位置 链尾(需遍历或索引直达) 聚簇索引叶节点 Seek 第一条记录
事务 ID 来源 本地递增 XID(32 bit) 本地递增 trx_id(48 bit) TSO 全局时间戳(64 bit)
快照表示 活跃事务 ID 列表 ReadView(活跃列表 + 边界) start_ts 单个时间戳
隔离级别默认值 Read Committed Repeatable Read Snapshot Isolation
Serializable 实现 SSI(9.1+) 间隙锁 + Next-Key Lock 悲观锁 / 乐观锁
GC 机制 VACUUM(autovacuum) Purge 线程 GC Worker
GC 触发 dead tuple 比例 最老活跃 ReadView safe_point 推进

读写性能特征

场景 PostgreSQL InnoDB TiDB
点查最新版本 可能需遍历 HOT chain 直接命中聚簇索引 一次 Seek
点查历史版本 可能直接命中(旧 tuple 仍在堆中) 需回溯 undo log Seek 特定时间戳
长版本链读取 性能劣化较小(tuple 在堆中连续) 性能劣化明显(undo log 随机 I/O) 劣化取决于 LSM 层数
UPDATE 开销 高(新 tuple + 可能更新索引) 低(原地更新 + undo record) 中(写三个 CF)
空间放大 高(dead tuple 占据堆空间) 中(undo log 单独空间) 中(旧版本在 SST)
Index-Only Scan 需 VM all-visible 原生支持(覆盖索引) 无需回表(KV 模型)

故障特征

故障模式 PostgreSQL InnoDB TiDB
长事务的影响 表膨胀、XID wraparound undo log 膨胀、读变慢 GC 受阻、空间膨胀
典型告警指标 n_dead_tup, age(relfrozenxid) history_list_length tikv_gc_safe_point 停滞
恢复手段 VACUUM FULL / pg_repack 等 purge 追上 / kill 长事务 手动推进 safe_point

十一、MVCC 开销实测:版本链长度对性能的影响

实验设计

版本链长度对读性能有直接影响。以下实验模拟不同版本链长度下的读取延迟。

-- PostgreSQL 实验:制造不同长度的版本链
-- 步骤1: 创建测试表
CREATE TABLE mvcc_bench (
    id   INT PRIMARY KEY,
    val  INT,
    pad  CHAR(100)
);
INSERT INTO mvcc_bench SELECT i, 0, repeat('x', 100) FROM generate_series(1, 10000) i;

-- 步骤2: 开启一个长事务(阻止 VACUUM)
BEGIN;
SELECT txid_current();  -- 占住快照

-- 步骤3: 在另一个会话中反复更新(制造版本链)
DO $$
BEGIN
    FOR i IN 1..100 LOOP
        UPDATE mvcc_bench SET val = i WHERE id BETWEEN 1 AND 1000;
    END LOOP;
END $$;

-- 步骤4: 测量读取延迟
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM mvcc_bench WHERE id = 1;

预期结果模型

版本链长度对读取延迟的影响大致呈线性关系:

版本链长度    PostgreSQL 读延迟    InnoDB 读延迟
    1             ~0.02ms             ~0.02ms
   10             ~0.03ms             ~0.05ms
   50             ~0.05ms             ~0.15ms
  100             ~0.08ms             ~0.30ms
  500             ~0.25ms             ~1.50ms
 1000             ~0.50ms             ~3.00ms

InnoDB 的劣化更明显,因为 undo log 的访问涉及随机 I/O(undo page 可能不在 buffer pool 中)。PostgreSQL 的旧版本在堆表中,与新版本在同一个或相邻的 page,局部性更好。

关键观察

  1. InnoDB N2O 的代价:读最新版本快,但回溯旧版本需要逐条遍历 undo record,每条可能引发一次 I/O。
  2. PostgreSQL HOT 优化:如果更新不涉及索引列,新旧 tuple 在同一个 page 内通过 HOT chain 连接,避免索引更新开销。
  3. TiDB 的优势:基于 LSM-tree 的存储,版本按时间戳有序排列,范围扫描可以高效利用 block cache。

监控建议

-- PostgreSQL: 监控 dead tuple 比例
SELECT relname,
       n_dead_tup,
       n_live_tup,
       last_autovacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC;

-- InnoDB: 监控 history list length
SELECT NAME, COUNT
FROM information_schema.INNODB_METRICS
WHERE NAME = 'trx_rseg_history_len';

-- TiDB: 监控 GC 进度
-- SHOW STATUS 或 Grafana 面板: tikv_gc_last_run_time

十二、工程陷阱与个人观点

工程陷阱速查表

陷阱 影响 预防措施
忘记关闭 idle in transaction 连接 PostgreSQL 表膨胀、XID wraparound 设置 idle_in_transaction_session_timeout
大批量 UPDATE 不分批 InnoDB undo log 暴涨、长事务 分批提交,每批 1000-5000 行
在 RR 级别下做”读后写”决策 写偏斜,业务逻辑错误 使用 SELECT … FOR UPDATE 或 SERIALIZABLE
VACUUM 参数使用默认值 大表 VACUUM 跟不上更新速度 按表设置 autovacuum_vacuum_scale_factor
不监控 history list length InnoDB undo tablespace 撑爆磁盘 告警阈值 1000000
TiDB GC lifetime 设太长 旧版本堆积、读性能下降 默认 10min 足够,按需调整
在 SERIALIZABLE 下不处理重试 事务频繁被中止,应用层报错 实现指数退避重试逻辑
对 MVCC 表做 COUNT(*) 估算 PostgreSQL 全表扫描(需检查可见性) 使用 pg_class.reltuples 近似估算
忽略二级索引回表开销 InnoDB 覆盖索引失效、性能骤降 理解 max_trx_id 机制
混淆 SI 和 Serializable 以为 RR 就是 Serializable 读论文,理解写偏斜

个人观点

关于 PostgreSQL 的 MVCC 设计:堆表内版本存储经常被批评为”表膨胀的根源”,但这个设计也有它的好处——旧版本的读取不需要额外的 I/O(不像 InnoDB 需要去翻 undo log)。对于分析型查询需要读历史快照的场景,PostgreSQL 的设计反而可能更快。问题不在于设计本身,而在于 VACUUM 的调优需要经验,且 autovacuum 的默认参数对大表来说太保守了。

关于 InnoDB 的”原地更新”:很多文章说 InnoDB 是”原地更新”,这容易让人以为它直接覆盖 page 上的数据。实际上对于变长字段的更新,InnoDB 也可能需要页分裂或 off-page 存储。“原地更新”更准确的说法是:聚簇索引始终指向最新版本,不需要像 PostgreSQL 那样追 ctid 链。

关于分布式 MVCC 的 TSO:TSO 是一个优雅的设计,但它引入了一个全局单点依赖。Spanner 用 TrueTime API 实现了去中心化的时间戳,但需要原子钟和 GPS 硬件支持。CockroachDB 用 HLC(Hybrid Logical Clock)来近似实现,但会引入不确定窗口期。没有银弹,每种方案都在做取舍。

关于 SSI 的普及:SSI 是一个理论上非常优雅的方案,但在工业界的采用率远不如预期。原因很简单:大部分应用在 RC 或 RR 下就能跑得很好,写偏斜问题可以用应用层的 SELECT … FOR UPDATE 来解决。SSI 的价值在于它让数据库层面提供了真正的 Serializable 保证,开发者不再需要逐个分析每个查询是否有写偏斜风险。如果你的应用有复杂的跨行约束,且你不想在应用层做锁管理,SSI 是正确的选择。

关于 MVCC 的未来

随着 NVM(Non-Volatile Memory)的普及,MVCC 的实现可能会发生根本性变化。传统 MVCC 的很多设计决策(例如 undo log 的存在、WAL 的必要性)都是基于”内存断电即失”的假设。在 NVM 上,version chain 可以直接持久化在内存中,不再需要 undo log 和 redo log 的二重奏。Peloton(CMU 的学术数据库)和 FOEDUS 已经在探索这个方向。

另一个趋势是确定性数据库(Deterministic Database),如 Calvin 和 BOHM。它们从根本上绕过了 MVCC 的复杂性:通过预先确定事务执行顺序,消除了并发控制的需求。这种方案在跨数据中心复制场景下有独特优势,但对交互式事务不太友好。

最终,没有哪种并发控制方案是”最好”的。MVCC 之所以成为事实标准,不是因为它理论上最优,而是因为它在”读不阻塞写”的工程约束下,提供了最好的实用性与正确性平衡。理解你的工作负载,比追求”最先进”的实现重要得多。


上一篇: LSM-tree Compaction 策略 下一篇: 学习索引

相关阅读: - WAL 与 ARIES 恢复算法 - 并发哈希表


By .