DeepSeek总结的PostgreSQL 的 MVCC 很糟。其他所有的也一样。第1部分

PostgreSQL 的 MVCC 很糟。其他所有的也一样。

2026-07-27

· 21 分钟 ·

Radim Marek

目录

如果你关注那些不喜欢 Postgres 的人,你大概会学到的第一件事就是 MVCC 很糟。一个 40 年前的设计错误。它的印记无处不在。体积翻倍、膨胀的表,32 位事务计数器上限,与 VACUUM 永无止境的斗争,死元组噩梦。它还带着凭证:Uber 在 2016 年测量了写放大并因此转投 MySQL;Andy Pavlo 的数据库小组称 MVCC 是 PostgreSQL 中他们最讨厌的部分。这是真的。Postgres 糟糕到了极点。

虽然这些都没有夸大,但它归结为一个真实的设计选择。膨胀、放大的写入、vacuum 保姆:每一项指控都追溯到一个决定,而不是一个缺陷,我们在一个活的 PostgreSQL 19 beta2 实例上逐一复现,这样你可以亲眼看到损害发生。但从社区传播到社区的裁决总是在一个问题上提前停止:和什么比?其他每个引擎改做什么,代价是什么?

因为 MVCC 不是可选的。任何想让读者不阻塞写者的数据库都必须把行的多个版本保存在某处,而每个这样做的引擎都回答同样的四个问题:

  • 旧版本住在哪里?在表本身,还是在单独的结构中?
  • 版本链指向哪个方向?从旧到新,还是从新到旧?
  • 索引指向什么?物理行位置,还是逻辑键?
  • 谁清理,何时清理?稍后的后台进程,还是事务本身?

PostgreSQL 的答案:在表中,从旧到新,物理位置,稍后的后台进程。批评者列出的每一项成本都来自这四个答案。而每一个替代方案都是一组不同的答案,账单寄给别人:写者、历史读者、tempdb、缓存、压缩器。其中一个花了数年工程来买 PostgreSQL 设计从第一天起就免费拥有的那一个属性。所有方案在事务午餐时保持打开时都失败,只是方式不同。

对 PostgreSQL 的指控

如果你想要完整机制,《PostgreSQL MVCC, Byte by Byte》用 pageinspect 走了一遍。短版本:PostgreSQL 中的 UPDATE 从不修改行。它把行的完整新副本写入堆,给旧版本的 t_xmax 打戳,并让两个版本都留在磁盘上。可见性在读取时逐元组决定。清理是别人的问题,具体来说是 VACUUM 的。

随之而来四项指控,下面逐一复现。

指控 1:写放大

Uber 抱怨的核心。因为表上的每个索引都指向行的物理位置(页号加槽位,即 ctid),而 UPDATE 创建一个新的物理行,所以每个索引都需要一个指向新位置的新条目。即使是你没碰过的列上的索引。

设置同一张 100 万行表的两个副本。一个只有主键,一个有四个额外二级索引:

sql 复制代码
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;

现在更新 100,000 行,只碰 last_seen。注意 last_seen 在两张表上都不在任何索引中。测量生成的 WAL(pg_stat_wal,在 CHECKPOINT 和 pg_stat_reset_shared('wal') 之后获得干净窗口):

sql 复制代码
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
sql 复制代码
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

同样的逻辑变更,100,000 个时间戳。精简表每行产生约 3.0 条 WAL 记录。有索引的表产生 7.1 条:堆更新,加上主键中的一个新条目,加上四个二级索引中每个一个新条目,而它们没有一个索引我们改变的列。它们还是全都被重写了,因为行移动了,而它们都指向物理位置。

这就是 Uber 测量的放大,而且它会叠加:更多 WAL 意味着更多检查点工作、更多全页写入,以及更多字节运送到每个副本。在重度索引的表上做单列更新,按有用变更的每字节计算,是你能做的最昂贵的事情之一。

PostgreSQL 的缓解是 HOT 更新(Heap-Only Tuples):如果没有索引列改变且新版本适合同一页面,索引就不动。last_seen 没有索引,所以那个条件成立;页面只是被塞满了,每个新版本都落在不同页面上。给表留出呼吸空间再试:

sql 复制代码
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:你的表是一个事故现场

每次 UPDATE 是一次 INSERT 加一次延迟的 DELETE。不管它,死版本就累积:

sql 复制代码
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》所述,沿途增长的索引永远不会自行缩回。这就是膨胀跑步机:存储设计保证垃圾以写入速度产生、以 vacuum 速度收集,而让这两个速率匹配是一项运维工作,PostgreSQL 把它外包给你,autovacuum 的默认值作为起点。

指控 3:一个空闲事务毒害水井

继续实验。在第二个会话中,打开一个事务,然后只是......持有一个快照:

sql 复制代码
-- 会话 B
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM accounts_lean;
-- 去吃午饭

回到会话 A,更新 100,000 行并 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

"Dead but not yet removable。"会话 B 的快照可能仍然需要那些版本,所以 vacuum 必须保留它们,而且不仅仅在这张表中:xmin 地平线为数据库中的每张表被拉回,无论会话 B 是否读过它们。PostgreSQL 永远不会取消读者,也永远不会让它失败。严格来说在 MVCC 层面是真的,但生产设置 routinely 从外部加回那种取消:idle_in_transaction_session_timeout 和 statement_timeout 的存在正是为了在午餐休息事务拖住 xmin 地平线之前杀死它。它只是悄悄停止收集垃圾,数据库范围,只要事务空闲。每个 Postgres 操作员最终都学会猎杀长运行事务和过时复制槽。

指控 4:32 位债务

每个元组头用 32 位存储其创建者事务 ID。四十亿事务后计数器回绕,所以 PostgreSQL 必须定期冻结旧元组,重写几个月没碰过的页面,纯粹为了回收 XID 空间。落后太多,数据库就停止接受写入以保护自己。问问 Sentry,他们写了 2015 年回绕故障,或者自那以后重新学到这一点的几家公司。但只有 PostgreSQL 让你定期重写未触碰的页面,以防止时钟耗尽。

公平地说,Oracle 的计数器也是有限的:SCN 几十年来是 48 位,而 Oracle DBA 在 2019 年忙于打补丁以获得 SCN 余量,因为发现数据库链接会向前跳计数器。扩宽先发生,并引起了慌乱:12.2 在 2016 年把 SCN 增长到 64 位("big SCN"),而 2019 年的补丁是让较旧数据库接受一个链接的 12.2 现在可以发送的更大值,代价是跨数据库链接的版本兼容性舞蹈。

所以:指控成立。默认放大写入,垃圾以写入速度产生并由你必须调优的进程收集,清理被任何空闲快照劫持,以及一个必须永远上弦的 32 位时钟。PostgreSQL 的 MVCC 很糟,以四种具体且可证明的方式。

相关推荐
川石课堂软件测试1 小时前
JMeter异步接口测试实战
数据库·python·jmeter·mysql·kubernetes·测试用例·prometheus
下页、再停留1 小时前
【C#桌面客户端系列学习-14】SQLite数据库之增删改查完整示例
数据库·sqlite
她的男孩1 小时前
JAR 全对仍 checksum mismatch:Forge Admin 把 Flyway 从 filesystem 改成 classpath
java·数据库·spring boot
小马不起床1 小时前
Django 后端实战指南_16:动作分类、动作库、动作收藏查询接口
数据库·python·django
ycydynq1 小时前
django搜索功能
数据库·python·django
呆萌很1 小时前
DISTINCT 和 GROUP BY 核心区别
数据库·sql
小蒜学长1 小时前
基于数据挖掘的京东商城商品推荐系统设计与实现(代码+数据库+LW)
java·数据库·spring boot·后端·推荐系统·个性化推荐
小小AK1 小时前
金蝶其他入库到MES送检签收:库存同步策略实战教程
数据库·制造
LuminousCPP1 小时前
数据库学习笔记(二):MySQL 基础操作入门|在 CentOS Stream 10 上连接、建库与建表
数据库·经验分享·笔记·学习·mysql·centos