PostgreSQL 和 MySQL 都实现了 MVCC,但路径截然不同。MySQL InnoDB 把旧版本塞进 Undo Log,数据页只存最新版;PostgreSQL 选择在堆表中直接保留所有版本,UPDATE 等于标记旧行加插入新行。这一选择带来连锁反应:不需要 Undo Log,但必须有 VACUUM 回收死元组;不支持原地更新,但发明了 HOT 更新来缓解索引膨胀。
本文把 PostgreSQL 的多版本机制从头拆到尾:xmin/xmax 如何标记版本、Clog 如何记录事务状态、快照如何界定可见性边界、VACUUM 如何回收空间、HOT 更新如何避免索引维护。每个环节都会和 MySQL 的 Undo Log MVCC 做对比,两种方案的取舍差异会非常清晰。索引类型和代价估计器是另一个大话题,见 PostgreSQL 高级索引。
前置知识
了解 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)包含三个关键字段:
typedef struct HeapTupleHeaderData { TransactionId t_xmin; // 插入该元组的事务ID TransactionId t_xmax; // 删除或更新该元组的事务ID CommandId t_cid; // 插入/删除该元组的命令ID(同事务内) ItemPointerData t_ctid; // 指向新版本元组的指针(更新时) // ... 其他字段} HeapTupleHeaderData;三个字段的含义:
| 字段 | 含义 | 何时设置 |
|---|---|---|
t_xmin | 创建该元组的事务 ID | INSERT 时设为当前事务 ID |
t_xmax | 删除/更新该元组的事务 ID | DELETE 时设为当前事务 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_ctidFROM 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 可见性判断的基础:
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 可见性判断规则
给定一个元组和快照,可见性判断遵循以下规则:
简化口诀:元组可见 = 插入事务在快照前已提交 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,但实现路径截然不同:
| 维度 | PostgreSQL | MySQL InnoDB |
|---|---|---|
| 多版本存储 | 堆表中直接保留旧版本(Append-Only) | Undo Log 中保留旧版本,数据页只存最新版 |
| 更新方式 | INSERT 新版本 + 标记旧版本 xmax | 原地更新数据页 + 写 Undo Log |
| 回滚机制 | 无 Undo Log,依赖 Clog 判断可见性 | 从 Undo Log 重建旧版本 |
| 空间回收 | VACUUM 扫描清理死元组 | Undo Log 段自动清理 |
| 回滚段膨胀 | 无回滚段,但堆表膨胀 | 长事务导致 Undo Log 膨胀 |
| 读旧版本 | 直接读堆表中的旧元组 | 从 Undo Log 链重建旧版本 |
| 热点数据页 | 多版本共存导致页分裂频繁 | 数据页始终是最新版,更紧凑 |
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; -- 默认 50SHOW autovacuum_vacuum_scale_factor; -- 默认 0.2(20%)SHOW autovacuum_analyze_threshold; -- 默认 50SHOW 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 流程
VACUUM 的核心步骤:
- 扫描:遍历堆表页面,识别 Dead Tuple
- 标记:将 Dead Tuple 的空间标记为可用,更新 FSM(Free Space Map)
- 更新 VM:更新可见性映射(Visibility Map),标记全干净的页
- 截断:如果文件末尾的页完全为空,截断文件释放磁盘空间
可见性映射(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 链找到新版本。
4.2 HOT 的触发条件
HOT 更新必须同时满足两个条件:
- 新版本与旧版本在同一数据页:PostgreSQL 在更新时会优先尝试在同一页中分配空间。如果页已满,则退化为普通更新。
- 更新的列不被任何索引引用:如果更新了索引列,索引条目必须更新,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_ratioFROM pg_stat_user_tablesWHERE relname = 'accounts';
-- 示例输出:-- n_tup_ins | n_tup_upd | n_tup_hot_upd | hot_ratio-- -----------+-----------+---------------+------------- 10000 | 8000 | 7200 | 90.0090% 的 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
| 维度 | PostgreSQL | MySQL InnoDB |
|---|---|---|
| 存储引擎 | 统一存储引擎(Heap Table) | 可插拔存储引擎(InnoDB/MyISAM/etc.) |
| MVCC 实现 | 堆表多版本(xmin/xmax) | Undo Log 回滚段 |
| 更新方式 | Append-Only(INSERT 新版本) | 原地更新 + Undo Log |
| 回滚 | 无 Undo Log,依赖 Clog | Undo 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 并发控制
| 维度 | PostgreSQL | MySQL InnoDB |
|---|---|---|
| 默认隔离级别 | Read Committed | Repeatable Read |
| RR 实现 | 快照(首次读时获取) | Gap Lock + Next-Key Lock |
| Serializable | SSI(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_ratioFROM pg_stat_user_tablesORDER BY dead_ratio DESC;
-- 示例输出:-- relname | n_live_tup | n_dead_tup | dead_ratio-- -----------+------------+------------+-------------- orders | 1000000 | 350000 | 35.00 <- 膨胀严重-- users | 500000 | 2000 | 0.40dead_ratio 超过 10% 就该警惕。但看绝对值更重要:一张 1 亿行的大表,即使 dead_ratio 只有 5%,也有 500 万死元组,查询时要额外扫描这些行。
死元组率持续居高不下,通常有两个原因:一是 AutoVacuum 触发阈值太高(默认 scale_factor=0.2 对大表太宽松),二是有长事务持有旧快照阻止回收。排查长事务:
-- 查找运行时间最长的事务SELECT pid, state, xact_start, now() - xact_start AS duration, queryFROM pg_stat_activityWHERE state != 'idle'ORDER BY duration DESC;
-- 查找阻止 VACUUM 的最小事务 ID(horizon)-- 如果这个值很旧,说明有长事务卡住了 VACUUM6.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_threshold 和 autovacuum_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 orderspg_repack 的代价是需要额外磁盘空间(约等于表大小)和短暂的排他锁(切换瞬间)。
6.4 索引膨胀与 REINDEX
VACUUM 只清理堆表的死元组,不清理索引的膨胀。频繁更新后,索引页也会积累碎片,导致索引扫描变慢。检查索引大小是否异常:
-- 查看各索引的大小和膨胀情况SELECT schemaname, relname, indexrelname, pg_size_pretty(pg_relation_size(indexrelid)) AS index_size, idx_scan AS index_scansFROM pg_stat_user_indexesORDER 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_updFROM 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_tupFROM pg_stat_user_tables WHERE relname = 'hot_test'; relname | n_dead_tup | n_live_tup----------+------------+------------ hot_test | 0 | 1007.3 表大小
SELECT pg_size_pretty(pg_relation_size('hot_test')) as size; size------- 16 kB尽管更新了 100 行,表大小仍然只有 16 kB。HOT 更新避免了表膨胀,因为新版本复用了旧版本的空间。如果没有 HOT 更新,每次 UPDATE 都会在新页中插入新版本,表大小会翻倍。
HOT 更新的前提是更新不涉及索引列。如果更新了索引列(如 id),PostgreSQL 必须在索引中插入新的索引条目,无法使用 HOT 更新。
参考资料
- PostgreSQL Documentation: MVCC - 官方 MVCC 机制与隔离级别说明
- PostgreSQL Documentation: Routine Vacuuming - VACUUM 与 AutoVacuum 触发条件、参数调优
- PostgreSQL Documentation: Heap-Only Tuples - HOT 更新原理与触发条件
- The Internals of PostgreSQL, Chapter 5 - xmin/xmax 版本链、可见性判断、VACUUM 流程的可视化讲解
- MySQL 8.0 Reference Manual: InnoDB Multi-Versioning - InnoDB MVCC 的 Undo Log 版本链,用于对比
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






