PostgreSQL 的 UPDATE 去了哪里:从 MVCC、死元组到 VACUUM

2026-07-20 25 预计阅读时间: 1 分钟
来源: planetscale.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:10 分钟

在 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_tupn_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 会重写整张表,并能显著收缩文件,但它需要重量级表锁,还需要额外磁盘空间完成重写。它更适合经过容量评估和维护窗口安排后的修复操作,不应成为日常清理机制。

上线前的检查顺序

遇到膨胀问题时,可以按下面的顺序处理:

  1. pg_stat_user_tables 找出死元组多、长时间没有 autovacuum 的表。
  2. pg_stat_activity 排查长事务和 idle in transaction 连接。
  3. 确认 autovacuum 是否运行、是否被锁等待或资源限制拖慢。
  4. 按热点表的写入速率调整阈值和比例,并持续观察 I/O。
  5. 区分“需要复用空间”和“必须归还磁盘”;只有后者才考虑表重写方案。
  6. 在执行 VACUUM FULL、重建索引或在线重组工具前,明确锁影响、额外空间和回滚方案。

MVCC 让读取者和写入者可以并发工作,但旧行版本并不会凭空消失。稳定的 PostgreSQL 运维依赖三件事:控制事务寿命、让 autovacuum 跟上版本产生速度,并正确判断空间究竟需要复用还是收缩。


相关推荐