PostgreSQL MVCC 的代价:真正该比较的是谁为历史版本买单

2026-07-27 18 预计阅读时间: 1 分钟
来源: postgr.es 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.

预计阅读时间:11 分钟

PostgreSQL 的 MVCC 经常因表膨胀、写放大、VACUUM 调优和 32 位事务 ID 被批评。这些问题都真实存在,但只说“PostgreSQL 的 MVCC 很差”还少问了一步:如果读者不能阻塞写者,那么旧版本必须保存在某个地方,清理成本也必须由某个组件承担。不同数据库没有消灭这笔成本,只是决定由写入、历史读取、缓存、临时空间还是后台整理来付账。

四个问题决定一种 MVCC 的性格

评估数据库的多版本并发控制,可以先问四个问题:

  1. 旧版本保存在主表、撤销日志、缓存,还是 LSM 树中?
  2. 版本链从旧版本指向新版本,还是从新版本追溯旧版本?
  3. 二级索引指向物理行位置,还是逻辑主键?
  4. 垃圾由后台任务延迟清理,还是由事务在关键路径上处理?

PostgreSQL heap 的答案是:版本留在表内,旧版本指向新版本,索引记录物理位置,VACUUM 稍后清理。一次 UPDATE 不会原地改写元组,而是写入完整的新元组,并把旧元组留给可见性判断和后续回收。

这直接带来三个结果:

  • 新版本换了物理位置,相关索引通常需要增加新条目,即使被修改的列没有索引。
  • 旧版本会占据 heap 和索引空间,普通 VACUUM 可以回收空间供内部复用,却不会自动缩小数据文件。
  • 只要旧快照仍可能看到某个版本,VACUUM 就不能删除它。

这不是偶发缺陷,而是存储布局的自然结果。

写放大与 HOT:缓解有效,但有条件

PostgreSQL 的二级索引指向元组的物理位置 ctid。更新产生新元组后,索引也要跟着新位置走。索引越多,一次很小的逻辑修改就可能生成越多 WAL、脏页和复制流量。

HOT(Heap-Only Tuple)更新可以跳过索引维护,但必须同时满足两个条件:没有修改任何索引列,而且新版本能够放进同一个 heap 页。第二个条件常被忽略。页面已经塞满时,即使只更新未索引的 last_seen,新元组仍可能溢出到别处。

因此 fillfactor 是写密集型表的重要旋钮:它用更高的基础存储占用换取页内更新空间。不过 HOT 是机会式优化,不保证每次命中;预留空间过多也会降低缓存密度和顺序扫描效率。

可以用下面的实验观察死元组、HOT 命中率和文件大小。假设本机安装了 Docker,示例使用 PostgreSQL 17;原讨论中的具体测量来自 PostgreSQL 19 beta2,因此数值不会完全相同。

docker run --name mvcc-lab \
  -e POSTGRES_PASSWORD=postgres \
  -p 5432:5432 \
  -d postgres:17

until docker exec mvcc-lab pg_isready -U postgres >/dev/null 2>&1; do
  sleep 1
done

docker exec -i mvcc-lab psql -U postgres -v ON_ERROR_STOP=1 <<'SQL'
CREATE EXTENSION IF NOT EXISTS pgstattuple;

DROP TABLE IF EXISTS accounts;
CREATE TABLE accounts (
    id bigint PRIMARY KEY,
    email text NOT NULL,
    status text NOT NULL,
    balance numeric NOT NULL,
    last_seen timestamptz
) WITH (fillfactor = 70);

INSERT INTO accounts
SELECT g,
       'user' || g || '@example.com',
       'active',
       100,
       now()
FROM generate_series(1, 200000) AS g;

CREATE INDEX accounts_email_idx ON accounts (email);
CREATE INDEX accounts_status_idx ON accounts (status);
ANALYZE accounts;

UPDATE accounts
SET last_seen = clock_timestamp()
WHERE id <= 100000;

SELECT
    n_tup_upd,
    n_tup_hot_upd,
    round(100.0 * n_tup_hot_upd / NULLIF(n_tup_upd, 0), 2) AS hot_percent,
    n_dead_tup
FROM pg_stat_user_tables
WHERE relname = 'accounts';

SELECT
    pg_size_pretty(pg_relation_size('accounts')) AS heap_size,
    pg_size_pretty(pg_total_relation_size('accounts')) AS total_size;

SELECT
    tuple_count,
    dead_tuple_count,
    round(dead_tuple_percent::numeric, 2) AS dead_percent
FROM pgstattuple('accounts');
SQL

运行前可以把 fillfactor 改成 100,删除并重建容器后再次执行,对比 HOT 比例与表尺寸。需要注意,统计视图的数据刷新可能略有延迟;严谨压测还应控制 checkpoint、缓存预热和并发写入。

长事务把清理问题放大到整个数据库

VACUUM 只能删除所有活跃快照都不再需要的元组。一个处于 REPEATABLE READ 的事务即使没有继续执行 SQL,也可能长期保留旧快照,拖住数据库的清理边界。类似风险还来自失效的复制槽和长时间运行的查询。

生产环境可以先用以下查询定位长期事务:

SELECT
    pid,
    usename,
    application_name,
    state,
    now() - xact_start AS transaction_age,
    now() - query_start AS query_age,
    wait_event_type,
    wait_event,
    left(query, 120) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;

再检查复制槽是否持续保留 WAL 或事务视界:

SELECT
    slot_name,
    slot_type,
    active,
    xmin,
    catalog_xmin,
    restart_lsn,
    pg_size_pretty(
        pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)
    ) AS retained_wal
FROM pg_replication_slots;

可以这样实践超时策略,但数值必须根据业务事务长度调整,不能直接照搬到批处理或报表连接:

idle_in_transaction_session_timeout = '5min'
statement_timeout = '15min'
lock_timeout = '5s'

idle_in_transaction_session_timeout 往往比单独设置 statement_timeout 更直接,因为真正危险的连接可能没有正在执行语句,只是打开事务后处于 idle 状态。连接池也应在归还连接前执行回滚,避免把未完成事务带回池中。

其他引擎只是把账单寄到别处

Oracle 和 InnoDB 采用 undo 风格:表中保留当前版本,旧值写入撤销结构,读者从新版本沿链回溯。这能保持主表紧凑,也让未修改键值的二级索引免于重写;代价是旧快照读取需要重建历史版本,大事务回滚可能与正向更新一样昂贵。历史空间不足时,系统要么积累 undo,要么让旧查询遭遇“snapshot too old”一类错误。

SQL Server 开启行版本隔离后,会把旧版本写入 version store。传统方案把压力集中到 tempdb,长快照可能产生实例级影响;Accelerated Database Recovery 则使用用户数据库中的持久版本存储,以缩小影响范围并降低回滚成本。这说明常数时间回滚本身也是一种需要专门存储设计换取的能力。

MongoDB WiredTiger 主要在缓存中维护版本链,并在需要时溢出到 history store。它减少了磁盘上的死元组,却把长快照的压力转移到缓存、驱逐过程和节点延迟上。

CockroachDB、YugabyteDB 一类 LSM 系统把版本编码为带时间戳的键,由 compaction 回收旧版本和 tombstone。这里没有 VACUUM 命令,但 compaction 承担了同一类延迟清理工作;一旦整理落后,扫描会穿过更多陈旧版本和删除标记。

etcd 也会保留按 revision 编号的历史值。compaction 删除旧 revision,defrag 才能真正重写后端文件并归还磁盘。请求已经被压缩掉的 revision 会失败,因此 Kubernetes 控制器同样要处理历史窗口和陈旧 watch 的边界。

落地时不要争论“最好”,要匹配负载

PostgreSQL 的选择适合重视读写互不阻塞、快速回滚和历史版本可检查性的系统,但运营团队必须主动管理垃圾。上线写密集型表前,至少检查以下事项:

  • 统计 n_tup_updn_tup_hot_upd,确认 HOT 比例是否符合预期。
  • 根据更新密度调整 fillfactor,同时评估额外空间和缓存命中率。
  • 避免没有查询价值的索引,因为每个索引都可能放大写入和 VACUUM 成本。
  • 监控长事务、idle in transaction、复制槽、n_dead_tup 和事务 ID 年龄。
  • 为高 churn 表单独调整 autovacuum 阈值,不要只依赖集群级默认值。
  • 区分“内部空间已可复用”和“操作系统看到的文件已缩小”;后者通常需要重写表。

成熟的结论不是 PostgreSQL 已经解决或彻底搞砸了 MVCC,而是它选择了可见、延迟、需要运维介入的失败模式。比较数据库时,继续追问旧版本存在哪里、索引指向什么、谁负责清理,以及长事务持续几个小时后会发生什么。答案比“有没有 VACUUM”更能预测系统在生产环境中的行为。


相关推荐