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 的起点:
- 旧版本数据存在哪里?
- 事务如何确定自己应该看到哪个版本?
- 旧版本什么时候被清理?
接下来我们逐一拆解。
二、InnoDB 的 MVCC 实现:undo log 驱动的隐式版本链
2.1 隐藏列与版本链
InnoDB 在每一行记录中维护三个隐藏列:
DB_TRX_ID(6字节):最后修改该记录的事务 IDDB_ROLL_PTR(7字节):指向 undo log 中旧版本记录的回滚指针DB_ROW_ID(6字节):若无主键,自动生成的行 ID
当你执行 UPDATE 操作时,InnoDB 并不原地修改数据。它做三件事:
- 将当前行的旧版本写入 undo log
- 在当前行上更新数据,并将
DB_TRX_ID设置为当前事务 ID - 通过
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 中的最小事务 IDmax_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)只做三件事:
- 冻结旧元组:将
xmin≤oldestXmin的元组标记为 Frozen - 回收空间(Page-level):将已删除的 Dead Tuple 占用的空间标记为 Free Space Map(FSM)可用
- 更新可见性映射(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),它有三个核心保证:
- 事务读取的是某个一致性快照(Snapshot Read)
- 只有无冲突的写入才能提交(First Committer Wins)
- 事务看到的数据在逻辑上像在单一时刻执行
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 的原理:
- 写操作时读取的元组会被标记为 rw-conflict-in
- 元组写入后会被标记为 rw-conflict-out
- 如果两个活跃事务的 rw-conflict-in/out 边形成环 → 触发 SIReadLock 检测
- 如果检测到危险结构("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 可见)。通过以下方式降低锁竞争:
- 业务上尽量让更新命中精确索引记录(减少 Gap Lock 范围)
- 降低隔离级别到 RC(如果业务逻辑能容忍不可重复读)
- 大事务拆分为小事务,减少锁持有时间
六、分布式 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(数据库工程师)的核心能力。"

发表评论 取消回复