#DevOps

PostgreSQL TOAST:大值数据的透明压缩

PG 一行最大 8KB,那 TEXT/JSONB 存 MB 级数据怎么办?TOAST(超大字段存储技术)自动把大值压缩+移到独立表。本文讲清 TOAST 的四种策略(PLAIN/EXTERNAL/EXTENDED/MAIN)、它对查询性能的影响、什么时候该调 TOAST 策略,以及大字段设计的最佳实践。

本文要点

  • 主题聚焦:DevOps
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年1月16日·更新于 2026年7月23日

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 文章轻松十几 KB
  • JSONB:一份 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+ 表名 看到,也可以直接查:

sql
-- 查看 orders 表的 TOAST 表
SELECT reltoastrelid::regclass AS toast_table
FROM pg_class
WHERE relname = 'orders';

-- 结果类似:pg_toast.pg_toast_16384

TOAST 表内部结构是固定的,就三列:

text
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 表——而且后续如果腾出空间,还会试图把它挪回主表。这个策略用得很少,主要给那种"大部分时候不大、偶尔才超阈值"的字段,希望它常规情况下跟行在一起(读得快)。

查看一张表各字段的策略:

sql
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 的动作是:

  1. 从 orders 主表读出这一行,拿到 description 字段的指针
  2. 拿着这个指针,去 pg_toast_16384 表里做一次查询,把所有分片捞出来
  3. 按顺序拼成完整数据,解压

第 2 步就是一次额外的随机 I/O。对于 5MB 的值,要读 2500 多个分片,那是 2500 次小 I/O(或者顺带读,取决于缓冲命中)。这个成本,在缓冲命中的热数据上不明显,一旦数据在磁盘上、或者并发起来,延迟立刻起飞。

代价二:SELECT * 是 TOAST 时代的头号性能杀手

这是最容易踩的坑。很多 ORM 默认 SELECT *,或者开发者图省事一把梭:

sql
-- 灾难:你只想看标题,却把每行 5MB 的 description 全捞回来了
SELECT * FROM orders WHERE status = 'pending' LIMIT 100;

这 100 行里,每行的 description 都要触发一次 TOAST 表查询 + 解压。本来只想扫个标题、走个索引的事,变成 100 次大对象重组。内存被吃光、网络被打满、CPU 全在解压。

铁律:凡是大字段所在的表,业务查询一律显式列出列名,绝不用 SELECT *。 需要大值时才单独查它,分两次或分接口走。

sql
-- 列表接口:不碰大字段
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 用到了它,一样要解压:

sql
-- 即使没 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:

sql
ALTER TABLE user_files
  ALTER COLUMN file_data
  SET STORAGE external;

改完注意:这只影响新写入和后续更新,已经存好的旧行不会自动重写。要让旧数据也生效,需要重写表:

sql
-- 谨慎:会锁表、产生完整表重写,大表上安排维护窗口
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 能省一大截:

sql
-- 全局默认(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 子表:

sql
-- 热表:几乎所有查询都扫它,要保持窄、保持缓存友好
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 + 索引占了多少:

sql
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 的情况下是存储态大小):

sql
-- 看 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+ 还可以直接看列的压缩算法:

sql
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 表一起看:

sql
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_size vs octet_length 看字段是否被 TOAST、pg_stat_all_tables 看 pg_toast schema 的读放大。

一句话收尾:TOAST 让你"能存",但永远不要因此忘了"读它要付钱"。 把大字段当二级公民对待——平时别碰它,需要时才专门去请它出来——这是处理大值数据最朴素也最有效的工程纪律。

Related

相关推荐