KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
00 · 一条 UPDATE 改了什么 — keel 龙骨
先说一个我自己踩过的坑。早些年排查一个「表越用越大但行数没变」的问题,我第一反应是索引膨胀、或者是没删干净的历史数据。查到最后发现表里从头到尾就一行——一条被业务反复更新的计数器。那一刻才真正接受一件事:在 PostgreSQL 里,UPDATE 不是把那一行改掉,而是把旧版本标死、再写一个新版本。
先说一个我自己踩过的坑。早些年排查一个「表越用越大但行数没变」的问题,我第一反应是索引膨胀、或者是没删干净的历史数据。查到最后发现表里从头到尾就一行——一条被业务反复更新的计数器。那一刻才真正接受一件事:在 PostgreSQL 里,UPDATE 不是把那一行改掉,而是把旧版本标死、再写一个新版本。
这一章不讲 SQL 语法,只做一件事:把一次 UPDATE 在磁盘上留下的全部痕迹摊开,看清楚 xmin / xmax / ctid 这三个字段各是什么,以及这套设计到底换来了什么。
现场
我在 labpg 库里建了一张最小的表,插一行,然后把它改一次:
CREATE TABLE t (id int PRIMARY KEY, note text, amount numeric);
INSERT INTO t (id, note, amount) VALUES (1, 'v1', 100.00);
SELECT ctid, xmin, xmax, id, note, amount FROM t;
-- ctid | xmin | xmax | id | note | amount
-- -------+------+------+----+------+--------
-- (0,1) | 741 | 0 | 1 | v1 | 100.00
UPDATE t SET note = 'v2', amount = amount + 1 WHERE id = 1;
SELECT ctid, xmin, xmax, id, note, amount FROM t;
-- ctid | xmin | xmax | id | note | amount
-- -------+------+------+----+------+--------
-- (0,2) | 742 | 0 | 1 | v2 | 101.00
物理位置从 (0,1) 挪到了 (0,2),xmin 从 741 变成 742。如果只看这一张表,你会以为 PG 把行搬了个家顺带改了字段。要证明旧版本没被覆盖,得直接去读堆页里的行指针。
磁盘上到底存了几个版本
pageinspect 扩展能把数据页拆到行指针级别。同一时刻读一次:
SELECT lp, t_xmin, t_xmax, t_ctid, t_infomask2, t_infomask
FROM heap_page_items(get_raw_page('t', 0));
-- lp | t_xmin | t_xmax | t_ctid | t_infomask2 | t_infomask
-- ----+--------+--------+--------+-------------+------------
-- 1 | 741 | 742 | (0,2) | 16387 | 1282
-- 2 | 742 | 0 | (0,2) | 32771 | 10498
-- (2 rows)
一行数据、两个行指针。这就把「更新是追加」这件事钉死了:页面上躺着两个物理版本,lp=1 是旧的,lp=2 是新的。两个版本都要能被「某些人」看到——这正是 MVCC 的起点,第 01 章会细讲谁能看到谁,这里先把结构记住。
三个字段的含义:
| 字段 | 是什么 | 本例取值说明 |
|---|---|---|
xmin |
创建这个版本的事务号 | 旧版本 741,新版本 742 |
xmax |
删除/改死这个版本的事务号,0 表示还没被删 | 旧版本 742(被 742 改死),新版本 0 |
ctid |
物理位置,格式 (页号,行指针序号) |
新版 (0,2) 在第 0 页第 2 个行指针 |
注意 ctid 是物理地址,会随 VACUUM FULL 重排、会随页面分裂变化,绝对不要拿它当主键用。旧版本的 ctid 写的是 (0,2) 而不是它自己的地址,这不是写错——那是「本行的更新后版本去哪了」的指针,跨版本链就是靠它串起来的。
位标志告诉你的更多信息
t_infomask / t_infomask2 是十六进制位掩码,按 PG 头文件 htup_details.h 的位定义拆开看:
lp=1 旧版本:t_infomask2 = 16387 = 0x4003
0x4000 HEAP_HOT_UPDATED(本行被 HOT 更新,原地链接后续版本)
0x0003 natts = 3(3 个字段)
t_infomask = 1282 = 0x0502
0x0400 HEAP_XMAX_COMMITTED(改死它的事务已提交)
0x0100 HEAP_XMIN_COMMITTED(创建它的事务已提交)
0x0002 HEAP_HASVARWIDTH(含变长字段,text)
lp=2 新版本:t_infomask2 = 32771 = 0x8003
0x8000 HEAP_ONLY_TUPLE(是个 HOT 链上的「只存在于堆」的版本)
t_infomask = 10498 = 0x2902
0x2000 HEAP_UPDATED(本身是更新的产物)
0x0800 HEAP_XMAX_INVALID(xmax=0,没被删)
0x0100 HEAP_XMIN_COMMITTED
0x0002 HEAP_HASVARWIDTH
HEAP_ONLY_TUPLE 这个词是理解 HOT 的钥匙。这次 UPDATE 只改了 note 和 amount,主键 id 没动,所以 id 上的主键索引不需要新增条目——索引仍然指向 (0,1),而 (0,1) 顺着 ctid 指到 (0,2)。这种「不碰任何索引」的更新就叫 HOT(Heap-Only Tuple)更新。执行计划里和 pg_stat_user_tables 里都能看到它。
三步链路
上面那件事在 PG 内部是一条固定的流水线,画成纵向链路:
flowchart TD
SQL["UPDATE t SET note='v2' WHERE id=1"] --> PARSE["解析 + 规划<br/>选中主键索引定位到 ctid (0,1)"]
PARSE --> PIN["把页 0 读进共享缓冲区<br/>(若不在内存则从磁盘读)"]
PIN --> HOT{"新版本里<br/>带索引的列变了吗?"}
HOT -->|"没变:走 HOT"| NEWT["在同一页申请新行指针<br/>写新版本 lp=2<br/>把 lp=1 的 xmax 置为当前事务号"]
HOT -->|"变了:普通更新"| NEWT2["新版本可能写入别的页<br/>每条索引都要插新条目"]
NEWT --> WAL["把 Heap INSERT/UPDATE 记录写进 WAL<br/>pg_wal 里的记录,先于数据页落盘"]
NEWT2 --> WAL
WAL --> COMMIT["COMMIT:WAL 刷盘(synchronous_commit=on)"]
COMMIT --> VIS["其他事务按快照判断<br/>该看到 lp=1 还是 lp=2"]
WAL -.->|"提交前崩溃"| LOST["新版本不进入可见集<br/>旧版本仍是唯一有效版本"]
COMMIT -.->|"autovacuum 未跑"| DEAD["lp=1 长期占位<br/>表现为 n_dead_tup 上涨"]
style SQL fill:#e3f2fd,color:#0d3b66
style PARSE fill:#e3f2fd,color:#0d3b66
style PIN fill:#e8f5e9,color:#1b5e20
style HOT fill:#fff3e0,color:#8a4b00
style NEWT fill:#e8f5e9,color:#1b5e20
style NEWT2 fill:#e8f5e9,color:#1b5e20
style WAL fill:#ffe0b2,color:#8a4b00
style COMMIT fill:#ffe0b2,color:#8a4b00
style VIS fill:#f1f8e9,color:#33691e
style LOST fill:#ffebee,color:#b71c1c
style DEAD fill:#ffebee,color:#b71c1c
第 1 跳是定位,第 2 跳是在内存里造新版本,第 3 跳是 WAL 与提交。真正决定「改动会不会丢」的不是第 2 跳,而是第 3 跳的刷盘时机——所以第 03 章整章都在讲 WAL。
表级统计怎么对得上
单看一行不容易意识到规模的代价。pg_stat_user_tables 会把这件事累计起来:
SELECT relname, n_live_tup, n_dead_tup, n_tup_ins, n_tup_upd, n_tup_hot_upd
FROM pg_stat_user_tables WHERE relname = 't';
-- relname | n_live_tup | n_dead_tup | n_tup_ins | n_tup_upd | n_tup_hot_upd
-- ---------+------------+------------+-----------+-----------+---------------
-- t | 1 | 1 | 1 | 1 | 1
n_live_tup=1、n_dead_tup=1:逻辑上一行,物理上两个版本,其中一个已是死元组。n_tup_hot_upd=1 和 n_tup_upd=1 相等,说明这次更新完全走了 HOT。
这里有个新手很容易误判的点:这两列不是精确计数。它们是统计收集器异步汇总的,而且刚提交完在另一个会话里查可能还是 0。第一次跑这个实验时我就被坑了——同一条 SQL,在事务里查是 0,换一个连接查才变成 1。看统计数字之前,先确认它是不是已经刷新。
主动破坏:把「再加一次更新」和「回滚」跑一遍
两个动作值得亲手做一次。
第一,连续更新同一行,观察 ctid 会不会一直往后走。答案是「走一段就回到页首」——因为 HOT 链在页内复用空间,这也是下一章 VACUUM 要讲的现象。
第二,把更新回滚掉:
BEGIN;
UPDATE t SET note = 'v3' WHERE id = 1;
SELECT xmin, xmax, ctid FROM t; -- 事务内能看到自己写的新版本
ROLLBACK;
SELECT xmin, xmax, ctid FROM t; -- 回到 v2 那个版本
回滚之后 (0,2) 这个版本会被标成 xmax=当前事务号 且该事务中止,从此永远不可见。它不会立刻消失,会一直占着页面直到被清理。「回滚了就等于没写」只对逻辑结果成立,对物理空间不成立——这是后面判断膨胀时最容易搞混的一条。
生产边界
教学环境里主键就一个 int,HOT 很容易命中。真实表常见的差异:
- 每个索引都可能把 HOT 打掉。只要 UPDATE 碰了任何一个被索引覆盖的列,就必须为新版本补索引条目,行也就不能只在堆里存在。一张表上索引越多,更新越难 HOT,写放大越明显。
- HOT 只在同一页内有空位时才成立。页写满(默认
fillfactor=100)之后,即使列没变,新版本也会落到新页上,退化成普通更新。对更新密集的表,适当调低fillfactor(比如 80~90)是有依据的优化手段。 n_dead_tup要看长期趋势而不是瞬时值。dead tuple 在 autovacuum 跑之前只涨不跌,单点采样没意义。- 上线要盯的:
pg_stat_user_tables.n_dead_tup / n_live_tup的比值、n_tup_hot_upd / n_tup_upd的比值(HOT 命中率掉下来通常意味着新增了索引或改了索引列)、以及表物理大小与n_live_tup的背离程度。
动手
- 建一张表,加两个索引,一次只改带索引的列、一次只改不带索引的列,各做 1000 次 UPDATE,比较两次的
n_tup_hot_upd和pg_relation_size。 - 用
heap_page_items打印一页,找出页里所有t_xmax <> 0的行指针,数一数死版本占比。 - 开一个事务做 UPDATE 但不提交,另开一个会话查
pg_stat_activity里有没有idle in transaction,观察死版本在提交前是否已经产生。
可观察结果:你能用 n_tup_hot_upd / n_tup_upd 解释「这张表为什么涨得快」,并能指出是哪一条索引把它拖出了 HOT 通道。
自测
- 为什么一个只有 1 行的表,物理上会有 2 个行版本?这两个版本分别由哪个字段区分?
ctid在旧版本里指向新版本,而不是指向它自己——如果不这么设计,MVCC 会缺掉什么信息?- 什么条件下一次 UPDATE 不会产生新的索引条目?这个条件被破坏时会额外付出什么代价?
- 回滚一个 UPDATE 之后,磁盘占用会立刻下降吗?为什么?
- 同一张
pg_stat_user_tables查询,在事务内和另一个连接里结果不一样,最可能的原因是什么?
↓ 下一步:01 章 · MVCC 与隔离级别 —— 两个版本都在磁盘上了,那到底谁该看到谁?这由快照决定。