“MVCC 就是多版本嘛” —— 说完这句的人通常也不知道 write skew 是什么。
多版本并发控制(Multi-Version Concurrency Control)是现代关系型数据库的核心机制,PostgreSQL、MySQL InnoDB、Oracle、SQL Server 的快照隔离都依赖它。但”多版本”只是手段,真正的问题是:快照隔离到底隔离了什么?隔离不了什么?
本文从 PostgreSQL 的实现出发,逐层拆解:版本链怎么存、快照怎么判可见性、隔离级别之间到底差在哪、Snapshot Isolation 的盲区(write skew)是什么、SSI 如何堵上这个漏洞,以及 MVCC 在工程上付出了什么代价。
如果你读过本站的 LSM-Tree 系列 或 SQLite 性能拆解,本文将从并发控制视角补完存储引擎的另一个关键维度。
一、为什么需要 MVCC
锁的代价:读写互斥杀吞吐
最朴素的并发控制是加锁。一个事务要读某行,先获取共享锁;要写某行,先获取排他锁。这保证了正确性,但代价是读写互斥:
-- 事务 A:读取账户余额(获取共享锁)
SELECT balance FROM accounts WHERE id = 1; -- 持有 S 锁
-- 事务 B:更新账户余额(需要排他锁)
UPDATE accounts SET balance = balance - 200 WHERE id = 1;
-- 阻塞!等待事务 A 释放 S 锁在 OLTP 系统中,一个长读事务(比如生成报表)会阻塞所有写操作。写操作之间还会死锁。吞吐量在高并发下急剧下降。
MVCC 的核心思想
MVCC 的解法很直接:读不阻塞写,写不阻塞读。
- 每次写操作不覆盖旧数据,而是创建新版本
- 每个事务看到的是一个一致性快照(snapshot),而不是当前最新的数据
- 读操作根据快照找到”自己应该看到的那个版本”,完全不需要加锁
写操作:INSERT 新版本,标记旧版本过期
读操作:根据快照判断哪个版本对自己可见
这意味着:一个生成报表的只读事务,和一个高频更新的写事务,可以完全并发运行,互不阻塞。
关键洞察:MVCC 不是消除了锁,而是把”读需要获取的锁”消除了。写与写之间仍然需要冲突检测。
二、PostgreSQL 的 MVCC 实现
PostgreSQL 的 MVCC 实现有一个独特之处:版本直接存在堆表(heap)里,而不是像 MySQL InnoDB 那样通过 undo log 回溯旧版本。这个设计决策深刻影响了 PostgreSQL 的性能特征。
堆表中的版本链:xmin, xmax, ctid
PostgreSQL 的每个元组(tuple,即行的一个版本)都带有系统列:
| 系统列 | 含义 |
|---|---|
xmin |
创建这个元组的事务 ID |
xmax |
删除(或更新)这个元组的事务 ID,0 表示未删除 |
ctid |
指向同一行下一个版本的物理位置 (blockno, offset) |
t_infomask |
状态位:xmin 是否已提交、xmax 是否已提交等 |
一次 UPDATE 在 PostgreSQL 中的实际操作:
-- 用户执行
UPDATE accounts SET balance = 800 WHERE id = 1;
-- PostgreSQL 内部:
-- 1. 在旧元组上设置 xmax = 当前事务 ID
-- 2. 插入一个新元组,xmin = 当前事务 ID,xmax = 0
-- 3. 旧元组的 ctid 指向新元组多次更新后,同一行在堆表中形成一条版本链:
每个版本都是一个完整的元组(包含所有列的值),通过
ctid 链接成链表。这和 InnoDB
的设计截然不同——InnoDB
只在聚簇索引中保留最新版本,旧版本存在 undo log
里,通过回滚指针(roll pointer)回溯。
事务快照:active transaction list
当一个事务获取快照时,PostgreSQL 记录三个关键信息:
typedef struct SnapshotData {
TransactionId xmin; // 所有 xid < xmin 的事务已完成
TransactionId xmax; // 所有 xid >= xmax 的事务尚未开始
TransactionId *xip; // xmin 和 xmax 之间仍在活跃的事务列表
uint32 xcnt; // 活跃事务的数量
} SnapshotData;快照的语义:一个事务对我可见,当且仅当它在我获取快照之前已经提交。
举例:假设快照时刻的状态是:
- 事务 100, 101, 103 已提交
- 事务 102 正在执行(活跃)
- 事务 104 尚未开始
那么快照为:xmin=100, xmax=104, xip=[102]
- 事务 100: xid < xmax 且不在 xip 中 → 可见
- 事务 102: 在 xip 中 → 不可见(还没提交)
- 事务 104: xid >= xmax → 不可见(还没开始)
可见性判断规则
PostgreSQL 在
src/backend/utils/time/tqual.c(旧版本)和
src/backend/access/heap/heapam_visibility.c(新版本)中实现了元组可见性判断。核心逻辑可以归纳为
5 条规则:
规则 1:xmin 未提交且不是当前事务 → 不可见
// 创建这个元组的事务还没提交(或已回滚),别人看不到
if (!TransactionIdDidCommit(tuple->xmin) &&
tuple->xmin != GetCurrentTransactionId())
return false; // 不可见规则 2:xmin 已提交,且 xmin 在快照中不可见 → 不可见
// 创建元组的事务虽然提交了,但在我获取快照之后才提交
// 注意:XidInMVCCSnapshot() 返回 true 表示该事务在快照中仍然"活跃"(未提交),
// 因此该事务的修改对当前快照不可见。
if (!XidInMVCCSnapshot(tuple->xmin, snapshot))
return false; // 不可见:这个版本"来自未来"规则 3:xmax 为 0 或无效 → 可见
// 没有事务删除或更新过这个元组,它就是当前有效版本
if (tuple->xmax == InvalidTransactionId)
return true; // 可见规则 4:xmax 未提交 → 可见
// 有事务正在删除这个元组,但还没提交,对我来说元组仍然有效
if (!TransactionIdDidCommit(tuple->xmax))
return true; // 可见:删除操作还没生效规则 5:xmax 已提交且在快照中可见 → 不可见
// 删除这个元组的事务在我获取快照之前就已提交
if (XidInMVCCSnapshot(tuple->xmax, snapshot))
return false; // 不可见:元组已被删除实际实现还要处理子事务(subtransaction)、组合状态位(
t_infomask)的缓存优化等细节,但核心逻辑就是这 5 条。
VACUUM 的角色:清理死元组
版本链会不断增长——每次 UPDATE 都创建新版本,旧版本不会自动消失。VACUUM 负责回收所有活跃事务都不再需要的死元组:
-- 手动 VACUUM
VACUUM accounts;
-- 查看死元组数量
SELECT relname, n_dead_tup, last_vacuum
FROM pg_stat_user_tables
WHERE relname = 'accounts';VACUUM 的工作流程:
- 扫描堆表,找到所有
xmax已提交且对所有活跃事务不可见的元组 - 将这些元组标记为可复用空间(在 Free Space Map 中记录)
- 如果整个页面都是死元组,归还给操作系统(
VACUUM FULL才做这一步) - 更新 Visibility Map,标记”全可见”页面(Index-Only Scan 依赖这个)
HOT(Heap Only Tuple)优化
如果 UPDATE 没有修改任何索引列,且新版本和旧版本在同一个页面内,PostgreSQL 可以使用 HOT 优化:
旧元组 (page 5, offset 3) --ctid--> 新元组 (page 5, offset 7)
↑ HOT: 不需要更新索引
好处是双重的:
- 索引不需要新增条目——索引仍然指向旧元组,通过 ctid 链追到新版本
- 微型 VACUUM(HOT pruning)可以在普通页面访问时顺便回收旧版本,不需要等大 VACUUM
HOT 优化对写密集场景至关重要。如果你的 UPDATE 频繁修改索引列,HOT 无法生效,死元组堆积速度会快得多。
三、隔离级别和 MVCC 的关系
SQL 标准定义了四个隔离级别,但它们的定义是基于”异象”(anomaly)的,而不是基于实现机制的。MVCC 是实现手段,隔离级别是行为保证。PostgreSQL 用同一套 MVCC 机制,通过快照获取的时机和额外的冲突检测来实现不同隔离级别。
Read Committed:每条语句一个快照
-- 事务 A(Read Committed)
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 快照 S1,看到 1000
-- 事务 B(此时提交了 UPDATE:balance = 800)
SELECT balance FROM accounts WHERE id = 1; -- 快照 S2,看到 800!
COMMIT;行为:每执行一条 SQL
语句,重新获取一个快照。所以同一个事务里两次
SELECT 可能看到不同的值。这是 PostgreSQL
的默认隔离级别。
能防止的异象:脏读(dirty read) 防不住的异象:不可重复读、幻读、write skew
Repeatable Read(Snapshot Isolation):整个事务一个快照
-- 事务 A(Repeatable Read)
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1; -- 快照 S1,看到 1000
-- 事务 B(此时提交了 UPDATE:balance = 800)
SELECT balance FROM accounts WHERE id = 1; -- 仍用 S1,仍看到 1000
COMMIT;行为:事务开始时获取一个快照,之后所有语句都用这个快照。如果事务试图更新一行已被其他事务修改过的数据,会收到序列化错误:
ERROR: could not serialize access due to concurrent update能防止的异象:脏读、不可重复读、幻读(大部分情况) 防不住的异象:write skew
注意:PostgreSQL 的 Repeatable Read 实际上是 Snapshot Isolation(SI),比 SQL 标准要求的 Repeatable Read 更强——它能防止幻读。但 SI 不等于 Serializable。
Serializable:快照 + 额外检测
-- 事务 A(Serializable)
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM doctors WHERE on_call = true; -- 看到 2
UPDATE doctors SET on_call = false WHERE name = 'Alice';
COMMIT; -- 可能失败:检测到序列化冲突行为:在 Snapshot Isolation 的基础上,额外追踪事务之间的读写依赖关系。如果检测到可能导致非序列化执行结果的依赖模式,回滚其中一个事务。
能防止的异象:所有异象,包括 write skew
各级别对比
| 隔离级别 | 快照时机 | 脏读 | 不可重复读 | 幻读 | Write Skew |
|---|---|---|---|---|---|
| Read Committed | 每条语句 | ✗ 防止 | ✓ 可能 | ✓ 可能 | ✓ 可能 |
| Repeatable Read (SI) | 事务开始 | ✗ 防止 | ✗ 防止 | ✗ 防止 | ✓ 可能 |
| Serializable (SSI) | 事务开始 | ✗ 防止 | ✗ 防止 | ✗ 防止 | ✗ 防止 |
四、Write Skew:快照隔离的盲区
经典例子:两个医生同时请假
某医院要求任何时候至少有 2 名医生在岗。当前有 Alice、Bob、Charlie 三人在岗。
-- 事务 T1(Alice 想请假)
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors WHERE on_call = true;
-- 结果:3(Alice, Bob, Charlie 在岗)→ 3 >= 2 + 1,可以请假
UPDATE doctors SET on_call = false WHERE name = 'Alice';
COMMIT; -- 成功
-- 事务 T2(Bob 同时想请假)—— 与 T1 并发执行
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors WHERE on_call = true;
-- 快照里也看到 3(Alice, Bob, Charlie 在岗)→ 可以请假
UPDATE doctors SET on_call = false WHERE name = 'Bob';
COMMIT; -- 也成功!结果:只有 Charlie 一个人在岗,违反了”至少 2 人在岗”的约束。
为什么 Snapshot Isolation 抓不住
Write skew 的特征是:两个事务读相同的数据,但写不同的行。
- T1 读了
doctors表的全部行,写了 Alice 的行 - T2 读了
doctors表的全部行,写了 Bob 的行
在 Snapshot Isolation 下,写冲突检测只看”是否有两个事务写了同一行”。T1 和 T2 写的是不同行,所以不会触发冲突。但它们的读集和写集之间存在逻辑依赖。
T1: R(doctors) → W(Alice)
T2: R(doctors) → W(Bob)
T1 的决策依赖于 Bob 在岗(T2 还没写)
T2 的决策依赖于 Alice 在岗(T1 还没写)
→ 循环依赖,非序列化结果
与 Lost Update 的区别
| 异象 | 读集 | 写集 | SI 能否检测 |
|---|---|---|---|
| Lost Update | 读同一行 | 写同一行 | ✓ 能(first-updater-wins) |
| Write Skew | 读同一数据 | 写不同行 | ✗ 不能 |
Lost Update 是 write skew 的特例(读集和写集重叠)。SI 能通过”first-updater-wins”规则检测 lost update,但 write skew 中写集不重叠,这个规则无法触发。
PostgreSQL 的 SSI(Serializable Snapshot Isolation)
PostgreSQL 9.1 引入了 SSI,这是 Michael Cahill 博士论文中提出的算法在工业级数据库中的首次实现。SSI 不使用传统的两阶段锁(2PL),而是在 Snapshot Isolation 基础上增加依赖追踪。
核心思路:追踪事务之间的 rw-antidependency(读写反依赖)。
rw-antidependency 和 dangerous structure
当事务 T1 读了某个数据项,随后事务 T2 写了同一个数据项(创建了更新版本),就形成一条 rw-antidependency:T1 →rw T2。
定义:如果 T1 读了数据项 x 的版本 v,T2 创建了 x 的更新版本 v'
则存在 rw-antidependency:T1 →(rw) T2
Cahill 证明了一个关键定理:在 Snapshot Isolation 下,如果一个非序列化的执行存在,那么依赖图中一定包含一个”dangerous structure”——即存在两条相邻的 rw-antidependency:
T_in →(rw) T_pivot →(rw) T_out
其中 T_pivot 是”枢轴事务”,它既被别的事务读了(T_in 读的数据被 T_pivot 更新),又读了别人后来写的数据(T_pivot 读的数据被 T_out 更新)。
回到医生请假的例子:
T1 →(rw) T2:T1 读了 Bob 的在岗状态,T2 后来改了 Bob 的状态
T2 →(rw) T1:T2 读了 Alice 的在岗状态,T1 后来改了 Alice 的状态
形成环:T1 →(rw) T2 →(rw) T1
这就是 dangerous structure → SSI 回滚其中一个事务
PostgreSQL 的 SSI 实现维护两个锁表:
// SIREAD 锁:记录事务读了什么(不阻塞,只追踪)
// 存储在共享内存中的哈希表
typedef struct SERIALIZABLEXACT {
// ...
SHM_QUEUE inConflicts; // 指向我的 rw-antidependency
SHM_QUEUE outConflicts; // 我指向别人的 rw-antidependency
// ...
} SERIALIZABLEXACT;当写操作创建新版本时,SSI 检查是否有其他活跃的 Serializable 事务曾经读过同一数据项。如果是,记录一条 rw-antidependency。当检测到 dangerous structure 时,回滚其中一个事务。
SSI 的性能开销比 2PL 低得多——读操作仍然不阻塞写操作,只是额外维护了 SIREAD 锁的追踪信息。代价是偶尔的误报(false positive):某些被回滚的事务实际上不会导致异常,但 SSI 保守地拒绝了它们。
五、MVCC 的工程代价
MVCC 带来了优雅的并发模型,但没有免费的午餐。
版本膨胀(bloat)和 VACUUM 的开销
每次 UPDATE 创建新版本,旧版本不会立即消失。如果一个表被频繁更新,堆表会快速膨胀:
-- 一个 1GB 的表被频繁更新后可能膨胀到 5GB
-- 检查膨胀率
SELECT
schemaname, tablename,
pg_size_pretty(pg_total_relation_size(schemaname || '.' || tablename)) AS total_size,
n_dead_tup,
n_live_tup,
round(n_dead_tup::numeric / greatest(n_live_tup, 1) * 100, 2) AS dead_ratio_pct
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC;VACUUM 需要扫描整个表来回收空间,这本身就是 I/O 密集的操作。autovacuum 默认配置在写密集场景下经常跟不上:
-- autovacuum 调优示例(写密集表)
ALTER TABLE hot_table SET (
autovacuum_vacuum_scale_factor = 0.01, -- 默认 0.2,改为 1% 就触发
autovacuum_vacuum_cost_delay = 2, -- 默认 20ms,加快速度
autovacuum_vacuum_cost_limit = 1000 -- 默认 200,允许更多 I/O
);长事务导致的 VACUUM 无法清理
VACUUM 只能回收所有活跃事务都不再需要的元组。一个长事务会”钉住”它的快照:
-- 一个忘记关闭的事务(快照在 txid 1000)
BEGIN;
SELECT * FROM some_table;
-- ... 开发者去吃午饭了 ...
-- 这期间所有 xmax > 1000 的死元组都无法被 VACUUM 清理
-- 表持续膨胀这是生产环境中最常见的 PostgreSQL 性能问题之一。但仅靠手动查 SQL 是不够的——你需要一套系统化的预警机制。
事务异常预警模型
从”发现问题”到”自动报警”,需要定义清晰的指标、阈值和流水线。
核心指标定义
| 指标 | 含义 | 数据源 | 黄色阈值 | 红色阈值 |
|---|---|---|---|---|
| snapshot_age | 最老活跃事务的存活时长 | pg_stat_activity.xact_start |
> 5 min | > 30 min |
| tuple_bloat_ratio | 死元组数 / 活元组数 | pg_stat_user_tables.n_dead_tup / n_live_tup |
> 20% | > 50% |
| vacuum_lag | 距上次 autovacuum 的时间 | pg_stat_user_tables.last_autovacuum |
> 1 hour | > 6 hours |
| xid_age | 最老未冻结事务 ID 的年龄 | age(datfrozenxid) |
> 500M | > 1B |
| dead_tup_rate | 死元组产生速率 | Δ(n_dead_tup) / Δ(time) | > 10K/min | > 50K/min |
Prometheus Exporter 采集
用 postgres_exporter
暴露上述指标(需要自定义查询):
# postgres_exporter 自定义查询配置
pg_long_running_transactions:
query: |
SELECT
max(extract(epoch from (now() - xact_start))) AS max_snapshot_age_seconds,
count(*) FILTER (WHERE xact_start < now() - interval '5 min') AS long_txn_count
FROM pg_stat_activity
WHERE state != 'idle' AND xact_start IS NOT NULL
metrics:
- max_snapshot_age_seconds:
usage: "GAUGE"
description: "最老活跃事务的存活时间(秒)"
- long_txn_count:
usage: "GAUGE"
description: "超过 5 分钟的活跃事务数"
pg_table_bloat:
query: |
SELECT
schemaname, relname,
n_dead_tup,
n_live_tup,
CASE WHEN n_live_tup > 0
THEN round(n_dead_tup::numeric / n_live_tup * 100, 2)
ELSE 0
END AS bloat_ratio_pct,
extract(epoch from (now() - last_autovacuum)) AS vacuum_lag_seconds
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000
ORDER BY n_dead_tup DESC
LIMIT 20
metrics:
- bloat_ratio_pct:
usage: "GAUGE"
description: "死元组 / 活元组比率"
- vacuum_lag_seconds:
usage: "GAUGE"
description: "距上次 autovacuum 的秒数"Grafana Dashboard + 告警规则
# Grafana alerting rule(伪配置)
- alert: PostgresLongTransaction
expr: pg_long_running_transactions_max_snapshot_age_seconds > 300
for: 2m
labels:
severity: warning
annotations:
summary: "存在超过 5 分钟的长事务"
description: "最老活跃事务已运行 {{ $value }}s,可能阻止 VACUUM 清理"
- alert: PostgresTableBloat
expr: pg_table_bloat_bloat_ratio_pct > 50
for: 10m
labels:
severity: critical
annotations:
summary: "表 {{ $labels.relname }} 膨胀率超过 50%"
description: "死元组比率 {{ $value }}%,建议手动触发 VACUUM"
- alert: PostgresXidWraparound
expr: pg_database_xid_age > 1000000000
for: 5m
labels:
severity: critical
annotations:
summary: "XID 年龄超过 10 亿,回卷风险"这套流水线的效果是:开发者忘记关事务 → 5 分钟后黄色告警 → DBA 收到通知 → 主动 kill 长事务,而不是等到表膨胀了 5 倍、autovacuum 跑不动了才发现。
事务 ID 回卷(wraparound)问题
PostgreSQL 使用 32 位无符号整数作为事务 ID,上限约 42 亿。当 txid 接近回卷点时,如果不及时 VACUUM,新旧事务 ID 的比较会出错——“未来的”事务看起来像”过去的”,导致数据丢失。
PostgreSQL 通过 VACUUM FREEZE 将元组的 xmin
标记为”frozen”(一个特殊值,表示”对所有事务都可见”)来避免回卷:
-- 检查距离回卷还有多少事务
SELECT datname,
age(datfrozenxid) AS xid_age,
2147483647 - age(datfrozenxid) AS remaining
FROM pg_database
ORDER BY xid_age DESC;
-- 如果 remaining 接近 0,紧急执行
VACUUM FREEZE;在 PostgreSQL 的运维事故中,事务 ID 回卷导致数据库被迫进入单用户模式做紧急 VACUUM FREEZE,是最严重的事故类型之一。
MySQL InnoDB vs PostgreSQL MVCC 对比
两种实现代表了 MVCC 的两条路线:
| 维度 | PostgreSQL | MySQL InnoDB |
|---|---|---|
| 版本存储 | 堆表中直接存多版本 | 聚簇索引存最新版本,旧版本在 undo log |
| UPDATE 操作 | 插入新元组 + 标记旧元组 xmax | 原地更新聚簇索引 + 写 undo log |
| 版本回溯 | 通过 ctid 链从旧到新 | 通过 roll pointer 从新到旧 |
| 回收机制 | VACUUM 清理死元组 | Purge 线程清理 undo log |
| 索引处理 | 每个版本可能需要索引条目(HOT 优化除外) | 二级索引不存版本,回表后通过 undo 回溯 |
| 读旧版本成本 | 直接读堆表,与读新版本一样快 | 需要从 undo log 重建,版本越旧越慢 |
| 写放大 | 高(每个版本是完整元组) | 低(undo log 只记录被修改的列) |
| 长事务影响 | 阻止 VACUUM,表膨胀 | 阻止 purge,undo log 膨胀 |
| 事务 ID | 32 位,需要 FREEZE 防回卷 | 48 位(6 字节),回卷周期极长 |
PostgreSQL 的优势:读旧版本不需要回溯 undo 链,在长事务读密集场景下更稳定。 InnoDB 的优势:写放大更低,不需要频繁 VACUUM,索引维护成本低。
不同数据库的 MVCC 实现总览
| 数据库 | 版本存储位置 | 旧版本访问方式 | 回收机制 | 隔离级别上限 |
|---|---|---|---|---|
| PostgreSQL | 堆表 | ctid 链(旧→新) | VACUUM | Serializable (SSI) |
| MySQL InnoDB | undo log | roll pointer(新→旧) | Purge 线程 | Repeatable Read (SI) |
| Oracle | undo tablespace | undo 回溯 | 自动 undo 管理 | Serializable (SI + SELECT FOR UPDATE) |
| SQL Server | tempdb 版本存储 | 行版本指针 | 后台清理 | Snapshot Isolation |
| CockroachDB | MVCC 键前缀 + 时间戳 | 直接存储在 KV 层 | GC 作业 | Serializable (SSI) |
CockroachDB 的 MVCC:HLC 时间戳替代 XID
PostgreSQL 和 MySQL 的 MVCC 用事务 ID(XID / trx_id)标记版本。CockroachDB 走了完全不同的路:用 HLC(混合逻辑时钟)时间戳作为版本号。这个设计选择解决了 PostgreSQL 的几个痛点。
HLC 时间戳 vs XID
| 维度 | PostgreSQL (XID) | CockroachDB (HLC) |
|---|---|---|
| 版本标识 | 32 位事务 ID | 128 位 HLC 时间戳(物理时间 + 逻辑计数器 + 节点 ID) |
| 回卷问题 | 42 亿上限,需要 VACUUM FREEZE | 无回卷——时间戳单调递增,不循环使用 |
| 物理含义 | 无(纯逻辑序号) | 有——近似真实时间,可用于调试和审计 |
| 分布式生成 | 单节点递增分配 | 每个节点独立生成,HLC 保证因果序 |
| 可见性判断 | 检查 xmin/xmax 是否在快照活跃列表中 | 比较写入时间戳和读事务时间戳的大小 |
Intent-Based 写锁
CockroachDB 的写操作不是直接写入最终数据,而是先写一个 intent(意图)——本质上是一个带事务元数据的临时版本:
写入 key="account-1", value=800:
实际存储:
key: /account-1/hlc_timestamp → {value: 800, txn_id: xxx, status: PENDING}
这就是 intent:一个"我打算写这个值但还没提交"的标记
其他事务遇到 intent 时: - intent 的事务已提交 → 把 intent 解析为正式版本,继续读 - intent 的事务已回滚 → 忽略这个 intent - intent 的事务还在进行中 → 等待或推高对方的时间戳(read push / write push)
这和 Percolator 的 lock 列设计异曲同工——事务状态编码在数据中,不依赖独立的锁管理器。
Read Refresh:不确定性窗口的处理
因为 HLC 依赖 NTP,不同节点的时钟有偏差。CockroachDB 用
max_offset(默认
500ms)定义不确定性窗口。读事务遇到这个窗口内的写入时,不是阻塞等待,而是
read refresh:
读事务 T_read,时间戳 ts_read,不确定性上界 ts_read + max_offset
遇到写入 W,时间戳 ts_write:
if ts_write > ts_read + max_offset:
→ W 一定在 T_read 之后发生,忽略
if ts_write <= ts_read:
→ W 一定在 T_read 之前发生,可见
if ts_read < ts_write <= ts_read + max_offset:
→ 不确定!
→ 将 T_read 的时间戳推高到 ts_write + 1
→ 检查推高后是否影响之前已读的数据(read refresh)
→ 如果没影响,事务继续;如果有影响,重启事务
read refresh 的巧妙之处:大多数情况下,推高时间戳不会影响已读数据(因为那些数据在新旧时间戳下可见性相同),事务可以继续而不需要重启。只有极端情况(不确定性窗口内恰好有你读过的数据被修改)才需要重启。
彻底解决 XID 回卷
PostgreSQL 的 32 位 XID 是一个永恒的运维隐患。XID 用完前必须 VACUUM FREEZE,否则数据库会拒绝写入甚至停机。大表的 FREEZE 操作可能需要数小时,期间 I/O 负载极高。
CockroachDB 根本没有这个问题:
- HLC 时间戳基于物理时间,单调递增,不会”用完”
- 旧版本通过 GC 作业按时间窗口回收(默认保留 25 小时),不需要 FREEZE 语义
- GC 是增量的——每次只清理一小块数据,不会像 VACUUM FULL 那样锁整个表
代价:HLC 的不确定性窗口导致读事务偶尔需要重启(PostgreSQL 的 XID 是精确的,没有这个问题)。这是用”统计上的小概率重启”换”确定性的 XID 回卷灾难”——对生产系统来说,前者显然更可接受。
总结
MVCC 的本质是用空间换时间——保留多个版本来避免读写锁冲突。但”快照隔离”并不是银弹:
- 快照隔离不等于可序列化。Write skew 是快照隔离的结构性盲区,不是实现 bug。
- SSI 堵上了这个漏洞,但代价是额外的读写依赖追踪和偶尔的误报回滚。
- MVCC 的工程代价是真实的:版本膨胀、VACUUM 开销、长事务问题、事务 ID 回卷,每一个都能在生产环境中造成严重故障。
选择隔离级别不是”越高越好”。Read Committed 足以应对大多数 OLTP 场景,Serializable 适合正确性要求极高的业务逻辑(比如金融转账)。关键是理解你的隔离级别保证了什么,没保证什么。
数据库不会骗你,但快照会让你看到过时的真相。知道自己看到的是”快照”而非”现实”,是用好 MVCC 的第一步。
参考
- Berenson, H., et al. A Critique of ANSI SQL Isolation Levels. SIGMOD, 1995.
- Cahill, M. J. Serializable Isolation for Snapshot Databases. PhD Thesis, University of Sydney, 2009.
- Ports, D. R. K., Grittner, K. Serializable Snapshot Isolation in PostgreSQL. VLDB, 2012.
- PostgreSQL Documentation. Chapter 13: Concurrency Control.
- PostgreSQL Source Code.
src/backend/access/heap/heapam_visibility.c. - Kleppmann, M. Designing Data-Intensive Applications. O’Reilly, 2017. Chapter 7.