多版本并发控制 (MVCC):从理论到工程实现
本文档系统性地介绍 MVCC(Multi-Version Concurrency Control)的设计哲学、核心接口抽象,以及 InnoDB、PostgreSQL、CockroachDB 等不同数据库引擎中的具体实现细节,涵盖 Undo Log、Heap Page、Sequence Number、全局时间戳等关键技术。
目录
- MVCC 定义与核心目标
- 抽象接口设计 (Interface Design)
- 实现方式一:基于 Undo Log (InnoDB 模式)
- 实现方式二:基于 Heap Tuple (PostgreSQL 模式)
- 实现方式三:基于时间戳排序 (NewSQL 模式)
- 实现方式四:追加式存储 (Append-Only)
- 逻辑时钟与分布式 MVCC
- 总结与对比
MVCC 定义与核心目标
定义
多版本并发控制 (MVCC) 是一种数据库并发控制协议,其核心思想是:
为数据库中的每一条数据维护多个历史版本,使得读操作可以访问旧版本数据,而写操作创建新版本,从而实现“读写互不阻塞”。
核心目标
- 读写分离:读不阻塞写,写不阻塞读。
- 一致性读 (Consistent Read):事务启动时看到的是一个一致性的数据快照。
- 高并发:相比传统的两阶段锁 (2PL),显著提升系统吞吐量。
抽象接口设计 (Interface Design)
为了屏蔽底层实现的差异(Undo Log 还是 Heap Page),所有 MVCC 系统对外暴露的逻辑通常遵循一套相似的接口。
java
// MVCC 引擎的抽象接口
public interface MvccEngine {
/**
* 事务开始时调用,获取一个 Read View (快照)
* @param txId 当前事务ID
* @return ReadView 对象,包含所有可见性判断规则
*/
ReadView beginTransaction(long txId);
/**
* 读取数据
* @param key 数据主键
* @param readView 事务的快照
* @return 可见的数据版本
*/
DataVersion read(Key key, ReadView readView);
/**
* 写入数据(创建新版本)
* @param key 数据主键
* @param newValue 新值
* @param txId 当前事务ID
*/
void write(Key key, Value newValue, long txId);
/**
* 提交事务
* @param txId 事务ID
*/
void commit(long txId);
/**
* 回滚事务
* @param txId 事务ID
*/
void rollback(long txId);
}
实现方式一:基于 Undo Log (InnoDB 模式)
这是最经典的 MVCC 实现,以 MySQL InnoDB 为代表。
核心组件
- B+Tree 聚簇索引:存储数据的最新版本。
- Undo Log (回滚段):存储数据的历史版本。
- Roll Pointer (回滚指针):存在于 B+Tree 的叶子节点中,指向 Undo Log。
数据结构示意
// InnoDB 行记录结构简化版
struct Row {
long trx_id; // 最后一次修改该行的事务ID
RollPointer roll_ptr; // 指向 Undo Log 的指针
Data data; // 当前数据
};
// Undo Log 结构
struct UndoLog {
long prev_trx_id; // 上一个版本的事务ID
Data old_data; // 旧版本数据
UndoLog* prev_log; // 指向上一个 Undo Log 的指针 (形成链表)
};
工作流程
- UPDATE:
- 将当前行复制到 Undo Log 中(旧版本)。
- 修改 B+Tree 中的数据(新版本)。
- 将新行的
roll_ptr指向刚创建的 Undo Log。
- SELECT (快照读):
- 从 B+Tree 取到最新行。
- 根据
ReadView判断最新行是否可见。 - 若不可见(如未提交),顺着
roll_ptr遍历 Undo Log 链表,直到找到第一个可见的版本。
实现方式二:基于 Heap Tuple (PostgreSQL 模式)
PostgreSQL 采用了另一种思路:直接将多版本数据(Tuple)存储在 Heap 表中。
核心组件
- Heap Table:无序的堆表,存放所有版本的 Tuple。
- Tuple Header:每个数据行头部包含版本信息 (
xmin,xmax)。 - Index (B-Tree):索引指向 Heap 中的行指针 (CTID)。
数据结构示意
// PostgreSQL Tuple 头
struct TupleHeader {
long xmin; // 插入该元组的事务ID
long xmax; // 删除/更新该元组的事务ID (0 表示未删除)
// ...
};
// 简化的可见性判断逻辑
bool isVisible(Tuple tuple, ReadView view) {
if (tuple.xmin == view.currentTxId) return true; // 自己改的可见
if (tuple.xmin < view.minActiveTxId && tuple.xmax == 0) return true; // 已提交且无删除
// ... 更复杂的活跃事务列表判断
return false;
}
特点
- 优势:索引结构简单,更新速度快(只需插入新 Tuple,更新索引指针)。
- 劣势:会产生大量垃圾版本(Dead Tuples),需要
VACUUM进程定期清理。
实现方式三:基于时间戳排序 (NewSQL 模式)
在分布式系统中,无法使用单机的内存指针(Undo Log),因此采用 时间戳 作为版本号。
代表系统:TiDB, CockroachDB。
核心组件
- 全局授时服务 (TSO):生成单调递增的时间戳。
- Key-Value 存储:存储
(Key, Timestamp) -> Value。
写入与读取逻辑
class TimestampMvccEngine implements MvccEngine {
private long getTimestamp() {
return globalTso.get(); // 从全局时钟获取
}
@Override
public void write(Key key, Value value, long txId) {
long commitTs = getTimestamp();
kvStore.put(key, commitTs, value); // 写入新版本
}
@Override
public DataVersion read(Key key, ReadView readView) {
// 读取所有 ts <= readView.readTs 的版本
List<Version> versions = kvStore.scan(key, readView.readTs);
return versions.last(); // 返回最新的可见版本
}
}
特点
- 强一致性:利用全局时间戳严格定义快照。
- 无锁读:完全不需要 Undo Log 链表回溯。
实现方式四:追加式存储 (Append-Only)
代表系统:CouchDB, RocksDB (LevelDB)。
核心思想
- 永不原地修改数据。
- 所有的
PUT/DELETE操作都是顺序写入新的 SST 文件。 - 旧版本通过 SST 文件的层级结构自然保留。
数据结构
SST File Level 0:
| Key=A, Seq=100, Value=X |
| Key=B, Seq=101, Value=Y |
| Key=A, Seq=105, Value=Z | <-- 最新的 A
SST File Level 1:
| Key=A, Seq=90, Value=OldX | <-- 旧版本
特点
- 写入极快(顺序 IO)。
- 读取较慢(需要合并多层 SST 文件)。
- 依赖 Sequence Number 来判断版本新旧。
逻辑时钟与分布式 MVCC
在分布式环境下,由于没有全局物理时钟,需要使用逻辑时钟来定序。
Vector Clock (向量时钟)
用于捕获事件因果关系 (Causality),常见于 Riak、Cassandra 等 AP 系统。
数据结构
class VectorClock {
Map<String, Long> clock; // Key: NodeId, Value: Logical Time
}
核心规则
- 本地事件:递增自己的时钟分量。
- 发送消息:携带当前的 Vector Clock。
- 接收消息:
myClock[node] = max(myClock[node], receivedClock[node])。
优缺点
- ✅ 精准判断因果关系。
- ❌ 元数据随节点数线性增长 (O(N)),不适合大规模集群。
Hybrid Logical Clock (混合逻辑时钟)
结合了物理时钟和逻辑时钟,是 CockroachDB 等现代分布式数据库的基石。
- 组成:
(WallTime, LogicTime)。 - 原理:优先使用 NTP 物理时间,发生冲突时用逻辑计数器兜底。
- 优势:在保证因果序的前提下,将元数据开销降至 O(1)。
总结与对比
| 实现方式 | 代表系统 | 版本存储位置 | 核心依赖 | 优势 | 劣势 |
|---|---|---|---|---|---|
| Undo Log | MySQL InnoDB | 独立 Undo 表空间 | Undo Log, Roll Pointer | 索引稳定,主数据页干净 | Undo 膨胀,回滚慢 |
| Heap Tuple | PostgreSQL | 主 Heap 表中 | xmin/xmax 规则 | 更新开销小 | 需要 VACUUM,索引膨胀 |
| 时间戳排序 | TiDB, CRDB | KV 层多版本 | 全局 TSO | 分布式友好,强一致 | 依赖 TSO,存储放大 |
| 追加式 | RocksDB | SST 文件 | Sequence Number | 写入性能极佳 | 读放大,Compaction 复杂 |
关键术语对照
- Undo Log:回滚日志,用于存储旧版本。
- Heap Page:堆页,数据库主数据文件。
- Sequence Number (SeqNo):序列号,单机存储引擎的版本号。
- Global Timestamp:全局时间戳,分布式系统的逻辑时钟。
- Vector Clock:向量时钟,用于去中心化的因果判断。
文档总结:
MVCC 并非单一技术,而是一套围绕“如何存储多版本”和“如何判断可见性”的解决方案集合。选择哪种实现,本质上是在读写性能、存储成本、分布式复杂度之间做权衡。