跳转到内容

一次 UPDATE 之后,旧数据去哪了

准备中…

大多数人对 UPDATE 的心智模型是「找到那一行,把值改掉」。在 Postgres 里这个模型是错的,而且错得很关键——它是表膨胀、VACUUM、长事务危害、count(*) 慢这一整串现象的共同源头。

我们直接把页面挖开看。

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');
⌘/Ctrl + Enter

Postgres 给每一行都维护三个系统列,平时不显示,但可以显式查出来:

SELECT ctid, xmin, xmax, id, v FROM demo;
⌘/Ctrl + Enter
  • ctid物理位置,格式是 (页号, 页内槽位)。注意它不是主键——它会变。
  • xmin插入这一行的事务号
  • xmax删除/更新这一行的事务号0 表示还没有。

现在做一次最普通的更新:

UPDATE demo SET v = 200 WHERE id = 1;

SELECT ctid, xmin, xmax, id, v FROM demo;
⌘/Ctrl + Enter

ctid(0,1) 变成了 (0,2)xmin 也换了一个更大的事务号。这一行换了物理位置

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));
⌘/Ctrl + Enter

页面上有两个元组

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 更新的意义所在。

刚才那次更新,hot_updatedheap_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));
⌘/Ctrl + Enter

这次两个标志都是 false——索引列变了,必须新增索引项,走不了 HOT。这就是「频繁更新的列不要建索引」这条经验的机制来源,也是 fillfactor 存在的理由:给页面留出空隙,新版本才有机会落在同一页里,HOT 才有机会发生。

VACUUM demo;
⌘/Ctrl + Enter
SELECT lp, lp_len, t_ctid, t_xmin, t_xmax
FROM heap_page_items(get_raw_page('demo', 0));
⌘/Ctrl + Enter

VACUUM 必须单独成块——它不能在事务块里执行,而一个代码块里的多条语句会被包进同一个隐式事务。)

lp = 1 那一行的 lp_len 变成了 0,元组数据被清掉了——但行指针(line pointer)本身还在。它不能被删除,因为可能还有索引项指向这个槽位;空间只能等页面整理时复用。

VACUUM 顺手还做了一件事,它是阶段四的关键前置:

SELECT * FROM pg_visibility_map('demo', 0);
⌘/Ctrl + Enter

all_visible = true 意味着「这一页上所有元组对所有事务都可见」。有了这个标记,索引扫描才敢不回表——因为不回表就无法判断可见性,而可见性映射替它做了判断。这就是 Index-Only Scan 的前提,我们会在阶段四回到这里。

all_frozen 仍然是 false——冻结是另一件事,关系到事务号回卷,那是本阶段后面的内容。

现在你应该能自己回答:为什么 Postgres 的 count(*) 比 MySQL 慢?

因为「有多少行」这个问题在 MVCC 下没有全局答案——行数取决于谁在问。同一时刻,事务 A 可能看到 1000 行,事务 B 看到 1002 行。Postgres 不能维护一个计数器,它必须扫描并对每一行做可见性判断。

EXPLAIN (ANALYZE, BUFFERS) SELECT count(*) FROM orders;
⌘/Ctrl + Enter

先自己回答,再点开对照。答不上来的那条,回到上面对应的小节重看一遍实验。

为什么说 Postgres 的 UPDATE 不是原地修改?旧版本上哪两个字段记录了「它被谁取代了」?

因为 MVCC 要求旧版本在一段时间内仍然可读——比当前事务更早开始的事务可能还需要看到它。所以 UPDATE 做的是「把旧版本标记为失效 + 插入一个新版本」。

旧版本上的两个字段:

  • t_xmax 填成执行更新的那个事务号,表示「从这个事务起我失效了」;
  • t_ctid 从指向自己改成指向新版本的位置,成为一个路标。

常见错误:以为 Postgres 像 MySQL/Oracle 那样原地改值、把旧值写进 undo 日志。方向正好相反——Postgres 把新值写在新位置,旧值原地不动。这个差异决定了两边完全不同的运维模型:undo 方案回滚要付出代价、长事务撑爆 undo 空间;Postgres 回滚几乎免费,代价是必须有 VACUUM

回到「旧版本并没有消失」

HOT 更新的两个前提条件是什么?为什么它能省掉索引写入?
  1. 没有任何被索引的列被修改
  2. 新版本能放进同一个页面

条件 1 意味着所有索引项的键值都没变;条件 2 意味着新版本的位置仍在同一页内,可以用页内的 t_ctid 链找到。两者同时成立时,索引项继续指向原来那个槽位就是对的——查询走到那里发现是路标,顺着链往后走即可。索引一个字节都不用改。

常见错误一:以为「只要没改被索引的列就是 HOT」。漏了条件 2——页面填满时新版本只能落到别的页,HOT 立刻失效。这正是 fillfactor 存在的理由。

常见错误二:以为 HOT 省的是「那一个索引」的写入。实际上一旦破坏条件,所有索引都要新增条目,不只是被改的那列上的那个。一张有 5 个索引的表,破坏 HOT 意味着 5 次索引写入。

详见 HOT 更新与 fillfactor

VACUUM 之后行指针为什么不能一并删除?

因为可能还有索引项指向这个槽位。索引记录的是「键值 → (页号, 槽位号)」,如果槽位被删除并让后面的槽位号前移,所有指向它们的索引项就全错了。

所以 VACUUM 只清掉槽位里的元组数据(lp_len 变成 0),槽位本身保留。要真正回收这些槽位,得先确认没有索引再指向它们——这需要一次完整的索引扫描,是 VACUUM 第二阶段做的事。

常见错误:以为「VACUUM 之后页面就干净了,空间立刻可用」。行指针数组只增不减,它本身也占空间(每项 4 字节)。一张反复增删的表,即使当前行数很少,行指针数组也可能很长——这是表膨胀里容易被忽略的一部分,只有 VACUUM FULLpg_repack 重写整表才能压缩。

回到「VACUUM 清掉的是什么」

all_visible 标记和 Index-Only Scan 是什么关系?

索引项里不存 xmin / xmax,所以光看索引无法判断某一行对当前事务是否可见,只能回堆表查元组头。

可见性映射里的 all_visible 位表示「这一页上所有元组对所有事务都可见」。有了这个保证,索引扫描就可以跳过回表——因为不管怎么判断,答案都是可见。

所以 Index-Only Scan 不是一个能单独打开的开关,它是 VACUUM 维护出来的副产品。

常见错误:以为「只要建了覆盖索引就能走 Index-Only Scan」。索引覆盖只是必要条件——VACUUM 没跑、或者被长事务钉住,可见性映射就是空的,再完美的覆盖索引也会退化成 Bitmap Heap Scan。生产环境里「同一条查询昨天快今天慢」,相当一部分就是这条链断在了 VACUUM 那一环。

详见优化器为什么不用你的索引