本节摘要:页面只有 8KB,超长字段由 TOAST 机制处理:能压则压,不能压就切成分块存进一张隐藏的 TOAST 表,主表里只留 18 字节的指针。压缩与四种外置策略由列级参数控制。TOAST 影响索引(B 树键长约 2.7K 上限)与查询(取大值要走第二张表)。
一个页面装不下"至少四个元组加页头"时行就得想办法。PostgreSQL 允许单行最大约 1GB,靠的正是 TOAST(The Oversized-Attribute Storage Technique):
TOAST 表本身也是页文件,带自己的索引,按指针取回时逐块拼装。所以"查一行带大 JSON 的记录"实际访问了两棵结构。
-- 查看表的 TOAST 关系与体积 SELECT c.relname AS toast_table, pg_size_pretty(pg_relation_size(c.oid)) AS toast_size FROM pg_class c WHERE c.reltoastrelid = ( SELECT reltoastrelid FROM pg_class WHERE relname = 'article');
ALTER TABLE article ALTER COLUMN body SET STORAGE EXTERNAL;
| 策略 | 压缩 | 切块外置 | 适用 |
|---|---|---|---|
| MAIN | 尽量 | 极不得已 | 默认,期望多数值能压进页 |
| EXTERNAL | 不压 | 超限即外置 | 要对大字段做子串/位置操作 |
| EXTENDED | 尽量 | 超限即外置 | 默认策略,最省空间 |
| PLAIN | 不压 | 不外置 | 定长类型专用 |
EXTERNAL 的价值:压缩过的文本做子串定位必须先整体解压,而未压缩外置的分块可以只取命中的块——对大文本高频取片段的场景,牺牲存储换回吞吐。
B 树索引键的单条上限约四分之一页(约 2704 字节)。给可能很长的列建 B 树索引,超限插入会直接报错。索引能引用被 TOAST 外置的值吗?能——但键本身不能太大,所以长文本的等值查询常用"表达式索引 + 哈希"绕路:
-- 对长文本建 md5 表达式索引,等值查询走索引 CREATE INDEX ON doc_store (md5(content)); SELECT * FROM doc_store WHERE md5(content) = md5('待查找的长文本');
键从几 KB 缩到 32 字符,索引体积与查找代价都回到正常量级。
⚠️ 常见坑:SELECT 星号把几十个大字段一起拖出来时,TOAST 拼装成为隐形大头——执行计划只会显示一次顺序扫描,看不出每行还额外访问了 TOAST 表。按需选列,是宽表上最便宜的性能优化。
同一份数据在不同 TOAST 策略下的落盘形态,用 pg_column_size 直接度量:
CREATE TABLE doc (id int primary key, body text); INSERT INTO doc VALUES (1, repeat('PostgreSQL 内幕巡礼', 600)); -- 约 10KB 可压缩 INSERT INTO doc VALUES (2, repeat(md5(random()::text), 320)); -- 约 10KB 不可压缩 SELECT id, pg_column_size(body) AS stored, -- 实际存储字节数(压缩后) length(body) AS raw_chars FROM doc;
id | stored | raw_chars ----+--------+----------- 1 | 2712 | 6000 2 | 10244 | 10240
重复文本被压到四分之一,随机十六进制串几乎压不动还多出切块开销。再把第二列改成 EXTERNAL 策略对比:
ALTER TABLE doc ALTER COLUMN body SET STORAGE EXTERNAL; UPDATE doc SET body = body WHERE id = 2; -- 触发重写以应用新策略 SELECT pg_column_size(body) FROM doc WHERE id = 2;
体积略增(不压缩),但对这个值的子串查询可以跳块执行——只解压命中的分块而不是整个值。吞吐是否划算取决于访问模式:高频取片段选 EXTERNAL,整体读取选默认 EXTENDED。
一套内容系统,文章列表页 SELECT 星号查询八百毫秒。执行计划显示一次索引扫描、估行准确、buffers 数也不夸张——所有常规指标正常。把查询列裁剪后发现:列表页其实只需要标题与摘要两列,而星号把每行约 30KB 的正文全拖了出来,每行都要去 TOAST 表取几十个分块并拼装,八百毫秒里六百多毫秒花在列裁剪之前的取大值上。改成显式列清单后同页 90 毫秒。
复盘的机制要点:TOAST 取回成本不出现在执行计划的任何节点里,它藏在"扫描输出宽度"背后。两个观测手段能抓到它:一是 EXPLAIN 里的 width 异常大(一行几万字节就该警觉);二是 pg_statio_user_tables 里 TOAST 关系的读写块数:
-- 看 TOAST 表自己的 IO 账单 SELECT t.relname, s.heap_blks_read + s.heap_blks_hit AS toast_blocks, i.idx_blks_read + i.idx_blks_hit AS toast_index_blocks FROM pg_statio_user_tables s JOIN pg_class t ON t.oid = s.relid WHERE s.relname = 'doc';
toast_blocks 与主表块数同量级甚至更高,就是"每行都在跑第二张表"的实锤。防御性写法只有一条:宽表上永远不用星号,按页面需要点名取列。