跳转到内容

HOT 更新与 fillfactor

准备中…

上一节我们看到,UPDATE 会在页面里留下旧版本、写入新版本。这里有个直接的推论:如果这张表上有 5 个索引,每次 UPDATE 是不是都要写 5 次索引?

答案是「不一定」——这就是 HOT(Heap-Only Tuple)机制存在的意义。

一次更新能走 HOT,必须同时满足:

  1. 没有任何被索引的列被修改
  2. 新版本能放进同一个页面

条件 1 的道理很直白:索引项指向的是「某个键值 → 某个 ctid」。键没变,索引就不需要新增条目。

条件 2 就有意思了——它把「索引维护成本」和「页面里还剩多少空隙」这两件看似无关的事绑在了一起。

fillfactor 决定 INSERT最多把页面填到百分之多少,剩下的留给后续更新。堆表默认是 100(填满)。

先建一张填满的表:

CREATE EXTENSION IF NOT EXISTS pageinspect;
DROP TABLE IF EXISTS hot100;
CREATE TABLE hot100 (id int PRIMARY KEY, v int, pad text) WITH (fillfactor = 100);
INSERT INTO hot100 SELECT i, 0, repeat('x', 200) FROM generate_series(1, 30) i;
SELECT pg_relation_size('hot100') / 8192 AS 页数;
⌘/Ctrl + Enter

30 行、每行约 200 字节,正好塞进 1 个页面。现在做三轮全表更新(v 不是索引列,条件 1 满足):

UPDATE hot100 SET v = v + 1;
UPDATE hot100 SET v = v + 1;
UPDATE hot100 SET v = v + 1;
⌘/Ctrl + Enter
SELECT pg_relation_size('hot100') / 8192 AS 页数,
     (SELECT count(*)
      FROM generate_series(0, pg_relation_size('hot100') / 8192 - 1) p,
           LATERAL heap_page_items(get_raw_page('hot100', p::int))
      WHERE (t_infomask2 & 32768) <> 0) AS HOT元组数;
⌘/Ctrl + Enter

再建一张留了 30% 空隙的对照表:

DROP TABLE IF EXISTS hot70;
CREATE TABLE hot70 (id int PRIMARY KEY, v int, pad text) WITH (fillfactor = 70);
INSERT INTO hot70 SELECT i, 0, repeat('x', 200) FROM generate_series(1, 30) i;
SELECT pg_relation_size('hot70') / 8192 AS 页数;
⌘/Ctrl + Enter

同样三轮更新:

UPDATE hot70 SET v = v + 1;
UPDATE hot70 SET v = v + 1;
UPDATE hot70 SET v = v + 1;
⌘/Ctrl + Enter
SELECT pg_relation_size('hot70') / 8192 AS 页数,
     (SELECT count(*)
      FROM generate_series(0, pg_relation_size('hot70') / 8192 - 1) p,
           LATERAL heap_page_items(get_raw_page('hot70', p::int))
      WHERE (t_infomask2 & 32768) <> 0) AS HOT元组数;
⌘/Ctrl + Enter

两张表放在一起对比:

SELECT 'fillfactor = 100' AS 表,
     pg_relation_size('hot100') / 8192 AS 三轮更新后的页数,
     (SELECT count(*) FROM generate_series(0, pg_relation_size('hot100') / 8192 - 1) p,
             LATERAL heap_page_items(get_raw_page('hot100', p::int))
      WHERE (t_infomask2 & 32768) <> 0) AS HOT元组数
UNION ALL
SELECT 'fillfactor = 70',
     pg_relation_size('hot70') / 8192,
     (SELECT count(*) FROM generate_series(0, pg_relation_size('hot70') / 8192 - 1) p,
             LATERAL heap_page_items(get_raw_page('hot70', p::int))
      WHERE (t_infomask2 & 32768) <> 0);
⌘/Ctrl + Enter

实测结果:

初始页数 三轮更新后 HOT 元组数
fillfactor = 100 1 4 33
fillfactor = 70 2 3 63

结果比直觉更极端:fillfactor = 70 一开始多占了一个页面(30% 空着),三轮更新之后反而总页数更少(3 对 4),HOT 元组数接近前者的两倍。

原因是留出来的空隙让新版本能落在原页里,既省掉了索引写入,也避免了不断向文件末尾追加新页。这个例子里,为空隙付出的那一页在三轮更新内就赚回来了。

索引项只记录了最初那个 ctid(比如 (0,1))。查询走索引到 (0,1),发现它带着「被 HOT 更新过」的标记,就顺着 t_ctid 一路往后走,直到找到对当前快照可见的那个版本。

这条链有代价:链越长,每次索引查找要多跳几次VACUUM 会做「HOT 链剪枝」(pruning)——把中间的死版本清掉,并把最前面那个行指针改成重定向指针,让它直接指向链尾。

亲眼看一下剪枝效果:

VACUUM hot70;
⌘/Ctrl + Enter
SELECT lp,
     lp_flags,
     CASE lp_flags
       WHEN 0 THEN '未使用' WHEN 1 THEN '正常元组'
       WHEN 2 THEN '重定向' WHEN 3 THEN '死指针'
     END AS 行指针类型,
     lp_off AS 重定向时这里存的是目标槽位
FROM heap_page_items(get_raw_page('hot70', 0))
ORDER BY lp
LIMIT 8;
⌘/Ctrl + Enter
SELECT count(*) FILTER (WHERE lp_flags = 1) AS 正常元组,
     count(*) FILTER (WHERE lp_flags = 2) AS 重定向指针,
     count(*) FILTER (WHERE lp_flags = 1 AND (t_infomask2 & 32768) <> 0) AS 其中是HOT元组
FROM heap_page_items(get_raw_page('hot70', 0));
⌘/Ctrl + Enter

第 0 页上前面那些槽位全部变成了 lp_flags = 2LP_REDIRECT)——它们不再存元组,只存一个「往这边走」的目标槽位。索引完全不用改,因为索引项指向的槽位号没变,只是那个槽位现在是个路牌。

这就是 HOT 的完整闭环:更新时不写索引,清理时也不写索引。

一个容易忽略的细节:TOAST 不破坏 HOT

Section titled “一个容易忽略的细节:TOAST 不破坏 HOT”

如果更新的是一个很大的 textjsonb 字段,它可能被存到 TOAST 表里(阶段六细讲)。主表里只留一个指针,而指针的大小是固定的——所以哪怕字段内容从 1KB 变成 100KB,主表里那一行的长度可能几乎没变,HOT 依然成立。

DROP TABLE IF EXISTS big;
CREATE TABLE big (id int PRIMARY KEY, doc text);
INSERT INTO big SELECT 1, string_agg(md5(i::text), '') FROM generate_series(1, 500) i;
UPDATE big SET doc = (SELECT string_agg(md5((i * 7)::text), '') FROM generate_series(1, 600) i)
WHERE id = 1;
SELECT lp, lp_len,
     (t_infomask2 & 32768) <> 0 AS 是HOT元组,
     (t_infomask & 4) <> 0      AS 有外部TOAST引用
FROM heap_page_items(get_raw_page('big', 0));
⌘/Ctrl + Enter

字段内容从 16000 字符变成 19200 字符(md5 拼出来的随机串,几乎不可压缩),但主表里两个版本都只有 46 字节——里面存的是 TOAST 指针,真正的数据在 TOAST 表里。两个版本都挤在第 0 页,第二个是 HOT 元组。

先自己回答,再点开对照。

HOT 更新的两个前提条件是什么?各自省掉了什么开销?
  1. 没有任何被索引的列被修改——省掉的是所有索引的写入。索引项记的是「键值 → ctid」,键没变,原来那条索引项就还是对的。
  2. 新版本能放进同一个页面——省掉的是向文件末尾追加新页,也让索引项能继续指向原槽位:查询走到那里发现是路标,顺着页内的 t_ctid 链往后走就能找到当前版本。

两个条件是的关系,缺一个就退回普通更新:新增所有索引项 + 可能新开一页。

常见错误:把 HOT 当成一张表的静态属性——「这张表没在更新的列上建索引,所以它走 HOT」。HOT 是每一次更新单独判断的,条件 2 取决于那一刻那一页还剩多少空间。同一张表、同一条 UPDATE 语句,今天走 HOT 明天不走。这个差异在测试环境几乎看不见:刚灌完数据的表页面还有空隙,HOT 比例接近 100%;生产上跑了几个月、页面被反复填满之后,同样的语句 HOT 比例掉到个位数,索引写入量突然翻几倍——而代码一行没改。

(回到上面「HOT 的两个前提」一节)

为什么 fillfactor 会影响索引的写入量?这两件事表面上毫无关系。

中间隔着两跳:fillfactor 决定 INSERT 时页面留多少空隙 → 空隙决定新版本能不能落在同一页 → 落在同一页是 HOT 的条件 2 → HOT 决定索引要不要写

实测对照(30 行、每行约 200 字节、三轮全表更新非索引列):

初始页数 三轮更新后 HOT 元组数
fillfactor = 100 1 4 33
fillfactor = 70 2 3 63

常见错误:以为留空隙是「拿空间换时间」,总体上更费空间,所以默认的 100 更省。实测结果正好相反——fillfactor = 70 一开始多占了一个页面,三轮更新之后总页数反而更少(3 对 4)。原因是留出来的空隙会被新版本反复复用,而填满的页只能不断向文件末尾追加新页。为空隙付出的那一页在三轮更新内就赚回来了,之后每一轮都是净赚。

(回到上面「对照实验:fillfactor 100 vs 70」一节)

什么样的表适合调低 fillfactor?什么样的不适合?
  • 适合:写多读少、且更新的都是非索引列——计数器、状态位、last_seen 时间戳这类。设 70~90,用一点空间换掉大量索引写入。
  • 不适合:只插不改的日志表。空隙永远不会被更新填上,留了就是纯浪费。

常见错误:把判据简化成「更新频繁就调低 fillfactor」。如果更新的恰好是被索引的列,条件 1 已经不成立,留再多空隙也走不了 HOT——空隙照样浪费,索引照样每次都写。这种表该做的第一件事是去掉那个列上的索引,而不是调 fillfactor。两个条件的顺序不能颠倒:fillfactor 只能救条件 2,救不了条件 1。

改了 fillfactor 之后为什么还要 VACUUM FULL 才生效?

因为 fillfactor 只影响之后写入的页面。ALTER TABLE ... SET (fillfactor = 70) 改的是一个参数,它不会去重排已有数据——已经填满的老页仍然是填满的,新版本仍然落不进去。要让现有数据按新 fillfactor 重新排布,必须重写整张表VACUUM FULLpg_repack

常见错误:改完参数跑一次普通 VACUUM,以为这就生效了。普通 VACUUM 只在页面内部回收死元组占的空间并登记进 FSM,它不搬动活元组、不重排页面——一个原本装了 40 行的满页,VACUUM 之后还是装着那些活行,物理布局一个字节没动。表面上看空闲空间确实多了(pg_freespace 有数),于是很容易误判成已经生效,实际 HOT 比例一点没涨。

详见表膨胀与 VACUUM

一张表更新频繁但 HOT 比例很低,第一个该怀疑的原因是什么?

是不是给一个经常变的列建了索引。

这条链是自我强化的:高频更新的列上有索引 → 每次更新都破坏条件 1 → 每次更新都要写所有索引(不只是那一个,一张有 5 个索引的表就是 5 次写入)→ 索引本身开始膨胀 → 索引膨胀又降低缓存命中率。

常见错误:第一反应去怀疑那个大字段——「这张表有个 jsonb,更新之后行变长了,肯定放不下同一页」。大字段通常反而是无辜的:超过阈值的值会被 TOAST 外置,主表里只留一个定长指针。正文那个实验里,字段内容从 16000 字符涨到 19200 字符,主表里两个版本都只有 46 字节,第二个照样是 HOT 元组。真正撑爆条件 2 的是页面里堆积的旧版本,不是某个大字段。

详见 TOAST