你在半夜三点被告警叫醒。数据库 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 引擎和工程实战经验。
一、并发控制问题:可串行化与隔离级别
为什么需要并发控制
数据库的核心价值之一是让多个事务”同时”操作共享数据,同时保证结果的正确性。这里的”正确性”有一个严格定义——可串行化(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 的核心非常直观:
- 每次写操作不覆盖旧数据,而是创建一个新版本。
- 每个版本都标记了创建它的事务 ID(或时间戳)。
- 读操作根据自己的”快照”来决定看到哪个版本。
这样一来,一个正在读取旧版本的查询不需要对数据加锁,另一个事务可以同时写入新版本。两者互不干扰。
版本链的两种组织方式
所有 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:快照记录”当前所有活跃事务
ID 的列表”。一个版本可见,当且仅当其
xmin(创建事务)已提交且不在快照的活跃列表中,且其xmax(删除事务)未提交或在活跃列表中。 - InnoDB:快照(ReadView)记录创建快照时的活跃事务列表、最小活跃事务
ID(
m_low_limit_id)和最大已分配事务 ID(m_up_limit_id)。 - TiDB:快照就是一个全局时间戳
start_ts,所有commit_ts <= start_ts的版本可见。
三、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 时:
- 在旧 tuple 的
t_xmax字段写入当前事务 ID,标记旧版本”被删除”。 - 在堆表中插入一条全新的 tuple,
t_xmin设为当前事务 ID,t_xmax设为 0。 - 旧 tuple 的
t_ctid指向新 tuple 的位置。 - 更新所有指向该行的索引条目(除非满足 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 | fCLOG:事务状态的仲裁者
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 时:
- 将旧行数据的被修改列写入 undo log,形成一条 undo record。
- 在聚簇索引中原地更新行数据为新值。
- 将行的
DB_TRX_ID更新为当前事务 ID。 - 将行的
DB_ROLL_PTR指向刚才写入的 undo record。 - 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 的创建时机来区分两种隔离级别:
- Read Committed(RC):每条 SELECT 语句都创建一个新的 ReadView。所以同一事务中两次查询可能看到不同的结果。
- Repeatable Read(RR):只在事务的第一条 SELECT 时创建 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)。那二级索引怎么判断可见性?
- 二级索引页面头部有一个
max_trx_id字段,记录修改过该页面的最大事务 ID。 - 如果
max_trx_id < ReadView.m_up_limit_id,说明该页上所有记录都可见,无需进一步检查。 - 否则,需要回表(通过主键去聚簇索引查找),在聚簇索引的行上做可见性判断。
这就是为什么 InnoDB 在 RR 级别下,覆盖索引扫描有时候不得不回表的原因。
五、TiDB/Percolator:分布式 MVCC
架构概览
TiDB 的 MVCC 实现继承自 Google 的 Percolator 论文,针对分布式场景做了关键适配。核心组件:
- TiKV:分布式 KV 存储层,底层是 RocksDB。每个 TiKV 节点管理若干 Region(数据分片)。
- PD(Placement Driver):集群调度中心,同时承担 TSO(Timestamp Oracle)角色。
- TSO:全局单调递增的时间戳分配器,是分布式 MVCC 的”时钟源”。
三个 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 是全局单点,如何避免成为瓶颈?
- 批量分配:TiDB 的每个节点不是每次需要时间戳都去请求一次 TSO,而是批量预取一段时间戳区间。
- Pipeline:TSO 请求支持 pipeline,一个 RTT 内可以处理多个请求。
- 本地缓存:对于只读事务,可以使用 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 环,写偏斜发生
实际影响
写偏斜不是理论怪谈。以下场景都可能触发:
- 双重预订:两个用户同时查询剩余座位数,各自认为还有余量,同时预订。
- 余额转账:两个账户各自检查对方余额后同时转账。
- 唯一性约束:两个事务同时检查用户名是否存在,都发现不存在,都插入了同样的用户名。
七、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 不是免费的:
- 内存开销:需要维护所有活跃事务的 SIREAD 锁和 rw-conflict 列表。
- 假阳性:SSI 是保守的,它可能中止一些实际上不会导致异常的事务。
- 重试负担:被中止的事务需要应用层重试。
但与传统的 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.50Autovacuum 的配置
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 线程负责清理:
- Purge 线程维护一个全局的”最老活跃 ReadView”。
- 所有
trx_id小于最老活跃 ReadView 的m_up_limit_id的 undo record,都可以安全清理。 - 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,局部性更好。
关键观察
- InnoDB N2O 的代价:读最新版本快,但回溯旧版本需要逐条遍历 undo record,每条可能引发一次 I/O。
- PostgreSQL HOT 优化:如果更新不涉及索引列,新旧 tuple 在同一个 page 内通过 HOT chain 连接,避免索引更新开销。
- 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 恢复算法 - 并发哈希表