欢迎访问 内存溢出 —— 独立后端与系统工程技术博客

专注于分布式系统、Linux内核、数据库存储与后端工程实践

PostgreSQL MVCC 多版本并发控制与 Vacuum 空间回收机制

发表于:|最后更新:|分类:数据库与存储|阅读:3915 次

与 MySQL InnoDB 采用回滚段(Undo Log)在原地构造历史版本的设计不同,PostgreSQL 直接在数据表页(Heap Page)中保留同一行数据的多个物理版本(Tuple)。这种设计使得回滚事务几乎可以瞬时完成,且无需遍历长链条的 Undo 记录,但也引入了“死元组(Dead Tuples)”、表膨胀(Bloat)以及事务号回卷(Transaction ID Wraparound)等运维挑战。

一、Heap Tuple 物理结构与隐藏系统列

在 PostgreSQL 中,每一行记录在磁盘页内都由 HeapTupleHeaderData 头部和实际用户列数据组成。可以通过显式查询系统列来观察它们的内部状态:

sql
SELECT ctid, xmin, xmax, cmin, cmax, id, balance
FROM accounts
WHERE id = 1001;

各核心隐藏字段的含义如下:

  • ctid:当前元组在表中的物理位置 (块号, 页内偏移号),例如 (0, 1) 表示第 0 号数据页的第 1 条记录。当发生 UPDATE 时,新元组的 ctid 会发生变化。
  • xmin:插入该物理元组的事务 ID(XID)。
  • xmax:删除或更新该物理元组的事务 ID。如果该行尚未被删除或更新,xmax 为 0。
  • t_infomask:16 位标志位(Hint Bits),缓存了 xmin 和 xmax 对应事务的提交或回滚状态(如 HEAP_XMIN_COMMITTED),避免每次读取都去查询 pg_xact(原 pg_clog)。

二、DELETE 与 UPDATE 在底层的真实动作

理解 PostgreSQL MVCC 的关键在于:UPDATE 在物理层面等价于一次标记旧行加一次插入新行。

  1. 执行 INSERT:分配新元组,设置 xmin = 当前事务 XID,xmax = 0。
  2. 执行 DELETE:不会立即从数据页抹除记录,而是将目标元组的 xmax 设置为当前删除事务的 XID。
  3. 执行 UPDATE:
    • 将旧版本元组的 xmax 设为当前事务 XID,并将其 t_ctid 指向新版本元组的位置;
    • 在同一个数据页(若空间充足,可触发 HOT 更新)或新数据页插入一条新元组,新元组的 xmin 设为当前事务 XID,xmax = 0。
操作类型 旧版本元组变化 新版本元组变化 索引是否需要更新
INSERT 无 xmin = cur_xid, xmax = 0 必须插入所有索引项
DELETE xmax = cur_xid 无 Vacuum 阶段批量清理
UPDATE (普通) xmax = cur_xid xmin = cur_xid, xmax = 0 所有索引需新增指向新 ctid 的条目
UPDATE (HOT) xmax = cur_xid,构成页内链 页内新增,不改索引列 无需修改索引,沿用页内跟指针跳转

三、事务快照(Snapshot)与可见性判断规则

当事务在 READ COMMITTED(每条语句获取一次快照)或 REPEATABLE READ(事务首条语句获取一次快照)下运行时,PostgreSQL 会生成一个形如 xmin:xmax:xip_list 的事务快照:

  • Snapshot.xmin:当前所有活跃(未提交)事务中最小的 XID。凡是 XID 小于该值的事务均已结束。
  • Snapshot.xmax:当前最新分配的 XID + 1。凡是 XID 大于或等于该值的事务在快照生成时尚未启动,一律不可见。
  • Snapshot.xip_list:快照生成时刻处于活跃状态的事务 XID 列表。

对于任意一个物理元组,若满足以下条件则对当前快照可见:

  1. Tuple.xmin 对应事务已提交,且在当前快照看来不是活跃事务或未来事务;
  2. Tuple.xmax == 0,或者 Tuple.xmax 对应的删除/更新事务已回滚,或者 Tuple.xmax 在当前快照看来尚未提交。

四、HOT 更新(Heap-Only Tuple)与 Fillfactor 调优

在频繁更新非索引字段的业务表(例如订单状态表、设备心跳表)中,如果每次 UPDATE 都修改所有 B-Tree 索引,写放大将非常严重。

PostgreSQL 提供了 HOT(Heap-Only Tuple) 机制:当被更新的列不包含任何索引列,且当前 8KB 数据页内仍有剩余空间存放新版本元组时,索引继续指向旧元组的页内项(Line Pointer),由页内链条通过 t_ctid 直接跳转到新元组。

sql
-- 为高频更新表预留 20% 的页内空间以最大化触发 HOT 更新
ALTER TABLE device_heartbeat SET (
    fillfactor = 80,
    autovacuum_vacuum_scale_factor = 0.02,
    autovacuum_vacuum_cost_limit = 1000
);

生产经验:对于行数超过千万的大表,默认的 autovacuum_vacuum_scale_factor = 0.2(即死元组达到 20% 才触发清理)往往过晚。建议针对核心写密集表单独下调至 0.01~0.05,或者直接按固定行数阈值 autovacuum_vacuum_threshold 触发。