mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
4669 字
13 分钟
PostgreSQL MVCC 与 VACUUM
2024-07-18

PostgreSQL 和 MySQL 都实现了 MVCC,但路径截然不同。MySQL InnoDB 把旧版本塞进 Undo Log,数据页只存最新版;PostgreSQL 选择在堆表中直接保留所有版本,UPDATE 等于标记旧行加插入新行。这一选择带来连锁反应:不需要 Undo Log,但必须有 VACUUM 回收死元组;不支持原地更新,但发明了 HOT 更新来缓解索引膨胀。

本文把 PostgreSQL 的多版本机制从头拆到尾:xmin/xmax 如何标记版本、Clog 如何记录事务状态、快照如何界定可见性边界、VACUUM 如何回收空间、HOT 更新如何避免索引维护。每个环节都会和 MySQL 的 Undo Log MVCC 做对比,两种方案的取舍差异会非常清晰。索引类型和代价估计器是另一个大话题,见 PostgreSQL 高级索引

前置知识#

Important
  • 了解 MVCC 的通用理论框架与 ReadView 可见性判断,参见 MySQL MVCC 原理

  • 了解事务隔离级别与快照读的概念,有助于理解 PostgreSQL 的快照获取时机

一、PostgreSQL 架构概述#

PostgreSQL 采用**每连接一进程(Process-per-Connection)**模型,与 MySQL 的线程模型不同。客户端发起连接时,Postmaster 主进程 fork 一个后端进程专门服务该连接。这种模型隔离性好,一个后端进程崩溃不会影响其他连接,Postmaster 会检测到并清理。代价是进程比线程更重,连接数上千时内存开销显著,因此社区强烈推荐使用连接池(如 PgBouncer)。

PostgreSQL 启动时向操作系统申请一大块共享内存,所有后端进程和辅助进程共享访问。其中几个关键区域:

  • Shared Buffers:数据页缓存,避免频繁磁盘 I/O,建议设置为系统内存的 25%
  • WAL Buffer:WAL(Write-Ahead Log)记录的写入缓冲
  • Clog(Commit Log):记录事务状态(已提交/已回滚/进行中),磁盘上对应 pg_xact/ 目录,活跃数据常驻共享内存
  • ProcArray:所有活跃后端进程的 xmin 快照,用于快照可见性判断

后台辅助进程各司其职:BgWriter 刷脏页、WalWriter 刷 WAL、Checkpointer 打检查点、AutoVacuum 自动清理死元组。统计信息自 PostgreSQL 14 起不再由独立子进程收集,改由后端进程直接在共享内存中累积。其中 AutoVacuum 是 PostgreSQL 区别于 MySQL 的标志性进程,后文会详细展开。

一个查询从客户端发送到结果返回,经历解析(Parser)、分析(Analyzer)、规划(Planner)、执行(Executor)四步。规划器是 PostgreSQL 的”大脑”,基于统计信息和代价估计器选择最优执行路径,这部分留到 高级索引篇 展开。本文聚焦的是数据存储层:元组如何组织、版本如何管理、死元组如何回收。

二、MVCC 实现#

2.1 Tuple 结构:xmin / xmax / ctid#

PostgreSQL 的堆表(Heap Table)中,每一行数据称为一个元组(Tuple)。每个元组的头部(HeapTupleHeaderData)包含三个关键字段:

src/include/access/htup_details.h
typedef struct HeapTupleHeaderData {
TransactionId t_xmin; // 插入该元组的事务ID
TransactionId t_xmax; // 删除或更新该元组的事务ID
CommandId t_cid; // 插入/删除该元组的命令ID(同事务内)
ItemPointerData t_ctid; // 指向新版本元组的指针(更新时)
// ... 其他字段
} HeapTupleHeaderData;

三个字段的含义:

字段含义何时设置
t_xmin创建该元组的事务 IDINSERT 时设为当前事务 ID
t_xmax删除/更新该元组的事务 IDDELETE 时设为当前事务 ID;UPDATE 时设为旧版本的当前事务 ID
t_ctid指向该元组的最新版本UPDATE 时旧版本指向新版本(block + offset)

pageinspect 扩展可以直接看到元组头部:

-- 安装扩展
CREATE EXTENSION IF NOT EXISTS pageinspect;
-- 假设表 test 的数据在 0 号数据块
SELECT lp_off, t_xmin, t_xmax, t_ctid
FROM heap_page_items(get_raw_page('test', 0));
-- 示例输出:
-- lp_off | t_xmin | t_xmax | t_ctid
-- --------+--------+--------+--------
-- 816 | 100 | 0 | (0,1) -- 由事务100插入,未被修改
-- 800 | 100 | 102 | (0,3) -- 由事务100插入,被事务102更新,新版本在(0,3)
-- 784 | 102 | 0 | (0,3) -- 由事务102插入(更新的新版本)

2.2 Clog:事务状态记录#

Clog(Commit Log)记录每个事务的最终状态,是 MVCC 可见性判断的基础:

src/include/access/xact.h
typedef enum {
TRANSACTION_STATUS_IN_PROGRESS = 0x00, // 进行中
TRANSACTION_STATUS_COMMITTED = 0x01, // 已提交
TRANSACTION_STATUS_ABORTED = 0x02, // 已回滚
TRANSACTION_STATUS_SUB_COMMITTED = 0x03 // 子事务已提交
} XidStatus;

Clog 在磁盘上以 8KB 页为单位存储在 pg_xact/ 目录中,每个事务仅占 2 bit。活跃的 Clog 页常驻共享内存,判断事务状态时通常不需要磁盘 I/O。这是 PostgreSQL 不需要 Undo Log 的关键:事务的提交状态不在数据页里,而在独立的 Clog 中,元组头部只记事务 ID,可见性判断时再查 Clog。

2.3 SnapshotData:快照#

快照(Snapshot)是某一时刻所有活跃事务的”照片”,用于判断元组对当前事务是否可见:

// 简化的快照结构
typedef struct SnapshotData {
TransactionId xmin; // 当前所有活跃事务中最小的事务ID
TransactionId xmax; // 下一个将分配的事务ID
TransactionId *xip; // 活跃事务ID列表(xmin ~ xmax 之间)
uint32 xcnt; // 活跃事务数量
} SnapshotData;

获取快照时,PostgreSQL 遍历 ProcArray(所有后端进程的当前事务信息),收集所有正在执行的事务 ID,构建 xip 数组。在 Read Committed 隔离级别下,每条 SQL 语句都会获取新快照;在 Repeatable Read 下,事务的第一条 SQL 获取快照后整个事务复用。这与 MySQL InnoDB 的 ReadView 生成时机一致,区别只在于版本链的组织方式。

2.4 可见性判断规则#

给定一个元组和快照,可见性判断遵循以下规则:

flowchart TD START["判断元组是否可见"] --> XMIN{"t_xmin 的状态?"} XMIN -->|"进行中"| INV1["不可见<br/>事务未提交"] XMIN -->|"已回滚"| INV2["不可见<br/>事务已撤销"] XMIN -->|"已提交"| XMIN_VIS{"t_xmin &lt; snapshot.xmin<br/>或不在 xip 中?"} XMIN_VIS -->|"否"| INV3["不可见<br/>插入事务在快照中仍活跃"] XMIN_VIS -->|"是"| XMAX{"t_xmax == 0?"} XMAX -->|"是"| V1["可见<br/>元组未被删除/更新"] XMAX -->|"否"| XMAX_STATUS{"t_xmax 的状态?"} XMAX_STATUS -->|"进行中"| V2["可见<br/>删除事务尚未提交"] XMAX_STATUS -->|"已回滚"| V3["可见<br/>删除事务已撤销"] XMAX_STATUS -->|"已提交"| XMAX_VIS{"t_xmax &lt; snapshot.xmin<br/>或不在 xip 中?"} XMAX_VIS -->|"否"| V4["可见<br/>删除事务在快照中仍活跃"] XMAX_VIS -->|"是"| INV4["不可见<br/>元组已被删除/更新"] style V1 fill:#c8e6c9,stroke:#2e7d32 style V2 fill:#c8e6c9,stroke:#2e7d32 style V3 fill:#c8e6c9,stroke:#2e7d32 style V4 fill:#c8e6c9,stroke:#2e7d32 style INV1 fill:#ffcdd2,stroke:#c62828 style INV2 fill:#ffcdd2,stroke:#c62828 style INV3 fill:#ffcdd2,stroke:#c62828 style INV4 fill:#ffcdd2,stroke:#c62828

简化口诀:元组可见 = 插入事务在快照前已提交 AND(未删除 OR 删除事务在快照中仍活跃或已回滚)

与 MySQL InnoDB 的可见性判断对比:InnoDB 从版本链最新版本开始,沿 roll_ptr 回溯,用 ReadView 的 m_ids 判断每个版本的 trx_id 是否可见。PostgreSQL 的判断对象是堆表中并存的每个元组,用快照的 xmin/xmax/xip 判断。两者的算法逻辑相似,根本差异在于版本存储位置:InnoDB 的旧版本在 Undo Log 里,PostgreSQL 的旧版本直接在堆表里。

2.5 与 MySQL Undo Log MVCC 对比#

PostgreSQL 和 MySQL 都实现了 MVCC,但实现路径截然不同:

维度PostgreSQLMySQL InnoDB
多版本存储堆表中直接保留旧版本(Append-Only)Undo Log 中保留旧版本,数据页只存最新版
更新方式INSERT 新版本 + 标记旧版本 xmax原地更新数据页 + 写 Undo Log
回滚机制无 Undo Log,依赖 Clog 判断可见性从 Undo Log 重建旧版本
空间回收VACUUM 扫描清理死元组Undo Log 段自动清理
回滚段膨胀无回滚段,但堆表膨胀长事务导致 Undo Log 膨胀
读旧版本直接读堆表中的旧元组从 Undo Log 链重建旧版本
热点数据页多版本共存导致页分裂频繁数据页始终是最新版,更紧凑
Warning

PostgreSQL 的 Append-Only MVCC 有一个显著缺点:频繁更新的表会产生大量死元组,导致表膨胀(Bloat)。如果 VACUUM 跟不上更新速度,查询性能会急剧下降。这是 PostgreSQL 运维中最常见的问题之一,下一节详细讨论。

关于 MySQL MVCC 的 Undo Log 版本链与 ReadView 可见性判断的完整算法,见 MySQL MVCC 原理。那篇文章也提到了 PostgreSQL 的 Append-Only 方案作为对比,本文则从 PostgreSQL 视角把这条路径讲透。

三、VACUUM#

3.1 Dead Tuple 的产生#

在 PostgreSQL 中,UPDATE 和 DELETE 不会立即回收旧版本的空间:

-- 事务 T1:插入一行
INSERT INTO accounts (id, balance) VALUES (1, 1000);
-- 堆表中产生元组:(t_xmin=100, t_xmax=0, balance=1000)
-- 事务 T2:更新该行
UPDATE accounts SET balance = 2000 WHERE id = 1;
-- 堆表中现在有两个元组:
-- 旧版本:(t_xmin=100, t_xmax=102, t_ctid=(0,2)) <- Dead Tuple
-- 新版本:(t_xmin=102, t_xmax=0, balance=2000) <- 当前可见版本
-- 事务 T3:删除该行
DELETE FROM accounts WHERE id = 1;
-- 新版本也变成 Dead Tuple:
-- (t_xmin=102, t_xmax=103, t_ctid=(0,2)) <- Dead Tuple

**Dead Tuple(死元组)**是指对所有当前和未来快照都不可见的元组。它们占据磁盘空间却无法被任何查询访问,必须由 VACUUM 回收。这是 Append-Only MVCC 的必然代价:旧版本直接留在堆表中,没有 Undo Log 自动清理机制,必须靠外部进程主动回收。

MySQL InnoDB 的 Purge 线程做类似的事,但它清理的是 Undo Log 段,不影响数据页布局。PostgreSQL 的 VACUUM 则要直接操作堆表页面,复杂度更高。

3.2 AutoVacuum 触发条件#

PostgreSQL 的 AutoVacuum 守护进程定期检查各表是否需要清理。触发条件基于两个阈值:

-- 查看 AutoVacuum 相关参数
SHOW autovacuum_vacuum_threshold; -- 默认 50
SHOW autovacuum_vacuum_scale_factor; -- 默认 0.2(20%)
SHOW autovacuum_analyze_threshold; -- 默认 50
SHOW autovacuum_analyze_scale_factor;-- 默认 0.1(10%)
-- VACUUM 触发条件:
-- dead_tuples > autovacuum_vacuum_threshold +
-- autovacuum_vacuum_scale_factor * reltuples
-- 即:死元组数 > 50 + 20% × 表行数
-- ANALYZE 触发条件:
-- changed_tuples > autovacuum_analyze_threshold +
-- autovacuum_analyze_scale_factor * reltuples
-- 即:变更行数 > 50 + 10% × 表行数

对于大表(如 1 亿行),默认 20% 的阈值意味着需要积累 2000 万死元组才触发 VACUUM,这往往太迟了。2000 万死元组意味着查询时要跳过大量无效行,索引扫描也要回查更多堆表页。生产环境通常需要调低 scale_factor

-- 对频繁更新的大表设置更激进的 VACUUM 策略
ALTER TABLE hot_table SET (
autovacuum_vacuum_scale_factor = 0.05, -- 5% 死元组即触发
autovacuum_analyze_scale_factor = 0.02 -- 2% 变更即更新统计信息
);

3.3 VACUUM 流程#

flowchart TD START["AutoVacuum 触发"] --> SCAN["扫描表的每一页"] SCAN --> CHECK{"发现 Dead Tuple?"} CHECK -->|"否"| NEXT["下一页"] CHECK -->|"是"| MARK["标记 Dead Tuple<br/>在 fsm 中记录可用空间"] MARK --> REFRACT["更新 fsm(空闲空间映射)<br/>和 vm(可见性映射)"] REFRACT --> NEXT NEXT --> MORE{"还有更多页?"} MORE -->|"是"| SCAN MORE -->|"否"| TRUNCATE{"末尾页全空?"} TRUNCATE -->|"是"| TRUNC["TRUNCATE 空页<br/>将磁盘空间归还操作系统"] TRUNCATE -->|"否"| DONE["VACUUM 完成"] TRUNC --> DONE style START fill:#e3f2fd,stroke:#1565c0 style MARK fill:#fff3e0,stroke:#e65100 style TRUNC fill:#e8f5e9,stroke:#2e7d32 style DONE fill:#c8e6c9,stroke:#2e7d32

VACUUM 的核心步骤:

  1. 扫描:遍历堆表页面,识别 Dead Tuple
  2. 标记:将 Dead Tuple 的空间标记为可用,更新 FSM(Free Space Map)
  3. 更新 VM:更新可见性映射(Visibility Map),标记全干净的页
  4. 截断:如果文件末尾的页完全为空,截断文件释放磁盘空间
Tip

可见性映射(Visibility Map, VM)是 VACUUM 的加速器。VM 中每个数据页占 1 bit,标记该页是否”全部元组对所有人可见”。VACUUM 可以跳过 VM 标记为干净的页,大幅减少扫描量。索引扫描也能利用 VM 跳过不必要的堆表回查(Index-Only Scan),这在 高级索引篇 中会提到。

3.4 VACUUM FULL vs LAZY#

特性VACUUM (LAZY)VACUUM FULL
锁类型共享锁,不阻塞读写排他锁,阻塞所有操作
空间处理标记空间可重用,不归还操作系统重写整表,归还空间给操作系统
执行速度快,增量处理慢,全表重写
额外空间不需要需要约等于表大小的临时空间
索引处理不重建索引重建所有索引
适用场景日常维护,AutoVacuum严重膨胀后的紧急修复
-- 日常维护:使用普通 VACUUM(不阻塞)
VACUUM accounts;
-- 紧急修复:使用 VACUUM FULL(阻塞 + 重写)
VACUUM FULL accounts;
-- 更好的替代方案:pg_repack(在线重建,不阻塞)
-- 需要安装扩展:CREATE EXTENSION pg_repack;
-- pg_repack -d mydb -t accounts;

3.5 参数调优#

-- 核心 VACUUM 调优参数
autovacuum_max_workers = 4 -- AutoVacuum 工作进程数(默认 3)
autovacuum_naptime = 30s -- 检查间隔(默认 1min)
autovacuum_vacuum_cost_limit = 2000 -- 每轮 I/O 限额(默认 -1,表示继承 vacuum_cost_limit 的值)
autovacuum_vacuum_cost_delay = 2ms -- 达到限额后的休眠时间(默认 2ms,PG11 及更早为 20ms)
-- 手动 VACUUM 的 I/O 节流
vacuum_cost_limit = 200 -- 每轮 I/O 限额
vacuum_cost_delay = 0 -- 默认不休眠(手动执行优先级高)

vacuum_cost_limit 是 VACUUM 的”油门”,它限制每轮 VACUUM 的 I/O 开销,避免 VACUUM 占满磁盘带宽影响业务查询。达到限额后 VACUUM 会休眠 vacuum_cost_delay 毫秒,然后继续。AutoVacuum 默认比手动 VACUUM 更温和(延迟更长),这是为了避免在业务高峰期抢 I/O。

四、HOT 更新#

4.1 Heap Only Tuple 原理#

PostgreSQL 的 UPDATE 是”删除旧版本 + 插入新版本”,这意味着每次更新都会产生一条新的索引条目。如果表有 5 个索引,一次更新就要写 5 条新索引记录。这在频繁更新的场景下极其低效。

HOT(Heap Only Tuple)更新是 PostgreSQL 的优化方案:当新版本和旧版本在同一个数据页中,且更新的列不被任何索引引用时,不需要更新索引。索引仍然指向旧版本,通过旧版本的 t_ctid 链找到新版本。

flowchart LR subgraph BEFORE["更新前"] IDX1["索引条目<br/>key=1 -> (0,1)"] TUPLE1["旧版本 (0,1)<br/>t_xmin=100<br/>t_xmax=0<br/>name='Alice'<br/>age=30"] IDX1 --> TUPLE1 end subgraph AFTER["HOT 更新后(age 从 30 到 31)"] IDX2["索引条目<br/>key=1 -> (0,1)<br/>(未变化!)"] TUPLE2["旧版本 (0,1)<br/>t_xmin=100<br/>t_xmax=102<br/>t_ctid=(0,2)<br/>name='Alice'<br/>age=30"] TUPLE3["新版本 (0,2)<br/>t_xmin=102<br/>t_xmax=0<br/>name='Alice'<br/>age=31"] IDX2 --> TUPLE2 TUPLE2 -->|"t_ctid 链"| TUPLE3 end BEFORE --> AFTER style IDX2 fill:#c8e6c9,stroke:#2e7d32 style TUPLE3 fill:#c8e6c9,stroke:#2e7d32

4.2 HOT 的触发条件#

HOT 更新必须同时满足两个条件:

  1. 新版本与旧版本在同一数据页:PostgreSQL 在更新时会优先尝试在同一页中分配空间。如果页已满,则退化为普通更新。
  2. 更新的列不被任何索引引用:如果更新了索引列,索引条目必须更新,HOT 无法跳过。
-- 查看表的 HOT 更新统计
SELECT n_tup_ins, n_tup_upd, n_tup_hot_upd,
round(n_tup_hot_upd::numeric / NULLIF(n_tup_upd, 0) * 100, 2) AS hot_ratio
FROM pg_stat_user_tables
WHERE relname = 'accounts';
-- 示例输出:
-- n_tup_ins | n_tup_upd | n_tup_hot_upd | hot_ratio
-- -----------+-----------+---------------+-----------
-- 10000 | 8000 | 7200 | 90.00

90% 的 HOT 比率说明大部分更新都走了 HOT 路径。如果 HOT 比率低,可以考虑:

  • 增大 fillfactor(默认 100%,留出空间给 HOT 更新)
  • 避免更新索引列
-- 设置 fillfactor 为 80%,预留 20% 页空间给 HOT 更新
ALTER TABLE accounts SET (fillfactor = 80);
VACUUM FULL accounts; -- 需要重建表使 fillfactor 生效

fillfactor 的原理是:每个数据页只填满 80%,留 20% 空闲空间。当 UPDATE 发生时,新版本可以在这个空闲空间中分配,从而满足”新版本与旧版本同页”的条件。代价是表会占用更多磁盘空间,但对于频繁更新的表,这个空间换来了 HOT 命中率的提升,非常划算。

4.3 与 MySQL 原地更新对比#

维度PostgreSQL HOT 更新MySQL InnoDB 原地更新
更新方式插入新版本 + ctid 链直接修改数据页中的行
索引更新HOT 时不更新索引始终需要更新索引(如果索引列变化)
Undo Log不需要必须写 Undo Log
空间效率同页内新旧版本共存,页利用率下降原地更新,空间紧凑
适用条件同页 + 非索引列更新任何更新
回滚旧版本仍在堆表中从 Undo Log 重建

五、PostgreSQL vs MySQL:MVCC 与并发控制#

MySQL MVCC 原理中,我们详细分析了 InnoDB 的 Undo Log 版本链实现。以下从 MVCC 和并发控制维度对比两个数据库的核心差异。

5.1 存储引擎与 MVCC#

维度PostgreSQLMySQL InnoDB
存储引擎统一存储引擎(Heap Table)可插拔存储引擎(InnoDB/MyISAM/etc.)
MVCC 实现堆表多版本(xmin/xmax)Undo Log 回滚段
更新方式Append-Only(INSERT 新版本)原地更新 + Undo Log
回滚无 Undo Log,依赖 ClogUndo Log 链
垃圾回收VACUUM(手动/自动)Undo Log 自动清理
表膨胀风险高(VACUUM 不及时)低(Undo 自动管理)
长事务影响阻止 VACUUM 回收死元组Undo Log 膨胀

长事务在两个数据库里都会拖慢垃圾回收,但机制不同。PostgreSQL 的长事务持有快照,快照的 xmin 之前的死元组不能被 VACUUM 回收,因为对该事务可能仍可见。MySQL 的长事务持有的 ReadView 会阻止 Purge 线程清理对应的 Undo Log。排查方法也不同:PostgreSQL 查 pg_stat_activity 找长事务,MySQL 查 information_schema.innodb_trx

5.2 并发控制#

维度PostgreSQLMySQL InnoDB
默认隔离级别Read CommittedRepeatable Read
RR 实现快照(首次读时获取)Gap Lock + Next-Key Lock
SerializableSSI(Serializable Snapshot Isolation)两阶段锁(2PL)
DDL 锁MVCC 式 DDL(ALTER 不阻塞读)MDL 锁(ALTER 阻塞读写)
死锁检测自动检测 + 回滚代价小的事务自动检测 + 回滚代价小的事务

PostgreSQL 的 Serializable 用的是 SSI(Serializable Snapshot Isolation),在 MVCC 快照基础上检测写偏序(Write Skew)冲突,只读事务永远不会被阻塞。MySQL 的 SERIALIZABLE 走的是传统两阶段锁路径,所有读加共享锁。MySQL MVCC 原理 的末尾详细讲了 SSI 的算法,这里不重复。

六、踩坑与运维#

6.1 表膨胀导致查询变慢#

表膨胀是 PostgreSQL 最常见的性能问题。Append-Only MVCC 下,频繁 UPDATE/DELETE 产生死元组,如果 AutoVacuum 跟不上,死元组积累,查询要扫描大量无效行,I/O 放大严重。

典型症状:同样的查询,数据量没怎么涨,但 EXPLAIN ANALYZE 显示 Seq Scan 上的 actual time 越来越高,Buffers: shared read 数也异常大。这时候第一件事是查死元组率:

-- 查看各表的死元组情况
SELECT relname,
n_live_tup,
n_dead_tup,
round(n_dead_tup::numeric / NULLIF(n_live_tup, 0) * 100, 2) AS dead_ratio
FROM pg_stat_user_tables
ORDER BY dead_ratio DESC;
-- 示例输出:
-- relname | n_live_tup | n_dead_tup | dead_ratio
-- -----------+------------+------------+------------
-- orders | 1000000 | 350000 | 35.00 <- 膨胀严重
-- users | 500000 | 2000 | 0.40

dead_ratio 超过 10% 就该警惕。但看绝对值更重要:一张 1 亿行的大表,即使 dead_ratio 只有 5%,也有 500 万死元组,查询时要额外扫描这些行。

Warning

死元组率持续居高不下,通常有两个原因:一是 AutoVacuum 触发阈值太高(默认 scale_factor=0.2 对大表太宽松),二是有长事务持有旧快照阻止回收。排查长事务:

-- 查找运行时间最长的事务
SELECT pid, state, xact_start,
now() - xact_start AS duration,
query
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY duration DESC;
-- 查找阻止 VACUUM 的最小事务 ID(horizon)
-- 如果这个值很旧,说明有长事务卡住了 VACUUM

6.2 VACUUM 跟不上更新速度#

当表的写入速度超过 AutoVacuum 的回收速度时,死元组会持续积累。常见于高并发写入的订单表、状态频繁变更的业务表。解决方案是针对具体表调参:

-- 对高频更新的表,设置更激进的 AutoVacuum 参数
ALTER TABLE orders SET (
autovacuum_vacuum_scale_factor = 0.05, -- 5% 死元组即触发
autovacuum_vacuum_threshold = 1000, -- 基础阈值提高到 1000
autovacuum_vacuum_cost_limit = 1000, -- 提高该表的 I/O 限额
autovacuum_vacuum_cost_delay = 1 -- 降低该表的休眠延迟
);

PostgreSQL 13+ 支持 autovacuum_vacuum_insert_thresholdautovacuum_vacuum_insert_scale_factor,可以针对纯 INSERT 场景单独触发 VACUUM(主要是为了维护 BRIN 索引和可见性映射)。

如果 AutoVacuum 实在跟不上,可以手动跑 VACUUM(不带 FULL),手动 VACUUM 默认 cost_delay=0,不节流,速度比 AutoVacuum 快很多。但要注意它会占 I/O,别在业务高峰跑。

6.3 VACUUM FULL 的代价与替代方案#

表膨胀严重时,普通 VACUUM 只能标记空间可重用,不能把空间还给操作系统。VACUUM FULL 可以重写整表归还空间,但它要加排他锁,阻塞所有读写,大表上执行可能几小时。生产环境几乎不能用。

替代方案是 pg_repack 扩展,它在后台创建影子表、同步数据、用触发器捕获增量、最后原子切换,全程不阻塞业务。使用前提是表必须有主键或唯一索引:

-- 安装扩展
CREATE EXTENSION pg_repack;
-- 命令行执行
-- pg_repack -d mydb -t orders

pg_repack 的代价是需要额外磁盘空间(约等于表大小)和短暂的排他锁(切换瞬间)。

6.4 索引膨胀与 REINDEX#

VACUUM 只清理堆表的死元组,不清理索引的膨胀。频繁更新后,索引页也会积累碎片,导致索引扫描变慢。检查索引大小是否异常:

-- 查看各索引的大小和膨胀情况
SELECT schemaname, relname, indexrelname,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
idx_scan AS index_scans
FROM pg_stat_user_indexes
ORDER BY pg_relation_size(indexrelid) DESC;

如果索引大小远超预期,可以用 REINDEX 重建。PostgreSQL 12+ 支持 REINDEX CONCURRENTLY,不阻塞读写:

-- 并发重建索引(推荐)
REINDEX INDEX CONCURRENTLY idx_orders_status;
-- 重建某表所有索引
REINDEX TABLE CONCURRENTLY orders;

索引维护的更多内容见 PostgreSQL 高级索引

七、实践:VACUUM 与 HOT 更新验证#

本节用 PostgreSQL 观察 HOT(Heap-Only Tuple)更新和 VACUUM 的效果。需要 PostgreSQL 环境。

7.1 创建测试表并观察 HOT 更新#

CREATE TABLE hot_test (
id SERIAL PRIMARY KEY,
status TEXT,
updated_at TIMESTAMP DEFAULT NOW()
);
INSERT INTO hot_test (status) SELECT 'pending' FROM generate_series(1, 100);
-- 更新所有行
UPDATE hot_test SET status = 'shipped', updated_at = NOW();
-- 查看 HOT 更新统计
SELECT relname, n_dead_tup, n_live_tup, n_tup_upd, n_tup_hot_upd
FROM pg_stat_user_tables WHERE relname = 'hot_test';
relname | n_dead_tup | n_live_tup | n_tup_upd | n_tup_hot_upd
----------+------------+------------+-----------+---------------
hot_test | 0 | 100 | 100 | 57

关键观察:

  • n_tup_upd = 100:总共更新了 100 行
  • n_tup_hot_upd = 57:其中 57 次是 HOT 更新,新版本与旧版本在同一数据页中,不需要更新二级索引
  • n_dead_tup = 0:当前没有死元组(Autovacuum 可能已经自动清理)

7.2 手动 VACUUM#

VACUUM hot_test;
SELECT relname, n_dead_tup, n_live_tup
FROM pg_stat_user_tables WHERE relname = 'hot_test';
relname | n_dead_tup | n_live_tup
----------+------------+------------
hot_test | 0 | 100

7.3 表大小#

SELECT pg_size_pretty(pg_relation_size('hot_test')) as size;
size
-------
16 kB

尽管更新了 100 行,表大小仍然只有 16 kB。HOT 更新避免了表膨胀,因为新版本复用了旧版本的空间。如果没有 HOT 更新,每次 UPDATE 都会在新页中插入新版本,表大小会翻倍。

Note

HOT 更新的前提是更新不涉及索引列。如果更新了索引列(如 id),PostgreSQL 必须在索引中插入新的索引条目,无法使用 HOT 更新。

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

PostgreSQL MVCC 与 VACUUM
https://blog.souloss.cn/posts/middleware/db/postgresql-mvcc-and-vacuum/
作者
Souloss
发布于
2024-07-18
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时