在 PostgreSQL 中,UPDATE 通常不会直接覆盖原来的行。数据库会写入一个新版本,并让旧版本继续存在,直到没有任何事务需要看到它。这个设计支撑了 MVCC 的并发读写能力,但也带来一个直接后果:旧版本会变成死元组,长期积累后造成表膨胀。
理解这条生命周期,可以解释很多生产现象:为什么更新频繁的表越来越大、为什么执行了 DELETE 却没有释放磁盘、为什么长事务会拖住 VACUUM,以及为什么一次 VACUUM FULL 可能造成明显阻塞。
UPDATE 实际上创建了一个新行版本
PostgreSQL 使用多版本并发控制(MVCC)决定每个事务能看到哪些行版本。每个版本都带有事务可见性信息,其中常见的系统列包括:
xmin:创建该版本的事务 ID。xmax:使该版本失效的事务 ID;未失效时通常为 0。ctid:该版本在堆表中的物理位置。
可以这样实践。先在一个测试数据库中建立表:
DROP TABLE IF EXISTS accounts;
CREATE TABLE accounts (
id bigint PRIMARY KEY,
balance numeric(12, 2) NOT NULL
);
INSERT INTO accounts VALUES (1, 100.00);
SELECT ctid, xmin, xmax, id, balance
FROM accounts;
接下来打开两个 psql 会话。
会话 A 启动一个可重复读事务,并建立快照:
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT ctid, xmin, xmax, id, balance
FROM accounts
WHERE id = 1;
保持会话 A 不提交。在会话 B 中更新同一行:
UPDATE accounts
SET balance = 200.00
WHERE id = 1;
SELECT ctid, xmin, xmax, id, balance
FROM accounts
WHERE id = 1;
此时回到会话 A,再查询一次:
SELECT ctid, xmin, xmax, id, balance
FROM accounts
WHERE id = 1;
会话 B 能看到余额为 200.00 的新版本,而会话 A 仍然看到快照中的 100.00。两个会话可能得到不同的 ctid,因为它们读取的是不同物理版本。
这不是缓存延迟,也不是副本同步问题,而是 MVCC 的预期行为。只要会话 A 的快照仍然有效,旧版本就不能被清理。
完成实验后提交会话 A:
COMMIT;
到这一刻,旧版本才有机会成为对所有事务都不可见的死元组。
死元组如何变成表膨胀
死元组占用的空间不会在事务提交时立即归还给操作系统。普通 VACUUM 会标记这些空间可供 PostgreSQL 后续复用,但通常不会缩小表文件。
这意味着需要区分三个概念:
- 逻辑删除:旧版本已经不再对任何事务可见。
- 空间复用:
VACUUM清理后,数据页中的位置可以容纳新元组。 - 文件收缩:表文件真正变小,通常需要重写或迁移数据。
下面的双会话实验可以观察长事务如何阻止清理。该示例会写入较多测试数据,只应在开发数据库中运行。
先建立测试表:
DROP TABLE IF EXISTS mvcc_bloat_demo;
CREATE TABLE mvcc_bloat_demo (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
revision integer NOT NULL DEFAULT 0,
payload text NOT NULL
) WITH (fillfactor = 100);
CREATE INDEX mvcc_bloat_demo_revision_idx
ON mvcc_bloat_demo (revision);
INSERT INTO mvcc_bloat_demo (payload)
SELECT repeat(md5(g::text), 8)
FROM generate_series(1, 50000) AS g;
ANALYZE mvcc_bloat_demo;
在会话 A 中持有旧快照:
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*), sum(revision)
FROM mvcc_bloat_demo;
保持事务打开。在会话 B 中连续更新数据:
UPDATE mvcc_bloat_demo SET revision = revision + 1;
UPDATE mvcc_bloat_demo SET revision = revision + 1;
UPDATE mvcc_bloat_demo SET revision = revision + 1;
UPDATE mvcc_bloat_demo SET revision = revision + 1;
UPDATE mvcc_bloat_demo SET revision = revision + 1;
ANALYZE mvcc_bloat_demo;
SELECT
pg_size_pretty(pg_relation_size('mvcc_bloat_demo')) AS table_size,
pg_size_pretty(pg_indexes_size('mvcc_bloat_demo')) AS index_size,
pg_size_pretty(pg_total_relation_size('mvcc_bloat_demo')) AS total_size;
这里更新了被索引的 revision 列,因此这些更新不能使用仅在堆页内建立版本链的 HOT 优化。表和索引都会产生新的条目,现象通常更容易观察。
仍然保持会话 A 的事务,在会话 B 中运行:
VACUUM (VERBOSE, ANALYZE) mvcc_bloat_demo;
VACUUM 可以完成部分工作,但不能删除会话 A 的旧快照仍可能访问的版本。然后在会话 A 中确认它仍然看到旧数据并提交:
SELECT count(*), sum(revision)
FROM mvcc_bloat_demo;
COMMIT;
回到会话 B,再执行一次清理:
VACUUM (VERBOSE, ANALYZE) mvcc_bloat_demo;
现在旧版本可以被回收。不过再次检查文件大小时,它未必明显下降:
SELECT
pg_size_pretty(pg_relation_size('mvcc_bloat_demo')) AS table_size,
pg_size_pretty(pg_indexes_size('mvcc_bloat_demo')) AS index_size,
pg_size_pretty(pg_total_relation_size('mvcc_bloat_demo')) AS total_size;
这是普通 VACUUM 的设计目标:快速回收可复用空间,同时避免重写整张表。它解决的是后续写入能否复用空间,而不是立即把磁盘交还给操作系统。
从统计视图判断清理是否跟得上
生产环境中,可以先从 pg_stat_user_tables 观察活元组、死元组和清理时间:
SELECT
schemaname,
relname,
n_live_tup,
n_dead_tup,
last_vacuum,
last_autovacuum,
vacuum_count,
autovacuum_count
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;
n_live_tup 和 n_dead_tup 是统计估算值,不是逐行精确计数。它们适合寻找异常表,不适合当作磁盘审计结果。
还要检查长事务。下面的查询会列出已经持有事务一段时间的客户端会话:
SELECT
pid,
usename,
application_name,
state,
now() - xact_start AS transaction_age,
wait_event_type,
wait_event,
left(query, 120) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
AND pid <> pg_backend_pid()
ORDER BY xact_start;
尤其需要关注 idle in transaction。这类连接可能没有消耗 CPU,却一直持有旧快照,使死元组无法清理。处理时不要看到长事务就直接调用 pg_terminate_backend;应先确认业务操作、批处理任务和迁移脚本是否允许中断。
如果有权限安装 pgstattuple 扩展,可以对重点表做更具体的检查。扫描大表可能产生明显 I/O,应避开高峰:
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT
table_len,
tuple_count,
dead_tuple_count,
dead_tuple_percent,
free_percent
FROM pgstattuple('public.mvcc_bloat_demo');
调整 autovacuum,而不是定期依赖 VACUUM FULL
Autovacuum 根据固定阈值与表规模比例决定何时清理。对于更新速度很高的大表,默认比例可能意味着积累大量死元组后才触发任务。
可以针对单表设置更积极的参数,而不必立即修改整个集群。下面只是一个可调整的起点,实际值应根据写入速率、表大小和 I/O 余量确定:
ALTER TABLE public.mvcc_bloat_demo SET (
autovacuum_vacuum_threshold = 1000,
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_analyze_threshold = 1000,
autovacuum_analyze_scale_factor = 0.01
);
大致可以把 vacuum 触发条件理解为:固定阈值加上表估算行数乘以比例。对于一亿行表,即使 scale_factor 只有 0.2,也可能容许约两千万次相关变更后才触发,因此大型热点表通常需要单独配置。
VACUUM FULL 会重写整张表,并能显著收缩文件,但它需要重量级表锁,还需要额外磁盘空间完成重写。它更适合经过容量评估和维护窗口安排后的修复操作,不应成为日常清理机制。
上线前的检查顺序
遇到膨胀问题时,可以按下面的顺序处理:
- 用
pg_stat_user_tables找出死元组多、长时间没有 autovacuum 的表。 - 用
pg_stat_activity排查长事务和idle in transaction连接。 - 确认 autovacuum 是否运行、是否被锁等待或资源限制拖慢。
- 按热点表的写入速率调整阈值和比例,并持续观察 I/O。
- 区分“需要复用空间”和“必须归还磁盘”;只有后者才考虑表重写方案。
- 在执行
VACUUM FULL、重建索引或在线重组工具前,明确锁影响、额外空间和回滚方案。
MVCC 让读取者和写入者可以并发工作,但旧行版本并不会凭空消失。稳定的 PostgreSQL 运维依赖三件事:控制事务寿命、让 autovacuum 跟上版本产生速度,并正确判断空间究竟需要复用还是收缩。