PostgreSQL的MVCC设计不佳。其他数据库的MVCC同样如此。
摘要
对PostgreSQL的MVCC设计进行了详细的技术分析,指出了其已知缺陷,如写放大和清理开销,并与替代方案进行了比较,说明所有数据库引擎都面临类似的基本权衡。
暂无内容
查看缓存全文
缓存时间: 2026/07/29 15:55
# PostgreSQL 的 MVCC 很糟糕。其他数据库的也一样糟糕。
来源: https://boringsql.com/posts/mvcc-bad-bad/
如果你关注那些不喜欢 PostgreSQL 的人,你学到的第一件事很可能是:MVCC 很糟糕。这个 40 年前的设计错误。它的痕迹无处不在。表格膨胀到两倍大小、32 位事务计数器上限、与 VACCUM 的无尽斗争、死元组的噩梦。它还有权威背书:Uber 在 2016 年测量了写放大,并因此迁移到 MySQL (https://www.uber.com/blog/postgres-to-mysql-migration/);Andy Pavlo 的数据库团队称 MVCC 是他们最讨厌的 PostgreSQL 部分 (https://www.cs.cmu.edu/~pavlo/blog/2023/04/the-part-of-postgresql-we-hate-the-most.html)。这都是真实存在的问题。PostgreSQL 糟糕到了极点。
虽然这些指控没有夸大,但它们归根结底是一个真正的设计选择。膨胀、写放大、VACUUM 的照料:每一项指控都追溯到一个决定,而不是缺陷,我们将在下面的 PostgreSQL 19 beta2 实例上一一复现,让你亲眼目睹损害是如何发生的。但社区间传播的结论总是少问了一个问题:**与什么相比?**其他数据库引擎又是怎么做的,又付出了什么样的**代价**?
因为 MVCC 不是可选的。任何希望读取不阻塞写入的数据库都必须将行的多个版本保存在某处,而每个这样做的引擎都需要回答同样的四个问题:
1. **旧版本存在哪里?**在表本身中,还是在一个独立的结构中?
2. **版本链指向哪个方向?**从旧到新,还是从新到旧?
3. **索引指向什么?**一个物理行的位置,还是一个逻辑键?
4. **谁来清理,以及什么时候清理?**由后台进程稍后处理,还是事务本身?
PostgreSQL 的答案:在表中,从旧到新,物理位置,后台进程稍后处理。批评者列出的每一项代价都源于这四个答案。而每一种替代方案都是另一组答案,不过账单会寄给别人——写入者、历史读取者、临时数据库(tempdb)、缓存、压缩器。其中有一个方案花了多年的工程努力,才换来 PostgreSQL 从第一天起就免费拥有的那个属性。而所有这些替代方案,在一个事务开着去吃午饭时,都会以不同的方式失败。
## 对 PostgreSQL 的指控https://boringsql.com/posts/mvcc-bad-bad/#the-charges-against-postgresql
如果你想了解完整机制,[PostgreSQL MVCC, Byte by Byte](https://boringsql.com/posts/postgresql-mvcc-byte-by-byte/) 使用 `pageinspect` 进行了详细讲解。简单来说:PostgreSQL 中的 UPDATE 从不修改一行。它会在堆中写入该行的一个完整新副本,标记旧版本的 `t_xmax`,然后将两个版本都留在磁盘上。可见性在读取时逐元组判断。清理是别人的问题,特别是 [VACUUM](https://boringsql.com/posts/vacuum-at-the-page-level/) 的问题。
以下四个指控逐一复现。
### 指控 1:写放大https://boringsql.com/posts/mvcc-bad-bad/#charge-1-write-amplification
这是 Uber 投诉的核心。因为表上的每个索引都指向行的**物理位置**(一个页号加一个槽位,即 ctid),而 UPDATE 会创建一个新的物理行,因此每个索引都需要一个新条目指向新位置。即使是你没修改的列上的索引也是如此。
创建两个相同的 100 万行表。一个只有主键,另一个有四个额外的二级索引:
``
CREATE TABLE accounts (
id bigint PRIMARY KEY,
email text NOT NULL,
status text NOT NULL,
balance numeric NOT NULL,
created_at timestamptz NOT NULL,
last_seen timestamptz
);
INSERT INTO accounts
SELECT g, 'user' || g || '@example.com', 'active', 100, now(), now()
FROM generate_series(1, 1000000) g;
CREATE INDEX ON accounts (email);
CREATE INDEX ON accounts (status);
CREATE INDEX ON accounts (balance);
CREATE INDEX ON accounts (created_at);
CREATE TABLE accounts_lean (LIKE accounts);
ALTER TABLE accounts_lean ADD PRIMARY KEY (id);
INSERT INTO accounts_lean SELECT * FROM accounts;
``
现在更新 10 万行,只修改 `last_seen`。注意 `last_seen` 没有出现在两个表的**任何**索引中。测量生成的 WAL(使用 `pg_stat_wal`,在 `CHECKPOINT` 和 `pg_stat_reset_shared('wal')` 之后清空窗口):
``
UPDATE accounts_lean SET last_seen = now()
WHERE id > 100000 AND id <= 200000;
``
``
wal_records | wal_fpi | wal_bytes | wal_pretty
-------------+---------+-----------+------------
302510 | 1419 | 38218531 | 36 MB
``
``
UPDATE accounts SET last_seen = now()
WHERE id > 100000 AND id <= 200000;
``
``
wal_records | wal_fpi | wal_bytes | wal_pretty
-------------+---------+-----------+------------
709440 | 1981 | 72394573 | 69 MB
``
同样的逻辑变更,10 万个时间戳。精简表大约每行产生 3.0 个 WAL 记录。有索引的表产生了 7.1 个:堆更新,加上主键中的一个新条目,再加上**四个二级索引各一个**新条目,而这些索引都没有索引我们修改的列。它们仍然全部被重写,因为行移动了,它们都指向物理位置。
这就是 Uber 测量到的放大,而且它还会叠加:更多的 WAL 意味着更多的检查点工作、更多的全页写入,以及更多需要传输到每个副本的字节。在一个高度索引的表上进行单列更新,是每字节有效变更中最昂贵的操作之一。
PostgreSQL 的缓解措施是 HOT 更新(Heap-Only Tuples (https://boringsql.com/posts/hot-updates/)):如果没有索引列被修改**并且**新版本能放在同一页面上,则索引保持不变。上面的两个条件都失败了;页面已经填满,所以每个新版本都落到了不同的页面上。给表留出空间再试一次:
``
ALTER TABLE accounts SET (fillfactor = 70);
VACUUM FULL accounts;
UPDATE accounts SET last_seen = now()
WHERE id > 200000 AND id <= 300000;
``
``
wal_records | wal_bytes | n_tup_hot_upd (this batch)
-------------+-----------+----------------------------
455812 | 51 MB | 41,996 of 100,000
``
好一些了,但注意数字:42% 是 HOT,而不是 100%。每个页面有 30% 的空闲空间,大约 80 行;一旦一个页面上的前大约 30 个更新耗尽了预留空间,该页面剩余的行就会溢出到其他地方,索引也是。在机会性剪枝回收了页面内的死版本后,再次运行同一批更新:
``
wal_records | wal_bytes | n_tup_hot_upd (this batch)
-------------+-----------+----------------------------
304990 | 22 MB | 66,314 of 100,000
``
66% HOT,每行 3.0 个 WAL 记录,几乎回到精简表的成本。这就是缓解措施的真实面貌:HOT 是机会性的,在稳态下出现而非按需提供,并且悄悄地用你表大小的 30% 作为预留空间。它有效,生产环境中的 OLTP 表在选择了合适的 fillfactor 后,通常能达到 90% 以上的 HOT 比率。但默认行为是每行 7.1 个记录,而批评者描述的是默认情况。
### 指控 2:你的表就是一个事故现场https://boringsql.com/posts/mvcc-bad-bad/#charge-2-your-table-is-an-accident-scene
每个 UPDATE 都是一个 INSERT 加上一个延迟的 DELETE。如果不处理,死版本就会积累:
``
VACUUM (FULL, ANALYZE) accounts_lean; -- 重置: 89 MB, 0% 死亡
\timing on
BEGIN;
UPDATE accounts_lean SET balance = balance + 1; -- 所有 100 万行
ROLLBACK;
SELECT pg_size_pretty(pg_relation_size('accounts_lean'));
SELECT tuple_count, dead_tuple_count, round(dead_tuple_percent)
FROM pgstattuple('accounts_lean');
``
``
BEGIN
Time: 0.032 ms
UPDATE 1000000
Time: 1590.112 ms (00:01.590)
ROLLBACK
Time: 0.124 ms
pg_size_pretty
----------------
178 MB
tuple_count | dead_tuple_count | dead_pct
-------------+------------------+----------
1000000 | 1000000 | 47
``
一个语句,一个**甚至没有提交**的事务,就使表的大小翻倍了。一百万个死元组等待 VACUUM,而正如 [VACUUM Is a Lie](https://boringsql.com/posts/vacuum-is-lie/) 所述,随之增长的索引永远不会自行缩小。这就是膨胀的跑步机:存储设计保证垃圾以写入速度产生,而以 VACUUM 速度收集,保持这两个速度匹配是一项运维工作,PostgreSQL 把它外包给了你,以 autovacuum 的默认值作为起点。
### 指控 3:一个空闲事务就能污染整个水源https://boringsql.com/posts/mvcc-bad-bad/#charge-3-one-idle-transaction-poisons-the-well
继续实验。在第二个会话中,打开一个事务然后……保持一个快照:
``
-- 会话 B
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM accounts_lean;
-- 去吃午饭
``
回到会话 A,更新 10 万行并执行 VACUUM:
``
INFO: finished vacuuming "mvcc_critics.public.accounts_lean":
tuples: 14 removed, 1099945 remain,
100000 are dead but not yet removable
removable cutoff: 718, which was 1 XIDs old when operation ended
``
“死亡但尚不可移除。”会话 B 的快照可能仍然需要那些版本,所以 VACUUM 必须保留它们,而且不仅在这个表中:xmin 边界会被数据库中所有表推迟,无论会话 B 是否读取它们。PostgreSQL 永远不会取消读取者,也不会让它失败。严格来说在 MVCC 层面是正确的,但生产环境通常会从外部添加这个取消机制:`idle_in_transaction_session_timeout` 和 `statement_timeout` 的存在正是为了在午餐事务拖累 xmin 边界之前杀死它。它只是悄悄地停止收集垃圾,全库范围,只要该事务空闲多久就多久。每个 PostgreSQL 操作员最终都会学会追踪长时间运行的事务和陈旧的复制槽。
### 指控 4:32 位的债务https://boringsql.com/posts/mvcc-bad-bad/#charge-4-the-32-bit-debt
每个元组头用 32 位存储其创建者事务 ID。40 亿个事务后计数器会循环,所以 PostgreSQL 必须定期“冻结”旧元组,重写那些数月未曾修改的页面,仅仅是为了回收 XID 空间。如果落后太多,数据库会停止接受写入以保护自身。问问 Sentry,他们写了关于 2015 年包装故障 (https://blog.sentry.io/transaction-id-wraparound-in-postgres/) 的文章,或者自从那之后重新学到这一点的其他几家公司。但只有 PostgreSQL 会让你定期重写未被触及的页面,以防止时钟过期。
公平地说,Oracle 的计数器也是有限的:SCN 几十年来都是 48 位,Oracle DBA 在 2019 年不得不为 SCN 空间打补丁,因为数据库链接被发现会向前跳跃计数器。最终的修复是将 SCN 扩展到 64 位(12.2 及以后),代价是在数据库链接之间进行版本兼容性舞步。
所以:指控成立。默认情况下写放大,垃圾以写入速度产生并由一个你需要调优的进程收集,清理被任何空闲快照挟持,以及一个必须永远上弦的 32 位时钟。PostgreSQL 的 MVCC 很糟糕,体现在四个具体且可证明的方面。
## 撤销阵营:Oracle 和 InnoDBhttps://boringsql.com/posts/mvcc-bad-bad/#the-undo-camp-oracle-and-innodb
Oracle 和 MySQL 的 InnoDB 对四个问题的回答正好相反:版本存在表**之外**,链指向从新到旧,清理部分由写入者负责。这通常是那些“PostgreSQL MVCC 很糟糕”的文章隐含地作为修复方案的设计,所以它值得仔细审视。
InnoDB 中的 UPDATE 会**原地**修改行,在聚簇索引内(在 InnoDB 中,表本身就是一个按主键排序的 B 树)。在覆盖之前,它会将旧值复制到**撤销日志**中,一个独立的存储区域,并在行上标记一个指向该撤销记录的指针。每行有两个隐藏的系统列用于此:`DB_TRX_ID`,最后修改它的事务,以及 `DB_ROLL_PTR`,指向撤销的指针。Oracle 的实现在细节上有所不同(撤销段、块级一致性读),但形状相同:表只存最新版本,历史以增量链的形式挂在它后面。
一个拥有较旧快照的读取者会找到当前行,发现 `DB_TRX_ID` 太新,然后沿着撤销链向后走,应用增量,直到重构出它有权看到的版本。Oracle 在块粒度上做这件事,在内存中重建整个块的一致性映像。
看看这能带来什么,逐条对比四个指控:
- **表保持紧凑。**每行一个版本,永远。没有膨胀跑步机,没有 `pgstattuple` 显示 47% 的死亡数据。撤销在固定大小的环(Oracle)中回收,或持续清除(InnoDB)。
- **二级索引幸免于难。**它们指向主键,一个逻辑引用,而不是物理位置。更新 `last_seen`,我们实验中的四个二级索引都不会被触及,因为行从未移动,其键也未改变。这一个决策就是 Uber 测量的写放大差距的大部分。
- **没有 VACUUM。**清理已融入正常操作:InnoDB 的清除线程持续修剪撤销历史,Oracle 回收撤销段。
- **没有包装仪式。**InnoDB 的事务 ID 是 48 位,Oracle 的 SCN 从 12.2 开始是 64 位(48 位时代随着指控 4 中的空间恐慌而结束),足够宽,两个引擎都不必冻结元组。
四比四。结案了?
以下是账单。
**回滚的代价等于事务的代价。**还记得我们的实验吗:PostgreSQL 回滚一个一百万的 UPDATE 只用了 **0.124 毫秒**,因为在堆版本化系统中,中止意味着“什么都不做,旧版本已经就位”。(制造混乱花了 1.59 秒,放弃它花了 0.124 毫秒。混乱还在,等着 VACUUM,但**你的事务**已经完成了。)在基于撤销的引擎中,回滚意味着遍历撤销链并逆转每个变更,逐行进行,工作量大致等于正向操作,有时更多。每个 MySQL 操作员最终都会杀死一个长时间运行的 UPDATE,然后看着数据库花费**更长**的时间回滚,而不是运行的时间,而且无法杀死,因为必须应用撤销。如果在事务中途崩溃,恢复会在系统完全健康之前继承同样的工作。正是为了逃避这一点,SQL Server 后来成长出了一个类似 PostgreSQL 风格的版本存储,但我们先走一步了。
读取者也会在读取时付出历史代价。在 PostgreSQL 中,一个旧的行版本只是坐在堆里;读取它的成本与其他任何读取相同。第一个读取一个最近提交的元组的读取者可能需要通过 `pg_xact` 设置提示位,这是每个元组的一次性税赋,之后就是普通的堆。而在撤销阵营中,除了最新版本之外的所有版本都必须**重构**,每次读取时,通过追逐指针到存储的不同部分并应用增量。一个针对热表的长时间运行报表会随着其快照与当前状态的差距扩大而逐渐变慢,同时还会用撤销页给缓冲池施加压力。
**空闲快照的问题并没有消失。它改变了失败模式。**撤销空间是有限的。当一个长时间运行的查询需要一个已被回收的撤销版本时,Oracle 不会像 PostgreSQL 那样暂停清理。它会杀死查询:`ORA-01555: snapshot too old`,数据库中最常见的错误码之一。InnoDB 在同一设计内做出了相反的选择:清除**等待**最旧的读取视图,历史堆积起来(观察 `SHOW ENGINE INNODB STATUS` 中的 history list length 增长),撤销表空间膨胀。MySQL 8 的自动撤销截断确实会收缩那些日志已变为非活动的表空间,但导致增长的长读取正是让日志保持激活状态的原因,所以垃圾会一直保留,只要读取者还在。
相似文章
@0xlelouch_: 2026年PostgreSQL的90%归结于掌握这10个概念:1) MVCC + vacuum。旧行版本保留;a…
本文列举了2026年高效管理PostgreSQL所需的十个核心概念,涵盖MVCC、索引、查询优化和安全。
PostgreSQL 适用于一切场景
本文认为,PostgreSQL 是一个功能全面的数据库解决方案,足以替代搜索引擎、消息队列和缓存等多种专用技术,从而简化 IT 架构。
使用 Postgres 作为作业队列的潜在后果
文章分析了使用 PostgreSQL 作为作业队列的可扩展性限制,特别强调了高并发下 MultiXact SLRU 争用导致的性能瓶颈。文章解释了为什么这种架构在开发环境中表现良好,但在生产环境中却会失败,并建议考虑替代方案。
页面级的VACUUM
本文提供了对PostgreSQL在页面级别的VACUUM过程的逐字节分析,使用pageinspect展示死元组空间是如何被回收的。
Postgres DELETE 如何真正实现高扩展性
DBOS 阐述了在每秒处理数万个工作流的数据保留策略场景下,尽管 MVCC 本身存在固有开销,如何通过调优缓存局部性和索引性能,使 Postgres DELETE 能够支撑高吞吐工作负载的扩展。