跳至内容

5.2 MVCC 与可见性

应用说“订单 1002 这一行”,heap 中却可能先后存在多个 tuple version。PostgreSQL 不让读取者与写入者围绕同一份可变字节互相排斥,而是让语句用 snapshot 判断哪些版本对自己可见。这就是 MVCC 的核心;它换来的并发性并非免费,旧版本最终必须由 VACUUM 安全回收。

5.2.1 元组版本、事务 ID 与快照

对 heap table,UPDATE 的基本效果是建立新版本并让旧版本退出未来可见范围,不是原地覆盖所有字段。逻辑主键仍指向“同一订单”,但物理 tuple header 携带版本信息:

系统列诊断含义不能怎样用
xmin插入此 tuple version 的 transaction ID不能当创建时间或全局唯一业务 ID
xmax删除/更新/锁定相关 XID 或 MultiXact 信息非零不自动等于“已提交删除”
ctid当前 tuple version 的物理 page/slotUPDATE、VACUUM FULL 等会改变,不能当主键
cmin/cmax同一事务内 command ordering 的内部信息不应写进应用协议

官方文档把 xmin 定义为“插入这个 row version 的事务”,并明确说每次更新会形成新的 row version。xmax 的语义更复杂:删除事务未提交、已经回滚,或行锁形成 MultiXact 时都可能非零。因此:

SELECT order_id, xmin, xmax, ctid
FROM shop.sales_order
WHERE order_id = 1002;

是很好的实验探针,却不是可靠的“这行是否存活”判断器。让 PostgreSQL 的 visibility machinery 返回普通 SELECT 结果,才是应用应使用的接口。

XID 是版本排序工具,不是永久编号

普通内部 XID 是 32 bit 循环空间,依靠 modulo 比较和 freezing 维持可见性。它会 wrap around,也可能因只读事务尚未写入而暂未分配。pg_current_xact_id_if_assigned() 正是为了在不强行分配 XID 的情况下观察当前事务。

本章一次只读观察得到:

assigned_xid_before_write=<none>
backend_snapshot=<none>|959|<none>|<none>

第一项是当前 backend 尚无 write XID;第二项依次是 backend_xidbackend_xmin、wait type、wait event。它不是“没有事务”:BEGIN ... READ ONLY 已经建立事务与 snapshot,只是尚不需要普通写 XID。

PostgreSQL 13 起提供的 xid8/pg_snapshot 函数给诊断和逻辑解码工具更适合的 64-bit 表达;它仍不是跨 cluster、跨恢复周期的订单 ID。业务标识继续使用 ch04 的 PK/业务键。

snapshot 是边界加活跃集合

pg_current_snapshot() 的文本形状为:

xmin:xmax:xip_list

例如 959:959: 表示:

  • snapshot xmin=959:当时最老的活跃 top-level XID 边界;
  • snapshot xmax=959:大于或等于这个上界的 XID 在快照时还不能视为已完成可见;
  • xip_list 为空:在两个边界之间没有额外列出的活跃 top-level XID。

这不是“所有已提交事务清单”。对某个 tuple,系统还要结合插入/删除 XID 的提交状态、当前事务自身、command ID、MultiXact 与 hint/frozen 状态判断。snapshot 也只列 top-level XID,不直接列每个 subtransaction。

可下载的 observe.sql把同一 snapshot 保存后分别用:

pg_snapshot_xmin(snapshot)
pg_snapshot_xmax(snapshot)
pg_snapshot_xip(snapshot)

解析,避免在应用里用字符串切割重造规则。snapshot 导出/导入还有严格的事务与隔离级别条件,本章不把它扩展成分布式一致性方案。

5.2.2 活跃、提交、中止与可见性判断

tuple header 记录“谁创建/结束版本”,transaction status 记录这些事务最终是 in progress、committed 还是 aborted,snapshot 则记录观察时刻的并发边界。可以先用一张非实现代码的判断图建立直觉:

    flowchart TD
  A["候选 tuple version"] --> B{"插入者是自己?"}
  B -- "是" --> C["结合 command ID 判断<br/>本事务内是否已经发生"]
  B -- "否" --> D{"插入 XID 已提交<br/>且在 snapshot 可见?"}
  D -- "否" --> X["不可见"]
  D -- "是" --> E{"该版本是否被<br/>已提交且可见的事务结束?"}
  E -- "是" --> X
  E -- "否" --> V["可见"]
  

真实实现还要处理 aborted XID、subtransaction、MultiXact、frozen tuple 和多种 infomask;这张图只用于解释责任分工,不能复制成应用端 visibility algorithm。

三种事务状态会留下不同结果

  • in progress:普通并发读不能看到它尚未提交的 tuple version;
  • committed:是否可见还取决于 snapshot 是否足够新;
  • aborted:它写出的版本不成为正常可见数据,但空间和 WAL 工作不会凭空消失。

同一事务总能在适当 command boundary 后看到自己的写入,否则无法执行“插入后查询、再更新”。这由 command ID 与特殊的 self-visibility 规则配合完成,不意味着别的会话也可见。

在默认 Read Committed 中,每个 command 获取新 snapshot。因此事务 A 的两个普通 SELECT 之间若事务 B 提交,第二个查询可以看到新版本。在 Repeatable Read/Serializable 中,普通读取使用 transaction snapshot,不会因 B 后来提交而更新自己的视图。隔离级别改变的是 snapshot 生命周期和冲突处理,不是把 heap 换成另一套存储。

不要从 header 单字段猜提交状态

下面这些推论都不成立:

  • xmin 小,所以一定 committed;
  • xmax=0,所以永远没有锁;
  • xmax<>0,所以该行已经删除;
  • ctid 没变,所以没有并发更新;
  • 当前 XID 比 tuple XID 大,所以可见。

例如行锁也可能写 xmax/MultiXact;aborted 删除会留下非零信息;VACUUM/freezing 与 hint bits 又会改变内部表示。需要调查某个 XID 时,可以在受控诊断中使用 pg_xact_status(xid8) 等官方函数并注明版本和保留窗口,但应用正确性不能依赖旧 XID 状态永远可查。

另一个边界是 sequence:nextval 的推进不随调用事务 rollback。这是为了并发与唯一分配效率,因此 identity/sequence 允许 gap。不要拿“订单行回滚了但 ID 跳号”反驳事务原子性;序列值本就有专门的非事务语义。

5.2.3 读不阻塞写的条件与代价

PostgreSQL 文档常用“reading never blocks writing and writing never blocks reading”概括 MVCC。工程上应把它展开为:在 primary 上,普通 heap SELECT 不与同一行的 row-level write lock 冲突;读取者可见旧的 committed version,写者创建新版本。

这不意味着任意读永远不会等:

  • SELECT ... FOR UPDATE/SHARE 主动成为 row locker;
  • 普通 SELECTACCESS SHARE table lock 会被 ACCESS EXCLUSIVE DDL/maintenance 阻塞;
  • query 可能等待 I/O、LWLock、buffer pin、WAL/IPC、parallel worker 或客户端;
  • standby 查询可能与 recovery cleanup 冲突;
  • CPU、buffer cache 和存储带宽仍会形成资源争用。

因此“读不阻塞写”是 lock compatibility 的精确性质,不是无延迟承诺。

本章实验中的两个并发结果

blocker 对订单 1002 执行未提交 UPDATE 后停在 pg_sleep。此时:

blocker:
  backend_xid=962
  wait_event_type=Timeout
  wait_event=PgSleep

ordinary reader:
  statement_timeout=1s
  result=旧的已提交 request_fingerprint

second writer:
  state=active
  wait_event_type=Lock
  wait_event=transactionid
  pg_blocking_pids={blocker_pid}

普通 reader 没去读取 blocker 的脏版本,而是从版本链找到旧 committed tuple;second writer 不能同时决定同一逻辑行的下一版本,所以等待 blocker XID 完成。这正是“read concurrency 高、write conflict 仍需排序”的组合。

代价落在空间、WAL 与清理

更新旧版本不会立即消失,因为仍可能有旧 snapshot 需要它。结果包括:

  • heap 中产生 obsolete/dead tuple,需要 VACUUM 标记空间可重用;
  • indexes 可能增加新 entry,取决于 HOT 条件和 indexed columns;
  • 写入与回滚都可能产生 WAL、dirty buffers 和统计计数;
  • 长事务/长 snapshot 提高全局 xmin horizon,延迟 dead tuple 清理;
  • autovacuum 既要回收空间,也要维护 visibility map 和防止 XID wraparound;
  • index-only scan 是否免 heap fetch 还依赖 visibility map。

所以生产上“没有锁等待但表越来越大”并不矛盾。MVCC 把读写冲突转成版本管理工作; 第 28 章会专门治理 VACUUM、freeze 和 bloat。

用三个问题判断所谓 MVCC 问题

  1. 当前会话在等什么?statewait_event_typewait_event,不要先猜锁。
  2. 谁阻碍了清理 horizon? 看 transaction age、backend_xmin、replication slot/standby feedback 等证据。
  3. 版本制造速度和回收速度是否失衡? 看 tuple change、dead tuple、autovacuum、WAL 与 relation size 趋势。

把所有性能问题笼统叫“MVCC 膨胀”不会产生动作。现象必须落到 version churn、oldest snapshot、vacuum progress、lock/wait 或 I/O 中的一项。


上一节:SQL 从文本到结果 · 返回本章目录 · 下一节:事务边界与失败语义 · 查看全书目录 · 查看索引中心

最后更新于