一次 UPDATE 之后,旧数据去哪了
大多数人对 UPDATE 的心智模型是「找到那一行,把值改掉」。在 Postgres 里这个模型是错的,而且错得很关键——它是表膨胀、VACUUM、长事务危害、count(*) 慢这一整串现象的共同源头。
我们直接把页面挖开看。
先造一行数据
Section titled “先造一行数据”CREATE EXTENSION IF NOT EXISTS pageinspect; DROP TABLE IF EXISTS demo; CREATE TABLE demo (id int PRIMARY KEY, v int, pad text); INSERT INTO demo VALUES (1, 100, 'hello');
Postgres 给每一行都维护三个系统列,平时不显示,但可以显式查出来:
SELECT ctid, xmin, xmax, id, v FROM demo;
ctid是物理位置,格式是(页号, 页内槽位)。注意它不是主键——它会变。xmin是插入这一行的事务号。xmax是删除/更新这一行的事务号,0表示还没有。
现在做一次最普通的更新:
UPDATE demo SET v = 200 WHERE id = 1; SELECT ctid, xmin, xmax, id, v FROM demo;
ctid 从 (0,1) 变成了 (0,2),xmin 也换了一个更大的事务号。这一行换了物理位置。
旧版本并没有消失
Section titled “旧版本并没有消失”SELECT 只能看到当前可见的版本。要看页面上到底躺着什么,得绕过可见性判断,直接读原始页面:
SELECT lp,
lp_len,
t_ctid,
t_xmin,
t_xmax,
(t_infomask2 & 16384) <> 0 AS hot_updated,
(t_infomask2 & 32768) <> 0 AS heap_only
FROM heap_page_items(get_raw_page('demo', 0));页面上有两个元组:
| lp | 含义 |
|---|---|
| 1 | 旧版本。t_xmax 被填成了更新事务的号,t_ctid 指向 (0,2)——它成了一个指向后继版本的路标 |
| 2 | 新版本。t_xmax = 0,是当前有效的那一个 |
所以 Postgres 的 UPDATE 本质是「把旧版本标记为在某个事务处失效,再插入一个新版本」。原地修改从未发生。
flowchart LR
subgraph before["UPDATE 之前"]
direction TB
i1["索引项<br/>→ (0,1)"]
t1["lp=1 · 元组 v=100<br/>xmin=752 · xmax=0"]
i1 --> t1
end
subgraph after["UPDATE 之后"]
direction TB
i2["索引项<br/>没有变化 → (0,1)"]
t2["lp=1 · 旧版本 v=100<br/>xmin=752 · xmax=753<br/>成了指向后继的路标"]
t3["lp=2 · 新版本 v=200<br/>xmin=753 · xmax=0<br/>当前有效"]
i2 --> t2
t2 -- "t_ctid" --> t3
end
before == "UPDATE demo SET v = 200" ==> after
style i1 fill:#8a8f9c,color:#fff,stroke:none
style i2 fill:#8a8f9c,color:#fff,stroke:none
style t1 fill:#2a6796,color:#fff,stroke:none
style t2 fill:#b5504e,color:#fff,stroke:none
style t3 fill:#3b8f6d,color:#fff,stroke:none
注意索引项完全没有变化——这正是下一节 HOT 更新的意义所在。
两个 infomask 位说明了更多
Section titled “两个 infomask 位说明了更多”刚才那次更新,hot_updated 和 heap_only 分别在旧、新元组上为真。这是 HOT 更新(Heap-Only Tuple)的标志:
- 新版本落在同一个页面内;
- 且没有任何索引列被修改。
满足这两个条件时,Postgres 就不去动索引了——索引项仍然指向 (0,1),查询走到 (0,1) 发现它是个路标,顺着 t_ctid 走到 (0,2)。这条链叫 HOT chain。
对照实验:改一个被索引的列(这里是主键 id),看两个标志位会怎样。
DROP TABLE IF EXISTS demo2;
CREATE TABLE demo2 (id int PRIMARY KEY, v int);
INSERT INTO demo2 VALUES (1, 100);
UPDATE demo2 SET id = 2 WHERE id = 1;
SELECT lp, t_ctid, t_xmin, t_xmax,
(t_infomask2 & 16384) <> 0 AS hot_updated,
(t_infomask2 & 32768) <> 0 AS heap_only
FROM heap_page_items(get_raw_page('demo2', 0));这次两个标志都是 false——索引列变了,必须新增索引项,走不了 HOT。这就是「频繁更新的列不要建索引」这条经验的机制来源,也是 fillfactor 存在的理由:给页面留出空隙,新版本才有机会落在同一页里,HOT 才有机会发生。
VACUUM 清掉的是什么
Section titled “VACUUM 清掉的是什么”VACUUM demo;
SELECT lp, lp_len, t_ctid, t_xmin, t_xmax
FROM heap_page_items(get_raw_page('demo', 0));(VACUUM 必须单独成块——它不能在事务块里执行,而一个代码块里的多条语句会被包进同一个隐式事务。)
lp = 1 那一行的 lp_len 变成了 0,元组数据被清掉了——但行指针(line pointer)本身还在。它不能被删除,因为可能还有索引项指向这个槽位;空间只能等页面整理时复用。
VACUUM 顺手还做了一件事,它是阶段四的关键前置:
SELECT * FROM pg_visibility_map('demo', 0);all_visible = true 意味着「这一页上所有元组对所有事务都可见」。有了这个标记,索引扫描才敢不回表——因为不回表就无法判断可见性,而可见性映射替它做了判断。这就是 Index-Only Scan 的前提,我们会在阶段四回到这里。
all_frozen 仍然是 false——冻结是另一件事,关系到事务号回卷,那是本阶段后面的内容。
回到那个经典问题
Section titled “回到那个经典问题”现在你应该能自己回答:为什么 Postgres 的 count(*) 比 MySQL 慢?
因为「有多少行」这个问题在 MVCC 下没有全局答案——行数取决于谁在问。同一时刻,事务 A 可能看到 1000 行,事务 B 看到 1002 行。Postgres 不能维护一个计数器,它必须扫描并对每一行做可见性判断。
EXPLAIN (ANALYZE, BUFFERS) SELECT count(*) FROM orders;
先自己回答,再点开对照。答不上来的那条,回到上面对应的小节重看一遍实验。
为什么说 Postgres 的 UPDATE 不是原地修改?旧版本上哪两个字段记录了「它被谁取代了」?
因为 MVCC 要求旧版本在一段时间内仍然可读——比当前事务更早开始的事务可能还需要看到它。所以 UPDATE 做的是「把旧版本标记为失效 + 插入一个新版本」。
旧版本上的两个字段:
t_xmax填成执行更新的那个事务号,表示「从这个事务起我失效了」;t_ctid从指向自己改成指向新版本的位置,成为一个路标。
常见错误:以为 Postgres 像 MySQL/Oracle 那样原地改值、把旧值写进 undo 日志。方向正好相反——Postgres 把新值写在新位置,旧值原地不动。这个差异决定了两边完全不同的运维模型:undo 方案回滚要付出代价、长事务撑爆 undo 空间;Postgres 回滚几乎免费,代价是必须有 VACUUM。
HOT 更新的两个前提条件是什么?为什么它能省掉索引写入?
- 没有任何被索引的列被修改;
- 新版本能放进同一个页面。
条件 1 意味着所有索引项的键值都没变;条件 2 意味着新版本的位置仍在同一页内,可以用页内的 t_ctid 链找到。两者同时成立时,索引项继续指向原来那个槽位就是对的——查询走到那里发现是路标,顺着链往后走即可。索引一个字节都不用改。
常见错误一:以为「只要没改被索引的列就是 HOT」。漏了条件 2——页面填满时新版本只能落到别的页,HOT 立刻失效。这正是 fillfactor 存在的理由。
常见错误二:以为 HOT 省的是「那一个索引」的写入。实际上一旦破坏条件,所有索引都要新增条目,不只是被改的那列上的那个。一张有 5 个索引的表,破坏 HOT 意味着 5 次索引写入。
VACUUM 之后行指针为什么不能一并删除?
因为可能还有索引项指向这个槽位。索引记录的是「键值 → (页号, 槽位号)」,如果槽位被删除并让后面的槽位号前移,所有指向它们的索引项就全错了。
所以 VACUUM 只清掉槽位里的元组数据(lp_len 变成 0),槽位本身保留。要真正回收这些槽位,得先确认没有索引再指向它们——这需要一次完整的索引扫描,是 VACUUM 第二阶段做的事。
常见错误:以为「VACUUM 之后页面就干净了,空间立刻可用」。行指针数组只增不减,它本身也占空间(每项 4 字节)。一张反复增删的表,即使当前行数很少,行指针数组也可能很长——这是表膨胀里容易被忽略的一部分,只有 VACUUM FULL 或 pg_repack 重写整表才能压缩。
all_visible 标记和 Index-Only Scan 是什么关系?
索引项里不存 xmin / xmax,所以光看索引无法判断某一行对当前事务是否可见,只能回堆表查元组头。
可见性映射里的 all_visible 位表示「这一页上所有元组对所有事务都可见」。有了这个保证,索引扫描就可以跳过回表——因为不管怎么判断,答案都是可见。
所以 Index-Only Scan 不是一个能单独打开的开关,它是 VACUUM 维护出来的副产品。
常见错误:以为「只要建了覆盖索引就能走 Index-Only Scan」。索引覆盖只是必要条件——VACUUM 没跑、或者被长事务钉住,可见性映射就是空的,再完美的覆盖索引也会退化成 Bitmap Heap Scan。生产环境里「同一条查询昨天快今天慢」,相当一部分就是这条链断在了 VACUUM 那一环。