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

数据库 MVCC:快照隔离到底隔离了什么

目录

“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 的解法很直接:读不阻塞写,写不阻塞读

写操作: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 指向新元组

多次更新后,同一行在堆表中形成一条版本链

PostgreSQL MVCC 版本链与快照可见性

每个版本都是一个完整的元组(包含所有列的值),通过 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;

快照的语义:一个事务对我可见,当且仅当它在我获取快照之前已经提交

举例:假设快照时刻的状态是:

那么快照为:xmin=100, xmax=104, xip=[102]

可见性判断规则

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 的工作流程:

  1. 扫描堆表,找到所有 xmax 已提交且对所有活跃事务不可见的元组
  2. 将这些元组标记为可复用空间(在 Free Space Map 中记录)
  3. 如果整个页面都是死元组,归还给操作系统(VACUUM FULL 才做这一步)
  4. 更新 Visibility Map,标记”全可见”页面(Index-Only Scan 依赖这个)

HOT(Heap Only Tuple)优化

如果 UPDATE 没有修改任何索引列,且新版本和旧版本在同一个页面内,PostgreSQL 可以使用 HOT 优化:

旧元组 (page 5, offset 3) --ctid--> 新元组 (page 5, offset 7)
                                     ↑ HOT: 不需要更新索引

好处是双重的:

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 的特征是:两个事务读相同的数据,但写不同的行

在 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 的不确定性窗口导致读事务偶尔需要重启(PostgreSQL 的 XID 是精确的,没有这个问题)。这是用”统计上的小概率重启”换”确定性的 XID 回卷灾难”——对生产系统来说,前者显然更可接受。


总结

MVCC 的本质是用空间换时间——保留多个版本来避免读写锁冲突。但”快照隔离”并不是银弹:

  1. 快照隔离不等于可序列化。Write skew 是快照隔离的结构性盲区,不是实现 bug。
  2. SSI 堵上了这个漏洞,但代价是额外的读写依赖追踪和偶尔的误报回滚。
  3. MVCC 的工程代价是真实的:版本膨胀、VACUUM 开销、长事务问题、事务 ID 回卷,每一个都能在生产环境中造成严重故障。

选择隔离级别不是”越高越好”。Read Committed 足以应对大多数 OLTP 场景,Serializable 适合正确性要求极高的业务逻辑(比如金融转账)。关键是理解你的隔离级别保证了什么,没保证什么

数据库不会骗你,但快照会让你看到过时的真相。知道自己看到的是”快照”而非”现实”,是用好 MVCC 的第一步。


参考

  1. Berenson, H., et al. A Critique of ANSI SQL Isolation Levels. SIGMOD, 1995.
  2. Cahill, M. J. Serializable Isolation for Snapshot Databases. PhD Thesis, University of Sydney, 2009.
  3. Ports, D. R. K., Grittner, K. Serializable Snapshot Isolation in PostgreSQL. VLDB, 2012.
  4. PostgreSQL Documentation. Chapter 13: Concurrency Control.
  5. PostgreSQL Source Code. src/backend/access/heap/heapam_visibility.c.
  6. Kleppmann, M. Designing Data-Intensive Applications. O’Reilly, 2017. Chapter 7.

By .