跳转到内容

TOAST:大字段去哪了

准备中…

一行不能跨页,但 textjsonb 字段显然可以超过 8KB。这中间的机制叫 TOAST(The Oversized-Attribute Storage Technique)。

当一行的总长度超过 TOAST_TUPLE_THRESHOLD(编译期常量,约 2KB——注意不是 8KB)时,Postgres 开始处理:

1. 找出这一行里最大的、可 TOAST 的字段
2. 尝试压缩它
3. 压缩后整行还是超过阈值?→ 把这个字段切成 2KB 的块,存进 TOAST 表,主表里只留一个指针
4. 还超?→ 回到第 1 步处理下一个字段

关键在于压缩优先。看一个实测:

DROP TABLE IF EXISTS t_toast;
CREATE TABLE t_toast (id int, doc text);
INSERT INTO t_toast VALUES
(1, repeat('a', 5000)),
(2, (SELECT string_agg(md5(i::text), '') FROM generate_series(1, 200) i));
SELECT id,
     length(doc)          AS 字符数,
     pg_column_size(doc)  AS 实际占用字节
FROM t_toast ORDER BY id;
⌘/Ctrl + Enter
  • 第 1 行:5000 个相同字符,压缩后只剩 69 字节——压缩比 72:1,远低于阈值,直接内联存在主表里,TOAST 表根本没被用到。
  • 第 2 行:6400 个 md5 拼出来的随机字符,几乎不可压缩,只能外置到 TOAST 表

每一列都有一个存储策略,决定 TOAST 能对它做什么:

SELECT attname AS 列名,
     atttypid::regtype::text AS 类型,
     CASE attstorage
       WHEN 'p' THEN 'plain(不压缩不外置)'
       WHEN 'e' THEN 'external(外置但不压缩)'
       WHEN 'm' THEN 'main(压缩,尽量不外置)'
       WHEN 'x' THEN 'extended(压缩且可外置,默认)'
     END AS 存储策略
FROM pg_attribute
WHERE attrelid = 'products'::regclass AND attnum > 0;
⌘/Ctrl + Enter
策略 压缩 外置 什么时候用
plain 定长类型(inttimestamptz)自动是这个,改不了
extended 变长类型的默认值,绝大多数情况保持它
main 最后手段 字段中等大小且每次查询都要读,想避免额外的 TOAST 表访问
external 字段很大且经常做子串操作

改成 EXTERNAL 之后压缩就被禁用了:

ALTER TABLE t_toast ALTER COLUMN doc SET STORAGE EXTERNAL;
INSERT INTO t_toast VALUES (3, repeat('a', 5000));
SELECT id, length(doc) AS 字符数, pg_column_size(doc) AS 实际占用字节
FROM t_toast ORDER BY id;
⌘/Ctrl + Enter

同样是 5000 个 aextended 下占 69 字节,external 下占 5000 字节

SELECT c.relname AS 主表,
     t.relname AS TOAST表,
     pg_size_pretty(pg_relation_size(c.oid)) AS 主表大小,
     pg_size_pretty(pg_relation_size(t.oid)) AS TOAST表大小
FROM pg_class c
JOIN pg_class t ON c.reltoastrelid = t.oid
WHERE c.relname IN ('products', 't_toast');
⌘/Ctrl + Enter

每张含可变长列的表都有一张配套的 TOAST 表(pg_toast.pg_toast_<oid>),结构固定:(chunk_id, chunk_seq, chunk_data),带一个唯一索引。

这带来几个实际后果:

1. pg_relation_size 不含 TOAST。 想看真实占用要用 pg_total_relation_size

SELECT relname,
     pg_size_pretty(pg_relation_size(oid))          AS 仅主表,
     pg_size_pretty(pg_total_relation_size(oid))    AS 含TOAST和索引
FROM pg_class WHERE relname IN ('products', 'orders', 't_toast');
⌘/Ctrl + Enter

2. TOAST 表也会膨胀,也需要 VACUUM。 一个被反复更新的大 jsonb 字段,每次更新都在 TOAST 表里写一份全新的副本(TOAST 里的数据不可变,改一个字节也要整个重写)。

3. TOAST 访问是额外的索引查找。 读一个被外置的字段,意味着一次主表访问 + 一次 TOAST 索引查找 + N 次块读取。

陷阱二:更新大字段会重写整个 TOAST 值

Section titled “陷阱二:更新大字段会重写整个 TOAST 值”
UPDATE docs SET d = jsonb_set(d, '{status}', '"done"') WHERE id = 1;

改一个键,代价是:读出整个 jsonb → 解压 → 修改 → 重新压缩 → 把所有 TOAST 块重写一遍 → 主表里插入新行版本。

字段 1MB 的话,这次「改一个字段」产生了约 1MB 的 WAL 和 1MB 的新 TOAST 数据。高频更新的大 jsonb 字段是最典型的膨胀源之一。

解法:把频繁变动的小字段从 jsonb 里提出来做成真正的列(jsonb 一节讨论过),让 jsonb 里只留下稳定的部分。

SELECT name, setting, short_desc FROM pg_settings WHERE name = 'default_toast_compression';
⌘/Ctrl + Enter

PG 14 起支持 lz4(比默认的 pglz 快好几倍,压缩率略低),但需要编译时启用。对写入密集的大字段场景,切到 lz4 通常是明显的净收益:

ALTER TABLE docs ALTER COLUMN d SET COMPRESSION lz4;

(本书的浏览器环境只编译了 pglz,所以这条命令在这里会失败。)

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

TOAST 的完整判定链是什么?触发阈值大约是多少,为什么不是 8KB?

阈值是 TOAST_TUPLE_THRESHOLD,编译期常量,约 2KB。判定链四步:

1. 找出这一行里最大的、可 TOAST 的字段
2. 尝试压缩它
3. 压缩后整行还是超过阈值?→ 切成 2KB 的块存进 TOAST 表,主表只留指针
4. 还超?→ 回到第 1 步处理下一个字段

不是 8KB,是因为 8KB 是硬上限(一行不能跨页),而 2KB 是策略阈值。等到接近 8KB 才外置,一个页面就只放得下一行,页面利用率崩掉;控制在 2KB 以内,一页至少能放四行。

常见错误:以为「字段超过 2KB 就会被 TOAST」。判定的对象是整行的总长度,而且先压缩再判断——5000 个 a 压完只剩 69 字节,整行远低于阈值,根本不会外置。反过来,几个各 800 字节的不可压缩字段单个都不到 2KB,整行却超了,照样会触发 TOAST。

为什么 repeat('x', 100000) 演示不出 TOAST 外置?

因为压缩优先,而 repeat 生成的数据压缩得太好了。实测里 5000 个 a 压缩后只剩 69 字节,压缩比 72:1——远低于阈值,直接内联存在主表,TOAST 表根本没被碰到。

想触发外置,必须用不可压缩的数据:随机串、已压缩的图片、加密后的内容。正文那个 md5 拼出来的 6400 字符就是这个用途。

反过来这也是个好消息:真实业务里的 JSON、XML、日志文本压缩率通常很高,很多你以为被 TOAST 了的字段其实一直安安静静躺在主表里。

常见错误:用 length() 判断字段占了多少空间。length() 返回的是字符数,跟磁盘占用没有关系——同一个值 length() 是 5000,pg_column_size() 是 69,差了 72 倍。要看实际占用只能用 pg_column_size()

四种存储策略分别是什么?EXTERNAL 牺牲了压缩,换来了什么?
策略 压缩 外置 什么时候用
plain 定长类型自动是这个,改不了
extended 变长类型的默认值,绝大多数情况保持它
main 最后手段 字段中等大小且每次查询都要读
external 字段很大且经常做子串操作

EXTERNAL 换来的是子串操作变成随机访问extended 下数据是压缩的,取前 100 个字符也必须把整个字段解压;external 下数据未压缩地切块存放,可以只读第一个块。对「存大文档但每次只读开头」的场景(日志、文章预览),读取量能降几个数量级。

常见错误:把 EXTERNAL 当成「强制外置」的开关。它做的事是禁用压缩——同样 5000 个 aextended 下 69 字节,external 下 5000 字节。外置只是「不压缩导致更容易超阈值」的连带结果。给一个小字段设 EXTERNAL 不会把它赶出主表,只会让它在主表里占更多空间。

常见错误二:给「很大且每次都要全量读」的字段设 EXTERNAL,想省掉解压的 CPU。方向反了——全量读时不压缩意味着要从磁盘读更多字节,I/O 的增加远超省下的 CPU。这种场景该用 main(压缩、尽量不外置),避免的是额外的 TOAST 表访问。EXTERNAL 只在只读开头一小段时才划算。

pg_relation_size 和 pg_total_relation_size 的区别?

pg_relation_size 只算主表(堆)本身,不含 TOAST 表、不含索引。pg_total_relation_size 把 TOAST 表和所有索引都算进去。

常见错误:用 pg_relation_size 排「哪张表最占空间」的榜单。一张全是大 jsonb 的表,主表可能只有几百 MB,配套的 TOAST 表几十 GB——它在榜单上会排得很靠后,而它才是真正吃磁盘的那个。

常见错误二:反复更新大 jsonb 之后看主表大小「纹丝不动」,就断定这张表没有膨胀。TOAST 表是独立的一张表,它也会膨胀、也需要 VACUUM,而膨胀恰恰全部发生在那里——每次更新都在 TOAST 表里写一份全新的副本。看膨胀必须看 pg_total_relation_size

详见表膨胀与 VACUUM

为什么「更新大 jsonb 的一个键」代价极高?该怎么规避?

因为 TOAST 里的数据不可变,改一个字节也要整个重写。完整代价是:读出整个 jsonb → 解压 → 修改 → 重新压缩 → 把所有 TOAST 块重写一遍 → 主表里再插入一个新行版本。

字段 1MB 的话,这次「改一个键」产生了约 1MB 的 WAL 和 1MB 的新 TOAST 数据。高频更新的大 jsonb 是最典型的膨胀源之一。

规避:把频繁变动的小字段从 jsonb 里提出来做成真正的列,让 jsonb 里只留下稳定的部分。

常见错误:以为 jsonb_set 是「就地改一个键」的高效路径,比在应用层读出整个文档、改完再写回省。在存储层两者完全一样——jsonb_set 省掉的只是一次网络往返,TOAST 块该重写还是全部重写,WAL 一个字节都不少。

常见错误二:把这个字段设成 EXTERNAL,指望跳过解压/重新压缩来降低更新代价。省下的只有压缩的 CPU,块重写和主表新版本一分不少;而且数据不再压缩之后,同一次更新产生的 TOAST 写入量和 WAL 量反而更大。

详见 jsonb 一节