PostgreSQL TOAST:大值数据的透明压缩
PG 一行最大 8KB,那 TEXT/JSONB 存 MB 级数据怎么办?TOAST(超大字段存储技术)自动把大值压缩+移到独立表。本文讲清 TOAST 的四种策略(PLAIN/EXTERNAL/EXTENDED/MAIN)、它对查询性能的影响、什么时候该调 TOAST 策略,以及大字段设计的最佳实践。
本文要点
- 主题聚焦:DevOps
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
PostgreSQL TOAST:大值数据的透明压缩
我见过一个挺典型的线上事故:一个订单表,每天正常写入几十万行,某天开始接口 P99 突然飙到 2 秒,DBA 一顿排查——CPU 不高、连接数正常、缓冲命中率 99%。最后定位到的原因荒诞得很好笑:有人往 orders 表加了一个 description TEXT 字段,业务开始往里塞几 MB 的合同正文。
表还是那张表,索引还是那些索引,但每读一行,磁盘都得多做一次随机 I/O 去一张你从没听说过的"影子表"里把那个大值捞回来。这就是 PostgreSQL 的 TOAST——一个默默在后台工作、却在你不了解它时能给你狠狠一记的功能。
这篇把 TOAST 讲透:它怎么存、怎么压、四种策略怎么选、什么时候它会拖慢你的查询、以及大字段到底该怎么设计。读完你会明白一句话:TOAST 是 PG 的优雅工程,但优雅的代价是 I/O,而 I/O 你永远不该假装看不见。
一、8KB 的页:PostgreSQL 存储的铁律
要理解 TOAST,先得理解 PG 的存储地基。
PostgreSQL 把所有数据存在一个一个的 页(page / block) 里,默认每个页 8KB(8192 字节)。这个 8KB 不是配置项,是编译时写死的(BLCKSZ),所有表、所有索引、所有行,都在这 8KB 的格子里。一个页里能塞下多行数据,PG 靠页内部的行指针(line pointer)来定位每一行。
这就引出一个硬性问题:一行数据(一个 tuple),最大能有多大?
答案不是 8KB,而是远小于 8KB。一个页要留出页头(Page Header,24 字节)、行指针数组,还要能容纳多行——所以单行不可能占满一整页。PG 的设计是:一行最好别超过页大小的 1/8 左右,否则一个页只能放一两行,空间利用率崩盘。
但现实里,数据根本不配合这个约束:
TEXT/VARCHAR:一篇 Markdown 文章轻松十几 KBJSONB:一份 API 响应、一份产品规格,几百 KB 不稀奇BYTEA:一张图片、一段音频、一个 PDF,MB 级起步
那问题来了——8KB 的页,怎么装得下一个 5MB 的 JSONB?
答案就是 TOAST。
二、TOAST 的机制:压缩 + 移出 + 行内指针
TOAST 全称 The Oversized-Attribute Storage Technique(超大字段存储技术)。它的存在让你可以毫无心理负担地往一个 TEXT 字段里塞几 MB 的文本,而不用像 MySQL 那样纠结 TEXT / MEDIUMTEXT / LONGTEXT 到底选哪个——PG 里没有这些类型之分,全是 text,背后全是 TOAST。
触发阈值:约 2KB
PG 给"一行该不该被 TOAST 处理"设了一个阈值,叫 TOAST_TUPLE_THRESHOLD,大约 2032 字节(约 2KB)。这个值大致是页大小的 1/4。
注意:这个阈值是针对"整行"的,不是单个字段。 当 PG 要写入的一行(所有字段加起来)超过约 2KB,TOAST 就会启动。它会挑行里那些变长大字段(text、jsonb、bytea、varchar、数组等),依次把它们"处理"掉,直到整行被压到阈值以下。
两板斧:压缩 + 移出
TOAST 处理一个大值,只有两板斧,按顺序来:
第一板斧:压缩。 PG 会尝试对这个值做无损压缩。历史上的内置算法是 pglz(PG 自己实现的轻量 LZ77 变种);从 PostgreSQL 14 开始,新增了 lz4 作为可选压缩算法(通过 default_toast_compression 控制),压缩率和速度都更好。如果压缩后能省到原来的 1/4 以下,就采用压缩版本,行里存一个标记说"这是压缩过的数据"。
第二板斧:移出(out-of-line)。 如果压缩后整行还是超阈值,PG 就把这个值**整个移到一张独立的"TOAST 表"**里,原表这行只保留一个 18 字节左右的指针(varatt_external 结构,含 TOAST 表里那条数据的 OID、长度等)。
这两板斧通常配合使用:先压缩,压缩后还塞不下就移出。这也是为什么一个 5MB 的 JSONB,在主表里实际只占十几字节——一个指针。
TOAST 表长什么样
每张"可能含大值"的表,PG 都会偷偷给它配一张影子表,命名规则是 pg_toast.pg_toast_<oid>。你可以用 \d+ 表名 看到,也可以直接查:
-- 查看 orders 表的 TOAST 表
SELECT reltoastrelid::regclass AS toast_table
FROM pg_class
WHERE relname = 'orders';
-- 结果类似:pg_toast.pg_toast_16384
TOAST 表内部结构是固定的,就三列:
column | type | 说明
-----------+---------+--------------------------------
chunk_id | oid | 属于哪条主表数据
chunk_seq | int4 | 第几个分片(从 0 开始)
chunk_data | bytea | 实际的数据分片
主键是 (chunk_id, chunk_seq)。注意第三列是 bytea,并且 TOAST 表内部也会被进一步 TOAST——这就是一个递归终止的设计:每个 chunk_data 分片被限制在 约 1996 字节,保证它本身永远不会再次触发 TOAST。
所以一个 5MB 的值,在 TOAST 表里会被切成大约 2500 多个 2KB 的小分片,每个分片是一行。读的时候,PG 按主键范围把这些分片捞出来、拼回去、解压,还原成你当初写入的那个 5MB。
这一切对你透明:你 SELECT description FROM orders,PG 在背后完成了分片重组和 lz4/pglz 解压,你拿到的就是原始文本。这是 TOAST 优雅的地方——也是它危险的地方,因为你根本感觉不到代价。
三、四种 TOAST 策略:PLAIN / EXTERNAL / EXTENDED / MAIN
并不是所有字段都走同一套处理逻辑。PG 给每个字段一个 storage 策略(pg_attribute.attstorage),决定它能不能压、能不能移出。一共四种:
| 策略 | 压缩? | 移出? | 典型用途 |
|---|---|---|---|
| PLAIN | 否 | 否 | 定长类型(int4、float8、timestamp),不可变长的字段 |
| EXTERNAL | 否 | 是 | 已压缩的二进制、需要随机读子串的大文本 |
| EXTENDED | 是 | 是 | 默认。text、jsonb、bytea、varchar、数组等 |
| MAIN | 是 | 是(尽量不) | 希望尽量留在行内,实在太大才移出 |
逐个说清楚:
PLAIN(默认用于定长类型)。 不压缩、不移出。int4、bigint、boolean、timestamp 这些定长类型永远是 PLAIN,因为它们本来就不可能超阈值,没有 TOAST 的必要。你也无法把一个 int 列改成 EXTENDED——它压根不是变长类型。
EXTENDED(变长类型的默认)。 先尝试压缩,压缩后还超阈值就移出到 TOAST 表。这是 99% 字段的策略,也是 PG 的默认选择。你建表时写的 description TEXT,自动就是 EXTENDED。
EXTERNAL(只移出不压缩)。 值可以移出到 TOAST 表,但 PG 不会 对它做压缩。读的时候也不用解压——少了一层 CPU 开销。适合两种场景:数据本身已经压缩过了(PNG/JPEG/WebP 图片、zip/gzip/zstd 包、加密后的密文),再压一遍纯属浪费 CPU 还压不小;或者你需要频繁随机访问这个值的子串(比如 substring(big_text, 100000, 200)),压缩状态下每次都得整体解压,慢得离谱。
MAIN(尽量内联)。 会压缩,并且尽量把压缩后的值留在主表行内,只有当压缩后整行仍然超阈值,才极不情愿地把它移出到 TOAST 表——而且后续如果腾出空间,还会试图把它挪回主表。这个策略用得很少,主要给那种"大部分时候不大、偶尔才超阈值"的字段,希望它常规情况下跟行在一起(读得快)。
查看一张表各字段的策略:
SELECT attname, attstorage
FROM pg_attribute
WHERE attrelid = 'orders'::regclass
AND attnum > 0;
-- attstorage: x=EXTENDED e=EXTERNAL m=MAIN p=PLAIN
或者直接 \d+ orders,输出的 Storage 列就是它(显示为 extended / external / main / plain)。
四、TOAST 对查询性能的影响:别以为它"免费"
TOAST 对开发者是透明的,但透明不等于免费。这是这篇最重要的一节,记牢了:TOAST 的代价不是存储,是 I/O 和 CPU。
代价一:每次读大值 = 一次额外的随机 I/O
主表那一行里只存了一个指针。当你 SELECT description FROM orders WHERE id = 100,PG 的动作是:
- 从
orders主表读出这一行,拿到 description 字段的指针 - 拿着这个指针,去
pg_toast_16384表里做一次查询,把所有分片捞出来 - 按顺序拼成完整数据,解压
第 2 步就是一次额外的随机 I/O。对于 5MB 的值,要读 2500 多个分片,那是 2500 次小 I/O(或者顺带读,取决于缓冲命中)。这个成本,在缓冲命中的热数据上不明显,一旦数据在磁盘上、或者并发起来,延迟立刻起飞。
代价二:SELECT * 是 TOAST 时代的头号性能杀手
这是最容易踩的坑。很多 ORM 默认 SELECT *,或者开发者图省事一把梭:
-- 灾难:你只想看标题,却把每行 5MB 的 description 全捞回来了
SELECT * FROM orders WHERE status = 'pending' LIMIT 100;
这 100 行里,每行的 description 都要触发一次 TOAST 表查询 + 解压。本来只想扫个标题、走个索引的事,变成 100 次大对象重组。内存被吃光、网络被打满、CPU 全在解压。
铁律:凡是大字段所在的表,业务查询一律显式列出列名,绝不用 SELECT *。 需要大值时才单独查它,分两次或分接口走。
-- 列表接口:不碰大字段
SELECT id, title, status, created_at
FROM orders
WHERE status = 'pending'
ORDER BY created_at DESC
LIMIT 100;
-- 详情接口:只在点进去时才读
SELECT id, description
FROM orders
WHERE id = $1;
这个习惯能避免线上 90% 的"莫名其妙变慢"。
代价三:WHERE / ORDER BY 也能触发 TOAST
更隐蔽。你以为自己没 SELECT 大字段,但如果 WHERE 或 ORDER BY 用到了它,一样要解压:
-- 即使没 SELECT description,过滤也得把每个值解压出来比较
SELECT id FROM docs WHERE body LIKE '%违约条款%';
这种全文匹配在大文本上是灾难性的——PG 必须把每一行的 body 完整 detoast 出来才能做 LIKE。这也是为什么全文检索要用 tsvector + GIN 索引,而不是拿 LIKE 去硬刚大字段。
代价四:压缩/解压的 CPU 开销
每次读一个压缩过的 TOAST 值,都要做一次解压;每次写,都要做一次压缩。pglz 速度还行,lz4 更快,但在高并发写大值的场景下(比如日志表每秒几千行,每行几 KB JSONB),这部分 CPU 会显眼地爬上去。如果你监控到 PG 进程 CPU 高、但 IO wait 低,且都是大字段表,TOAST 压缩是一个值得怀疑的方向。
五、什么时候该手动调 TOAST 策略
绝大多数情况,默认的 EXTENDED 就是对的,别乱动。但有几种典型场景,你应该主动去调:
场景一:字段存的是已经压缩过的二进制。 典型的是 BYTEA 存图片、PDF、gzip 包。这些数据本身已经压缩,pglz 再压一遍既压不动(甚至略微膨胀),又白烧 CPU。这时改成 EXTERNAL:
ALTER TABLE user_files
ALTER COLUMN file_data
SET STORAGE external;
改完注意:这只影响新写入和后续更新,已经存好的旧行不会自动重写。要让旧数据也生效,需要重写表:
-- 谨慎:会锁表、产生完整表重写,大表上安排维护窗口
VACUUM FULL user_files;
-- 或更彻底
CLUSTER user_files USING user_files_pkey;
场景二:需要频繁随机读子串的大文本。 比如一份几十 MB 的日志,业务经常 substring() 取中间某段。压缩状态下,取一段也得整体解压——EXTENDED 会让你每次随机访问都付完整解压成本。改成 EXTERNAL 后,PG 可以定位到具体分片,只读相关的几个 2KB 分片,省一大半 I/O 和 CPU。
场景三:某个字段几乎从不被读取,但它在主表行内太占地方。 默认 EXTENDED 已经会把它移出,但如果你想让主表行更紧凑(提高一页能放的行数、提升缓存命中),可以考虑把它显式独立成一张子表(见下一节)。
关于压缩算法本身:升级到 PG 14+ 后,建议把 default_toast_compression 设为 lz4。 lz4 在压缩率和速度上对 pglz 是全面碾压,尤其是大值写入密集的表,CPU 能省一大截:
-- 全局默认(postgresql.conf)
-- default_toast_compression = 'lz4'
-- 或针对单列(PG 14+)
ALTER TABLE events
ALTER COLUMN payload
SET COMPRESSION lz4;
注意 SET COMPRESSION 同样只影响新数据,旧数据要重写才生效。
一句话总结调策略的心法:已经压缩过的别再压,随机访问的别压缩,默认 EXTENDED 是给"会再被整段读"的普通文本用的。
六、大字段设计最佳实践:垂直拆分、收窄 SELECT、JSONB 单独表
策略调优是战术,表设计是战略。这几条是过来人血泪:
最佳实践一:大字段做垂直拆分(vertical partitioning)。
不要把大字段和热数据挤在同一张表。一张 orders 表,主表只放所有"经常查、经常过滤"的小字段(id、customer_id、status、amount、created_at),把 description 这种大字段拆到 order_details 子表:
-- 热表:几乎所有查询都扫它,要保持窄、保持缓存友好
CREATE TABLE orders (
id bigserial PRIMARY KEY,
customer_id bigint NOT NULL,
status text NOT NULL,
amount numeric(12,2),
created_at timestamptz NOT NULL DEFAULT now()
);
-- 冷表:只有看详情时才 JOIN
CREATE TABLE order_details (
order_id bigint PRIMARY KEY REFERENCES orders(id),
description text
);
-- 列表查询:只碰窄表,缓存命中率高
SELECT id, customer_id, status, amount
FROM orders
WHERE status = 'pending'
ORDER BY created_at DESC LIMIT 100;
-- 详情查询:才拉大字段
SELECT o.*, d.description
FROM orders o
JOIN order_details d ON d.order_id = o.id
WHERE o.id = 123;
这比依赖 TOAST 的"自动移出"更可控——主表永远是窄的,每页能放更多行,缓冲池命中率显著提升。TOAST 解决的是"一行怎么塞得下",垂直拆分解决的是"一张表怎么读得快",两者不矛盾,但拆表是主动设计。
最佳实践二:绝不 SELECT *,列名写死。
前面讲过,这里再强调一遍,因为它是 TOAST 时代排名第一的低级性能问题。把 ORM 的默认 SELECT * 关掉,代码审查里把"大字段表上的 SELECT *"列为必拦项。
最佳实践三:大 JSONB 文档考虑单独表,或评估是否该用文档库。
JSONB 是 PG 的杀手锏,但一个字段塞 5MB 的 JSONB 是反模式。JSONB 的 GIN 索引对超大文档代价极高(索引项膨胀),而且整体读写都要 detoast。如果业务真的需要存超大嵌套文档:
- 能不能拍平成关系表?(往往性能更好)
- 能不能把"元数据"(常查、常过滤的字段)拎到主表,"正文" JSONB 单独表?
- 是不是该评估 MongoDB / 对象存储 + PG 存元数据?
判断标准:如果这个 JSONB 字段你几乎从不整体读、只查里面的某个路径,那它不该是 JSONB,该拍平。如果经常整体读、且很大,它该单独成表。
最佳实践四:大值别拿去做索引键。
btree 索引单条最大约 2712 字节,超过直接报错 index row size ... exceeds maximum。就算没超,把大文本塞进索引也是灾难——索引膨胀、写入变慢。要在大文本上做检索,用 tsvector 全文检索或 pg_trgm 模糊匹配,让索引存的是 token / trigram 这种小东西,不是原始大值。
最佳实践五:批量写入大值时留意 WAL 放大。
每个 TOAST 分片的写入都会记 WAL。一个 5MB 的值写入,WOL 里是 5MB 的分片数据 + 主表更新,写放大可观。批量导入大值的场景(比如 ETL 把一批文档灌进去),考虑分批、考虑是否需要关闭某些索引再重建。
七、诊断:你的 TOAST 表有多大、触发率多少
不测量不优化。下面这套查询是排查 TOAST 问题的标配。
1. 看一张表连 TOAST 在内一共多大。
pg_total_relation_size() 包含主表 + 所有索引 + TOAST 表,而 pg_relation_size() 只算主表本体。两者一减,你能看出 TOAST + 索引占了多少:
SELECT
relname,
pg_size_pretty(pg_relation_size(c.oid)) AS main_size,
pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
pg_size_pretty(pg_relation_size(c.reltoastrelid)) AS toast_size
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
AND c.reltoastrelid <> 0
AND n.nspname = 'public'
ORDER BY pg_relation_size(c.reltoastrelid) DESC
LIMIT 10;
如果某张表的 toast_size 远超 main_size——恭喜,你找到了一个"主表只是 TOAST 指针陈列馆"的表,垂直拆分的候选。
2. 看某一行某个字段实际存了多大。
pg_column_size() 返回字段在磁盘上的实际占用(含 TOAST 指针、未 detoast 的情况下是存储态大小):
-- 看 description 字段在行内的存储大小(TOAST 后通常只剩指针的十几字节)
SELECT
id,
pg_column_size(description) AS stored_size,
octet_length(description) AS logical_size
FROM orders
WHERE id = 100;
stored_size 是十几字节、logical_size 是 5MB——说明这个值被 TOAST 移出了。两个值接近且都不大——说明它老老实实待在行内。
3. 判断一个值有没有被压缩。
pg_column_size 对比解压前后,或者用 substring 之类去触发 detoast 再 octet_length,能间接看出。PG 14+ 还可以直接看列的压缩算法:
SELECT attname, attcompression
FROM pg_attribute
WHERE attrelid = 'orders'::regclass AND attnum > 0;
-- attcompression: p=pglz l=lz4 (空=无压缩/定长)
4. 监控 TOAST 表的 I/O。
把 pg_stat_all_tables 拿来对 TOAST 表一起看:
SELECT
relname,
seq_scan,
seq_tup_read, -- TOAST 表被顺序读的行数,大值多就高
idx_scan,
idx_tup_fetch
FROM pg_stat_all_tables
WHERE schemaname = 'pg_toast'
ORDER BY seq_tup_read + idx_tup_fetch DESC
LIMIT 10;
pg_toast schema 里的表被频繁读,说明对应的主表字段在被反复 detoast。结合前面"哪些字段大",就能定位热点。
5. 一句话定位 TOAST 是否在拖慢某个查询。
跑 EXPLAIN (ANALYZE, BUFFERS),如果某个查询慢,但 Seq Scan / Index Scan 上的行数和代价都不大,而实际执行时间却远超预期,去查那张表的 TOAST 表——大概率是大字段的 detoast 在背锅。配合 \d+ 表名 看哪些列是 extended/external,基本一抓一个准。
小结
- TOAST 是 PG 解决"8KB 页装不下大值"的自动机制:阈值约 2KB,超过就先压缩、再移出到独立的
pg_toast表,主表行内只留一个 18 字节指针。对你透明,但不免费。 - 四种策略要记牢:
PLAIN(定长,不动)、EXTENDED(先压再移,默认)、EXTERNAL(只移不压,给已压缩/需随机访问的数据)、MAIN(尽量内联)。PG 14+ 记得把压缩算法换成lz4。 - TOAST 的代价是 I/O 和 CPU,不是存储:每次读大值都是一次额外的 TOAST 表随机读 + 解压。
SELECT *是头号性能杀手,凡大字段表一律显式列名。 - 大字段做垂直拆分:把热字段和冷大值分表存,主表保持窄,缓存命中率才是性能的基本盘。超大 JSONB 要么拍平、要么单独表,别让 GIN 索引去吞天书。
- 诊断三件套:
pg_total_relation_size看总量、pg_column_sizevsoctet_length看字段是否被 TOAST、pg_stat_all_tables看pg_toastschema 的读放大。
一句话收尾:TOAST 让你"能存",但永远不要因此忘了"读它要付钱"。 把大字段当二级公民对待——平时别碰它,需要时才专门去请它出来——这是处理大值数据最朴素也最有效的工程纪律。
Related
相关推荐
文章
Docker 部署 Next.js 社区项目的注意事项
围绕 DevOps、Next.js 的技术趋势、工程实践和独立开发视角。
文章
OpenTelemetry 如何帮助你观察 AI 应用?
围绕 DevOps、AI 的技术趋势、工程实践和独立开发视角。
文章
长运行工具调用:Operation、Poll 与 Cancel 的终态协议
初始请求超时后,工具可能仍在服务端运行;发出取消也不等于副作用已停止。本文为单次长运行工具调用建立 operation 身份、轮询水位、取消竞态和唯一终态接受点。
文章
Agent 记忆删除传播:从用户遗忘请求到可验证清除
删除主表一行不会清掉向量、摘要、缓存和恢复后的备份。本文用删除状态机、代际墓碑、派生血缘与端到端 canary 验证真正遗忘。