PostgreSQL 的 MVCC 经常因表膨胀、写放大、VACUUM 调优和 32 位事务 ID 被批评。这些问题都真实存在,但只说“PostgreSQL 的 MVCC 很差”还少问了一步:如果读者不能阻塞写者,那么旧版本必须保存在某个地方,清理成本也必须由某个组件承担。不同数据库没有消灭这笔成本,只是决定由写入、历史读取、缓存、临时空间还是后台整理来付账。
四个问题决定一种 MVCC 的性格
评估数据库的多版本并发控制,可以先问四个问题:
- 旧版本保存在主表、撤销日志、缓存,还是 LSM 树中?
- 版本链从旧版本指向新版本,还是从新版本追溯旧版本?
- 二级索引指向物理行位置,还是逻辑主键?
- 垃圾由后台任务延迟清理,还是由事务在关键路径上处理?
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_upd与n_tup_hot_upd,确认 HOT 比例是否符合预期。 - 根据更新密度调整
fillfactor,同时评估额外空间和缓存命中率。 - 避免没有查询价值的索引,因为每个索引都可能放大写入和 VACUUM 成本。
- 监控长事务、
idle in transaction、复制槽、n_dead_tup和事务 ID 年龄。 - 为高 churn 表单独调整 autovacuum 阈值,不要只依赖集群级默认值。
- 区分“内部空间已可复用”和“操作系统看到的文件已缩小”;后者通常需要重写表。
成熟的结论不是 PostgreSQL 已经解决或彻底搞砸了 MVCC,而是它选择了可见、延迟、需要运维介入的失败模式。比较数据库时,继续追问旧版本存在哪里、索引指向什么、谁负责清理,以及长事务持续几个小时后会发生什么。答案比“有没有 VACUUM”更能预测系统在生产环境中的行为。