跳转到主要内容

7 让更新保持 HOT

测量非 HOT 更新的真实代价,了解 HOT 的确切规则,并设计表、索引和 fillfactor,让更新保持低成本。

复制一份带有六个索引的 users,更新其中 5,000 行,并递增一个没有任何索引覆盖的计数器。Postgres 写入 438 KB 的 WAL。再递增同一个计数器,同时更新 updated_at(它有索引),就像大多数 ORM 每次保存时做的那样;同样的 5,000 行会写入 2.96 MB,几乎是七倍。逐行更新时差距还会扩大:一次更新分别是 127 字节和 15,327 字节,因为其中两张被触及的索引页必须整页记录。区别就在于这次更新是不是 HOT。

每次更新都会把一份完整的新行版本写到新地址(更新会写入完整的新行),而索引指向地址。如果不做任何特殊处理,每次更新都会在每个索引中写入一个新条目。HOT 是 heap-only tuple(堆内元组)的缩写,是一条逃生通道:新版本留在同一页,索引完全不用知道它的存在。

你从来不用打开 HOT。它一直处于启用状态,而模式决定它能多常发挥作用:你给哪些列建索引、页填得有多满,以及事务保持打开的时间。把这三点做好,几乎每次更新都能跳过索引。

冷更新要为每个索引付费

把不是 HOT 的更新称为冷更新。它会写入一个新元组(完整的一行),在每个索引中新增一个条目,即使那些索引覆盖的列没有变化,还要为这一切写入 WAL。HOT 更新只写元组及其 WAL。就这些。

HOT 把新版本留在索引已经能找到的位置。冷更新把新版本放到另一页,并在每个索引中增加一个条目。HOT 更新把新版本写到同一页,通过 t_ctid 从旧版本串起来,不改变索引;页内剪枝后,lp1 会变成指向新版本的重定向。
图 7-1 HOT 把新版本留在索引已经能找到的位置。冷更新把新版本放到另一页,并在每个索引中增加一个条目。HOT 更新把新版本写到同一页,通过 t_ctid 从旧版本串起来,不改变索引;页内剪枝后,lp1 会变成指向新版本的重定向。

下面是完整的开场实验。我们复制了一份 users,让每一页留下 10% 的空闲空间(下面会讲 fillfactor 90),然后用两种方式更新其中的 5,000 行;表上分别有一个、三个和六个索引,并避开任何检查点。HOT 更新递增 login_count,没有索引覆盖它。冷更新还更新了有索引的 updated_at(只有一个索引时,该索引就是建在 updated_at 上的):

索引数HOT WAL冷更新 WAL冷更新 / HOTHOT 缓冲区冷更新缓冲区
1438 KB999 KB2.3x11,37021,504
3438 KB1.91 MB4.5x11,37046,906
6438 KB2.96 MB6.9x11,37087,026
表 7-1 冷更新要为每个索引付费

先看 HOT 那几列:无论索引数量是多少,都是 438 KB 和 11,370 个缓冲区。HOT 更新看不见你的索引。每增加一个索引,冷更新一侧就大约多出一次 HOT 更新量的 WAL;索引数为六时,冷批次触及的缓冲区是 HOT 的 7.7 倍。

账单还会继续。HOT 更新之后的清理,没有索引条目需要移除;冷更新之后的清理则读取了全部六个索引的每一页。每个副本也都要重放同一份 WAL(Uber 遇到了这种不对称,而且双方都有道理)。

动手验证 · lab 06-hot
select lab.prove(
  'second batch, cold vs HOT: about 6.9x with six indexes',
  m('c6 batch 2', 'bytes') / m('h6 batch 2', 'bytes') between 6.2 and 7.6);
select lab.prove(
  'the HOT batch touches the same buffers in every configuration; cold with six indexes about 7.7 times as many',
  m('h1 batch 2', 'buffers') = m('h6 batch 2', 'buffers')
  and m('c6 batch 2', 'buffers') / m('h6 batch 2', 'buffers') between 7.0 and 8.3);
select lab.prove(
  'after cold updates, vacuum reads every page of all six indexes',
  (select idx_blocks from vacuum_cost where tbl = 'c6')
    >= pg_indexes_size('c6') / 8192 - 5);

完整实验脚本

深入阅读:检查点之后,每个索引条目都是一张彩票

pg_walinspect 扩展(PostgreSQL 15+)会从日志中读回记录。我们在检查点之后,对这张六索引表运行了四次单行更新,交替执行 HOT 和冷更新:

 update |   record_type   |         relname          | record_length | fpi_length
--------+-----------------+--------------------------+---------------+------------
 1 HOT  | HOT_UPDATE      | users_hot                |         15694 |      15616
 2 cold | UPDATE          | users_hot                |          7497 |       7424
 2 cold | INSERT_LEAF     | users_hot_updated_at_idx |          7453 |       7400
 ...      (five more index records, each about 7,450 bytes)
 3 HOT  | PRUNE_ON_ACCESS | users_hot                |            56 |          0
 3 HOT  | HOT_UPDATE      | users_hot                |            71 |          0
 4 cold | PRUNE_ON_ACCESS | users_hot                |            58 |          0
 4 cold | UPDATE          | users_hot                |            81 |          0
 4 cold | INSERT_LEAF     | users_hot_updated_at_idx |          7453 |       7400
 4 cold | INSERT_LEAF     | users_hot_pkey           |            64 |          0
 ...
  • **更新 1,HOT:**一条记录携带两份整页镜像(堆和可见性映射),因为自上次检查点以来,这两个页都是第一次被触及(检查点后的第一次变化会记录整页)。
  • **更新 2,冷更新:**七条记录,一条堆记录和每个索引各一条,每条都有自己的 8 KB 镜像。共 52,359 字节,约 51 KB。
  • **更新 3,HOT:**71 字节,加上一条 56 字节的剪枝记录,共 127 字节。
  • **更新 4,冷更新:**七条记录,加上剪枝记录。其中四次索引插入命中了已经有镜像的叶页(64 到 80 字节);另外两次没有命中,每次都付出了 7,400 字节。共 15,327 字节。

冷更新每个索引多出一条记录,而每条记录都有机会带来一份 8 KB 镜像。在检查点后的第一个 5,000 行批次中,镜像主导了两种更新:HOT 写入 10.0 MB,六个索引的冷更新在 35,250 条记录中写入 24.3 MB(每行一条堆记录、每个索引一条记录,此外还有 250 次去重遍历)。每个副本都要接收这 24.3 MB。

动手验证 · lab 06-hot
select lab.prove(
  'right after a checkpoint, the HOT update is one record carrying two full-page images (heap and visibility map)',
  m('row 1 hot', 'records') = 1 and m('row 1 hot', 'fpi') = 2);
select lab.prove(
  'every one of those seven records carried an 8 KB page image: about 51 KB',
  m('row 2 cold', 'fpi') = 7 and m('row 2 cold', 'bytes') between 50000 and 55000);
select lab.prove(
  'a moment later a HOT update costs about 127 bytes: the update plus a small prune record, no images',
  m('row 3 hot', 'records') = 2 and m('row 3 hot', 'fpi') = 0
  and m('row 3 hot', 'bytes') between 100 and 160);
select lab.prove(
  'cold batches write one more record per row per index: 10,000, about 20,250 and about 35,250',
  m('c1 batch 1', 'records') = 10000
  and m('c3 batch 1', 'records') between 20000 and 20500
  and m('c6 batch 1', 'records') between 35000 and 35500);

完整实验脚本

HOT 需要一次未被索引覆盖的变化,以及页上的空间

更新因为两个原因之一而无法成为 HOT,修复方式取决于具体是哪一个。我们先给它们命名,后面整章都会使用这些名称:

  • 更新没有改变任何索引列时,我们称它为 HOT-safe(对 HOT 而言是安全的)。(从 PostgreSQL 16 起,只被BRIN索引覆盖的列不计入。)
  • 更新同时满足 HOT-safe,且新版本能和旧版本放在同一页上时,它才是 HOT。

索引提到的每一列都算作已索引

“已索引”指的是出现在任何索引定义中的任何位置,而不只是键列。我们给一份 projects 副本建立了五种索引:

create index on projects_hot (account_id) where archived_at is null;  -- partial
create index on projects_hot (account_id) include (name);             -- covering
create index on projects_hot (lower(slug));                           -- expression
create index on projects_hot using brin (created_at);                 -- summarizing
create index on projects_hot (budget);

然后一次只运行一项更新,并观察 n_tup_hot_upd 在 pg_stat_user_tables 中的变化:

更新你可能期待它成为 HOT 的原因HOT?
set archived_at = now()没有索引存储 archived_at否
set name = name || ' (old)'name 只在 INCLUDE 中否
set slug = upper(slug)lower(upper(slug)) 的值相同否
set created_at = created_at - interval '1 day'只有 BRIN 索引覆盖它是
set budget = 1.00(原为 1.0)作为数字相等否
set name = name, account_id = account_id, budget = budget没有任何变化是
表 7-2 索引提到的每一列都算作已索引

把这些“否”当作警告。部分索引的谓词也算,所以在部分索引中使用软删除列,会让每次归档和恢复都变成冷更新。INCLUDE 列也算:覆盖索引会让它所携带的列失去 HOT。表达式索引算的是列本身,不是结果。BRIN 指向页范围而不是行,因此不需要新的指针。

发生变化意味着字节不同

“变化”不是 SET 列表,也不是 SQL 相等性:Postgres 会比较每个索引列的新旧字节。因此 set name = name 满足 HOT-safe,这对每次都把所有列写回去的 ORM 是个好消息。而 1.0 变成 1.00 虽然数值相等,却是冷更新,因为字节不同。

HOT 版本永远不会离开它所在的页

第二个条件是物理条件。HOT 版本永远不会离开它所在的页,所以落到该页的查找就能在那里找到它。如果新版本放不下,它就会去别的地方,并在每个索引中取得一个条目。你可以用下面要讲的 fillfactor 控制这部分空间。

深入阅读:索引尚未建完,就已经开始让 HOT 付出代价

某列一旦存在索引,即使索引还在用 create index concurrently 构建、尚未有效,该列的更新也会停止成为 HOT。并发构建会等待正在运行的事务结束,正是为了确保开始构建前,每个会话都已把该列视为已索引。如果构建失败并留下一个无效索引,就把它删掉:我们的索引从未完成,却仍然让该列的更新变成冷更新。

动手验证 · lab 06-hot
select lab.prove('an invalid, never-finished index still makes updates of its column cold',
  m('invalid indexed', 'hot') = 0 and m('invalid indexed', 'newpage') = 0);
select lab.prove('(control) updating an unindexed column of the same table is HOT',
  m('invalid other', 'hot') = 1);

完整实验脚本

实验还会检查规则表中的每一行。

动手验证 · lab 06-hot
select lab.prove('INCLUDE columns count: renaming is cold',
  m('include', 'updated') = 1 and m('include', 'hot') = 0);
select lab.prove('a column covered only by BRIN stays HOT (PostgreSQL 16+)',
  m('brin', 'hot') = 1);
select lab.prove('numeric 1.0 to 1.00 is cold: equal as numbers, different bytes',
  m('scale', 'hot') = 0
  and 1.0::numeric = 1.00::numeric
  and numeric_send(1.0) <> numeric_send(1.00));
select lab.prove('set x = x on indexed columns stays HOT: the bytes did not change',
  m('same', 'hot') = 1);

完整实验脚本

HOT 查找会沿着版本链走,不离开所在页

HOT 更新就像贴在旧公寓门上的转交便签。索引保留着旧地址;旧版本写着“搬到了同一栋楼的槽位 3”,查找顺着这张便签走,不必离开这一页。通过 t_ctid 字段连接起来的各个版本,组成一条 HOT 链。lp 编号就是行指针,即页顶端编号的槽位(行指针让元组移动而不改变地址):

 1  INSERT              index ──► lp1 [v1]

 2  HOT UPDATE x2       index ──► lp1 [v1] ─► lp2 [v2] ─► lp3 [v3]
                                     t_ctid       t_ctid

 3  prune               index ──► lp1 REDIRECT ──────────► lp3 [v3]
    (v1, v2 dead              lp2 UNUSED (free, reusable)
     to everyone)

 4  DELETE, prune       index ──► lp1 DEAD     lp3 UNUSED

 5  VACUUM              index entry removed; lp1 UNUSED

 cold UPDATE at any step: new version on another page, plus a
 new entry in EVERY index; old entries wait for vacuum.
  • **索引永远不会移动。**查找落到 lp1,然后沿着版本链走到快照可见的版本:无论链有多长,都只需一张索引页和一张堆页。
  • **页内剪枝会把版本链变成重定向。**一旦旧版本对每个快照都已死亡,下一个访问者就会剪枝(只有没有快照能看到的版本才是死版本),让 lp1 成为指向最新版本的重定向,不触碰索引。这就是 HOT 价值的一半:空间会在正常流量中回来。
  • **只有清理才能完成工作。**整条链都死亡后,lp1 仍会作为死存根留下,直到清理移除索引条目。

深入阅读:没有索引指向堆内元组

一张带有 128 行短记录的 counters 表,会把一页填到只剩 488 字节。我们让另一个会话保持可重复读快照,然后把第 1 行更新了三次:

 lp  | t_xmin  | t_xmax  | t_ctid  |                 raw_flags
-----+---------+---------+---------+---------------------------------------------
   1 | 1534621 | 1534622 | (0,129) | {...,HEAP_XMAX_COMMITTED,HEAP_HOT_UPDATED}
 129 | 1534622 | 1534623 | (0,130) | {...,HEAP_HOT_UPDATED,HEAP_ONLY_TUPLE}
 130 | 1534623 | 1534624 | (0,131) | {...,HEAP_HOT_UPDATED,HEAP_ONLY_TUPLE}
 131 | 1534624 |       0 | (0,131) | {...,HEAP_ONLY_TUPLE}

HEAP_HOT_UPDATED 表示“我的后继版本是堆内元组,沿着我的 t_ctid 跟过去”;HEAP_ONLY_TUPLE 表示“没有索引指向我”。bt_page_items 显示键 1 只有一个索引条目,仍指向 (0,1);索引扫描读取两个缓冲区。沿着链接走时,扫描会检查下一个版本的 xmin 是否与前一个版本的 xmax 匹配,因为两次访问之间槽位可能已经被释放并复用:ctid 是地址,不是身份。

另一个会话提交后,同一个 select 打印出 WAL: records=1 bytes=64。一次读取剪枝了这一页:槽位 1 现在是指向 131 的重定向(lp_flags 2),129 和 130 未使用(lp_flags 0)。一页最多容纳 291 个行指针,这可以防止小行用存根把页填满。

动手验证 · lab 06-hot
select lab.prove('four versions of row 1, linked by t_ctid: slot 1 to 129 to 130 to 131',
  (select array_agg(t_ctid::text order by lp) from page0 where lp in (1, 129, 130, 131))
  = array['(0,129)', '(0,130)', '(0,131)', '(0,131)']);
select lab.prove('an index scan follows the chain in two buffers: one index page, one heap page',
  lab.buffers($$ select * from counters where id = 1 $$) = 2);

-- session A commits; the next read prunes
select lab.prove('a read query wrote WAL: one small prune record',
  m('prune read', 'records') = 1 and m('prune read', 'bytes') < 100);
select lab.prove('slot 1 is now a redirect (lp_flags 2) to slot 131',
  (select lp_flags = 2 and lp_off = 131 from page0 where lp = 1));

完整实验脚本

页接近填满时才进行页内剪枝

同时满足两个条件时,任何触及这张页的查询都会进行剪枝,包括 select。页上带有由更新或删除设置的提示,表示页中可能有死版本。并且页上的空闲空间小于其 fillfactor 预留空间和页大小 10%(819 字节)两者中的较大值。空间充裕的页不会在访问时剪枝;清理最终会处理它。

剪枝会记录 WAL,所以读取会写入。只读负载产生 WAL 不必惊讶(启用校验和后,连读取也会写 WAL讲的是另一个原因)。

深入阅读:空间充裕的页会被留下

三行数据,对第 1 行更新两次,然后执行一次索引扫描:没有 WAL,page_header() 显示有 7,988 字节空闲空间,并且已经设置了剪枝提示。五个版本全部留下。counters 页一开始有 488 字节空闲空间,低于 819 字节的界线,所以第一次访问时就剪枝了。源码中的规则是 heap_page_prune_opt。

还有两个细节。剪枝需要清理锁(不能有其他人钉住该页),并且不会等待清理锁;因此非常繁忙的页可能跳过剪枝,让一次原本 HOT-safe 的更新转移到其他地方。另外,update 当时不能剪枝它正在更新的行所在的页,所以下一次 HOT 更新所需的空间,通常来自更早的一次访问。

动手验证 · lab 06-hot
select lab.prove('a roomy page is not pruned on access: no WAL, the old versions stay',
  m('roomy read', 'records') = 0
  and (select count(*) from heap_page_items(get_raw_page('roomy', 0)) where lp_flags = 1) = 5);

完整实验脚本

给页留下空间,否则每次更新都会移动

刚加载的表是挤满的。填充因子是每页在继续使用下一页之前允许插入填满的百分比;剩余空间留给更新。它默认是 100,因此刚加载的页没有多余空间,第一次更新无论是否 HOT-safe,都会变成冷更新。你可以按表设置它:

alter table users set (fillfactor = 90);

我们制作了三份 users 副本,都带有 login_count 列,各有两个索引,分别将 fillfactor 设为 100、90 和 70;然后从四个 pgbench 客户端随机更新用户,合计执行 200,000 次单行更新(pgbench 是随 Postgres 提供的负载生成器),并保持自动清理开启:

填充因子HOT移到新页更新前 / 后页数更新后索引大小
10092.2%15,5421,137 / 1,3118,336 KB 到 8,624 KB
90100%01,266 / 1,2668,336 KB,不变
70100%01,613 / 1,6138,336 KB,不变
表 7-3 给页留下空间,否则每次更新都会移动

填充因子为 100 时,有 15,542 次更新变成冷更新;挤满的表最终比填充因子为 90 的表还大,索引也随之增长。它最终还是付出了预留空间的代价,只不过使用了最糟糕的货币。批处理更严苛:在填充因子为 100 的表上,一条更新 10% 行的 update 中 10,000 次更新没有一次是 HOT,写入的 WAL 是同一语句在 90 时的五倍。

动手验证 · lab 06-hot
call random_updates('users_ff100', 50000);
call random_updates('users_ff90', 50000);
select from pg_stat_force_next_flush();

select lab.prove('fillfactor 90: every one of 50,000 single-row updates was HOT, none moved',
  (select n_tup_upd = 50000 and n_tup_hot_upd = 50000 and n_tup_newpage_upd = 0
   from pg_stat_user_tables where relname = 'users_ff90'));
select lab.prove('fillfactor 100: thousands of HOT-safe updates moved to new pages and went cold',
  (select n_tup_newpage_upd between 1000 and 5000
       and n_tup_hot_upd + n_tup_newpage_upd = n_tup_upd
   from pg_stat_user_tables where relname = 'users_ff100'));

完整实验脚本

深入阅读:五个批次后,挤满的表终究也长到了同样大小

在 pgbench 运行中,填充因子为 100 的表现比你预期的好,这是因为第一次更新把一行移走后,剪枝会在每页释放一两个槽位。并发和自动清理的时机都会改变这些数字,所以实验运行了一个可重复的版本:单个会话随机执行 50,000 次单行更新。填充因子为 90 时 HOT 比例保持 100%;填充因子为 100 时约为 95%,并开始增长。批次无法依赖剪枝,因为一条语句会先更新一页上的多行,之后才有机会剪枝。

批处理运行的是在新副本上的 update ... set login_count = login_count + 1 where id % 10 = 0:

填充因子HOTWAL 记录数WAL触及的缓冲区
10010,000 次中 0 次40,4713.5 MB103,024
9010,000 次中 10,000 次10,000703 KB22,532
表 7-4 给页留下空间,否则每次更新都会移动

五个这样的批次之后,填充因子为 100 的表 HOT 比例提高到了 77%,页数恰好达到 1,266,也就是填充因子为 90 的表的大小;为此它付出了 11,365 次冷更新和额外的 43 个索引页。

动手验证 · lab 06-hot
select lab.prove('the packed table wrote about five times the WAL (3.5 MB vs 703 KB)',
  m('bulk_ff100 0', 'bytes') / m('bulk_ff90 0', 'bytes') between 4.5 and 5.6);
select lab.prove('and touched almost five times the buffers (103,024 vs 22,532)',
  m('bulk_ff100 0', 'buffers') / m('bulk_ff90 0', 'buffers') between 4.2 and 5.0);
select lab.prove('and grew to the size of the fillfactor 90 table, 1,266 pages',
  abs(pg_relation_size('bulk_ff100') / 8192 - pg_relation_size('bulk_ff90') / 8192) <= 5);

完整实验脚本

预留空间要先付出代价,之后才会回本

第一天这些预留空间是空的:填充因子为 90 让表大了 11%,为 70 让表大了 42%,而膨胀估算器会把这些预留空间也算作膨胀。像 events 这样的只追加表保持 100。更新很多的表从 90 开始;如果行有几百字节,或者 n_tup_newpage_upd 一直上升,可以降到 70 或 80。

之后再设置只会作用于新页。已有页会保持挤满,直到发生重写(不加锁重建膨胀的表);对于一张正在使用的大表,跳过重写,让新页采用这个设置。

先修索引,再调填充因子

填充因子只帮助 HOT-safe 更新。如果索引覆盖了你更新的列,再多的空闲空间也不能让这些更新成为 HOT。在调整表大小之前,先检查你违反的是哪一半规则。

忙碌的表 HOT 比例应高于 90%

Postgres 会替你统计这些数据。pg_stat_user_tables 有 n_tup_upd(所有更新)、n_tup_hot_upd(HOT 更新)和 n_tup_newpage_upd(PostgreSQL 16+:新版本去了另一页,因此这是冷更新)。留在原页的冷更新说明页上有空间,所以改变了索引列;移动了的冷更新则说明没有空间:

select relname,
       n_tup_upd,
       n_tup_hot_upd                                 as hot,
       n_tup_newpage_upd                             as no_room,
       n_tup_upd - n_tup_hot_upd - n_tup_newpage_upd as index_changed,
       round(100.0 * n_tup_hot_upd / nullif(n_tup_upd, 0), 1) as hot_pct
from pg_stat_user_tables
where n_tup_upd > 0
order by n_tup_upd desc
limit 20;

index_changed 高:索引覆盖了更新会改变的列,填充因子帮不上忙。no_room 高:页太满了;降低填充因子,或者查找长事务(下一节)。两者都高:先修索引,因为即使索引列发生了变化,只要发生移动,也会计入 no_room。

按一天的变化来观察,而不是看全生命周期累计值。在有真实更新流量的表上,超过 90% 是健康的;忙碌表上的低比例是最应该先修的事情。

深入阅读:本章各表的分类,以及读出零值的计数器

在实验数据库中:

   relname    | n_tup_upd |  hot  | no_room | index_changed | hot_pct
--------------+-----------+-------+---------+---------------+---------
 churn        |    300000 |     0 |  127233 |        172767 |     0.0
 users_ff100  |     50000 | 47623 |    2377 |             0 |    95.2
 projects_hot |         7 |     3 |       0 |             4 |    42.9
...

churn 每次更新都会改变索引列,其中 127,233 次还发生了页移动,这就是“先修索引”的原因。后端进程会在事务结束后约一秒内刷新统计信息。在脚本中,先调用 select pg_stat_force_next_flush();,或者在事务内部读取 pg_stat_xact_user_tables。对于一条语句,在随后会回滚的事务中执行 explain (analyze, buffers, wal),可以看到 HOT 大约每行一条 WAL 记录,冷更新则是每行一条再加每个索引一条;在整个负载范围内,pg_stat_statements 会按 wal_bytes 给语句排序。

动手验证 · lab 06-hot
select lab.prove('projects_hot: four cold updates, all because an indexed column changed',
  (select index_changed = 4 and no_room = 0 and hot = 3 from hot_report where relname = 'projects_hot'));

完整实验脚本

长事务会关闭 HOT

页内剪枝只能移除对每个快照都已死亡的版本。数据库中任何地方的一个旧快照,都会让比该快照更新的版本无法被剪枝。旧版本堆积,页被填满,原本 HOT-safe 的更新开始移动,并写入索引条目。

我们在一张全新的填充因子为 90 的表上重复了五个批次,同时让另一个会话停留在一个打开的可重复读事务中:

50,000 次更新,填充因子 90HOT堆页索引页
没有打开的事务100%1,266 到 1,2661,042 到 1,042
另一个会话持有快照22.8%1,266 到 1,7551,042 到 1,429
表 7-5 长事务会关闭 HOT

另一个会话从未碰过这张表。它只持有快照几秒钟,HOT 比例就降到了 23%,表和索引却永久增长了三分之一以上。同一个快照也会阻塞清理(清理无法移除旧快照可能需要的内容)。保持事务短小,并设置 idle_in_transaction_session_timeout。

动手验证 · lab 06-hot
-- session A, over dblink
-- ... five batches of 10,000 updates on held_ff90, then session A commits

select lab.prove('with session A holding a snapshot, the HOT rate fell to about 23 percent',
  (select sum((m ->> 'hot')::int) from measurements where name like 'held_ff90 %')
    between 9000 and 14000);
select lab.prove('the table grew about 39 percent (1,266 to about 1,755 pages)',
  pg_relation_size('held_ff90') / 8192.0
    / (select heap_pages from bulk_before where relname = 'held_ff90') between 1.3 and 1.45);

完整实验脚本

HOT 是一次性做出的设计决定

本章中的每次冷更新都来自某个索引或某个已填满的页。两者都是模式选择,所以一次修好即可。

不要给 updated_at 建索引

几乎每个 ORM 都会在每次保存时设置 updated_at。给它建索引后,这张表的每次更新都会在每个索引上变成冷更新。在给任何大多数更新都会改变的列(updated_at、last_seen_at、status、计数器、locked_by)建索引前,先说清楚是哪条查询需要它:

  • **“自上次同步以来发生了什么变化?”**更适合用只追加的变更日志或 outbox 表来服务。
  • updated_at 上的 BRIN 索引是 HOT-safe 的(PostgreSQL 16+),但 HOT 会把行留在原处,因此这些值很快就会偏离物理顺序。先测量。
  • 很少运行的管理员查询可以直接扫描。每天十秒钟,总好过让数百万次更新都变成冷更新。

把热点计数器移到窄表

宽行上的计数器每次递增都会复制整行,并为了这 8 个字节把整行拖过缓存。我们把一个 view_count 放到 profiles 表上;这张表包含 416 字节的 bio,共 6,667 页。我们又把同一个计数器放进一张两列的旁表(599 页)。两边的 HOT 比例都保持 100%,但检查点之后立刻递增 10,000 次时,宽表写入 49.3 MB WAL,窄表写入 4.9 MB:页镜像只有十分之一,而且计数器能够留在缓存中。

last_seen_at、login_count、速率限制、心跳都可以这样处理:凡是写入频率远高于所在行的内容都适用。非常频繁的递增要合并成批量更新。也不要在繁忙的表上用触发器维护计数(计数器触发器会让写者串行)。

深入阅读:窄表不会让 HOT 更容易发生

在稳定状态下,WAL 差距会缩小(第二个批次为 1.06 MB 对 764 KB),因为只要更新留在原页,记录的就只有发生变化的字节,除非 wal_level 是 logical。而且 10% 的预留空间由数量为原来十倍的窄行共享:一条每五行递增一行的语句,在宽表上保持了 67% 的 HOT,在窄表上保持了 54%。

动手验证 · lab 06-hot
select lab.prove('right after a checkpoint the side table writes about a tenth of the WAL',
  m('wide 1', 'bytes') / m('narrow 1', 'bytes') between 8 and 12);
select lab.prove('in steady state the gap is small: a same-page update logs only the bytes that changed',
  m('wide 2', 'bytes') / m('narrow 2', 'bytes') < 2);
select lab.prove('a burst of 20,000 updates in one statement costs both tables HOT: about 67 and 54 percent',
  m('wide burst', 'hot') between 12000 and 14400
  and m('narrow burst', 'hot') between 9000 and 13000);

完整实验脚本

让 ORM 少写一些

无条件更新 updated_at 会产生一个新的行版本;如果 updated_at 有索引,这就是冷更新。一次没有真实变化的保存虽然是 HOT-safe,仍然会写入版本和 WAL,并增加未来的清理工作。使用脏检查;在普通 SQL 中则加上保护条件:

update users
set name = $1
where id = $2
  and name is distinct from $1;

自底向上的索引删除是安全网

从 PostgreSQL 14 开始,如果 B-树的键没有变化,它会在冷更新之后自行清理:分裂叶页之前,删除其中指向死版本的条目。这是一张安全网,不能代替 HOT。每次冷更新仍然写入并记录了这些条目,而键发生变化的索引要一直等到清理才会处理自己的垃圾。

深入阅读:数一数自底向上删除

churn 表(id、email 和 updated_at 都有索引;不运行清理)分 30 个批次执行了 300,000 次冷更新,每次都改变 updated_at:

索引键是否改变?持有的堆指针更新前 / 后页数
churn_updated_at_idx是400,000276 / 1,098
churn_pkey否196,685276 / 551
churn_email_idx否189,244499 / 975
表 7-6 自底向上的索引删除是安全网

updated_at 索引保留了每个活跃条目和过期条目。另外两个索引在运行过程中分别删除了 68% 和 70% 的过期条目。

动手验证 · lab 06-hot
select lab.prove('the updated_at index holds all 400,000 pointers: live and stale',
  heap_pointers('churn_updated_at_idx') = 400000);
select lab.prove('the primary key, whose key never changed, deleted over half its stale entries on the fly',
  heap_pointers('churn_pkey') < 250000);

完整实验脚本

这就是 Postgres 做出的取舍

Postgres 索引指向物理地址:查找直接,但每次冷更新都会写入每个索引。MySQL 的 InnoDB 做了相反的选择:那里的更新只触及发生变化的列所属的索引,所以给 updated_at 建索引,每次更新只需写一个索引,而在这里可能要写每一个索引(一次更新写一行,或者写每个索引)。HOT 缩小了这道差距,所以代价如此依赖你的模式。几乎总能重新塑造造成问题的表。在责怪数据库之前,先做这个。

本章要点

  • 冷更新会在每个索引中写入条目,还要付出 WAL、清理以及每个副本上的同等代价:六个索引时,WAL 是 HOT 更新的 6.9 倍。
  • HOT-safe 表示没有索引列发生变化;HOT 还要求页上有空间。要知道自己违反的是哪一半。
  • 已索引表示出现在索引的任何位置:键、INCLUDE、表达式或部分谓词(仅被 BRIN 索引覆盖的列例外,PostgreSQL 16+)。
  • 不要在没有明确查询需求的情况下给 updated_at 或其他大多数更新都会改变的列建索引。
  • 更新很多的表设置 fillfactor = 90(宽行使用更低的值);只追加表保持 100。
  • 用 n_tup_hot_upd 和 n_tup_newpage_upd 对照 n_tup_upd,判断应该修索引还是增加页内空间。
  • 一个被持有的快照把我们的 HOT 比例从 100% 降到 23%,并让每个索引都增长。保持事务短小。
  • 把不断变化的计数器移到窄表,并且不要写入没有变化的行。

延伸阅读

表格阅读