4.3 TOAST:大值是如何被切碎存储的


4.3 TOAST:大值是如何被切碎存储的

本节摘要:页面只有 8KB,超长字段由 TOAST 机制处理:能压则压,不能压就切成分块存进一张隐藏的 TOAST 表,主表里只留 18 字节的指针。压缩与四种外置策略由列级参数控制。TOAST 影响索引(B 树键长约 2.7K 上限)与查询(取大值要走第二张表)。

两倍页大小规则

一个页面装不下"至少四个元组加页头"时行就得想办法。PostgreSQL 允许单行最大约 1GB,靠的正是 TOAST(The Oversized-Attribute Storage Technique):

  1. 先压缩:默认 pglz 或 lz4 算法,压得动且值得(约超过 2KB)就压
  2. 压不动再切块:压缩后仍超限,切成约 2KB 的分块,移入同库的一张 TOAST 表
  3. 主表留指针:原列位置放 18 字节的 TOAST 指针

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 的价值:压缩过的文本做子串定位必须先整体解压,而未压缩外置的分块可以只取命中的块——对大文本高频取片段的场景,牺牲存储换回吞吐。

图:TOAST 的压缩与切块决策

TOAST 与索引的隐秘耦合

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 表。按需选列,是宽表上最便宜的性能优化。

体积实验:pg_column_size 看策略差异

同一份数据在不同 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。

排错案例:宽表的隐形双倍 IO

一套内容系统,文章列表页 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 与主表块数同量级甚至更高,就是"每行都在跑第二张表"的实锤。防御性写法只有一条:宽表上永远不用星号,按页面需要点名取列。

本节要点回顾

  • 先压后切:压缩优先,压不动才切块外置,主表留指针
  • 四种策略:EXTENDED 默认,EXTERNAL 服务子串定位,PLAIN 定长专用
  • 取大值有暗价:TOAST 表二次访问不出现在执行计划里
  • 长键索引有上限:约 2.7KB,用表达式索引缩短键

作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U