与 MySQL InnoDB 采用回滚段(Undo Log)在原地构造历史版本的设计不同,PostgreSQL 直接在数据表页(Heap Page)中保留同一行数据的多个物理版本(Tuple)。这种设计使得回滚事务几乎可以瞬时完成,且无需遍历长链条的 Undo 记录,但也引入了“死元组(Dead Tuples)”、表膨胀(Bloat)以及事务号回卷(Transaction ID Wraparound)等运维挑战。
一、Heap Tuple 物理结构与隐藏系统列
在 PostgreSQL 中,每一行记录在磁盘页内都由 HeapTupleHeaderData 头部和实际用户列数据组成。可以通过显式查询系统列来观察它们的内部状态:
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 在物理层面等价于一次标记旧行加一次插入新行。
- 执行
INSERT:分配新元组,设置xmin = 当前事务 XID,xmax = 0。 - 执行
DELETE:不会立即从数据页抹除记录,而是将目标元组的xmax设置为当前删除事务的 XID。 - 执行
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 列表。
对于任意一个物理元组,若满足以下条件则对当前快照可见:
Tuple.xmin对应事务已提交,且在当前快照看来不是活跃事务或未来事务;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 直接跳转到新元组。
-- 为高频更新表预留 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触发。