TOAST 机制(大对象存储)
PostgreSQL 的页只有 8KB,一行数据也不允许跨页存储。那么一个存着 1MB JSON 文档、或者 200KB 文章正文的行,是怎么塞进 8KB 页里的?答案就是 TOAST。本节讲解 TOAST 的触发条件、四种存储策略、压缩算法,以及如何用系统视图直接查看溢出数据。
前置知识
阅读本节前,建议先了解:Table Files(表文件)
基础概念
TOAST(The Oversized-Attribute Storage Technique,超大属性存储技术) 是 PostgreSQL 自动处理大字段的机制。它的核心思路非常朴素:当一个字段值太大、一行装不下时,就把它压缩、或者切碎后搬到另一张隐藏的表里去,主表行里只留一个指针。
理解 TOAST 需要先统一几个术语:
| 术语 | 英文 | 说明 |
|---|---|---|
| 元组 | Tuple | 页中实际存放的一行数据 |
| 属性 | Attribute | 一行中的某一列的值 |
| 行外存储 | Out-of-line Storage | 把大字段搬到独立 TOAST 表 |
| 行内存储 | In-line Storage | 数据仍留在主表行内 |
| TOAST 表 | TOAST Table | 隐藏表 pg_toast.pg_toast_<oid>,存切碎后的数据块 |
| 压缩 | Compression | 先压缩再判断是否还需要行外存储 |
| 切片 | Chunk | TOAST 表中的一行,每片最多约 2KB |
TOAST 的设计目标是在不让用户感知的前提下,把「大字段」与「8KB 页」这两个互相冲突的约束协调起来。它由三个关键约束推导而出:
约束 1:页大小固定 8KB(BLCKSZ 编译期决定)
约束 2:元组不允许跨页,一行必须完整落在一页内
约束 3:页内还要留出空间给行指针、页头、其他列
推导结论:
· 一行可用空间约 8192 - 24(页头) - 4(行指针) ≈ 8164 字节
· TOAST_TUPLE_THRESHOLD = 约 2KB(实际 2032 字节)
→ 行总长超过该值即触发 TOAST 处理
· TOAST_TUPLE_TARGET = 约 2KB(实际 1996 字节)
→ 压缩/搬迁后要压回到该值以下才算成功为什么阈值是 2KB 而不是 8KB
历史上 PostgreSQL 还会把「多个字段」的元组放进页里,所以预留了 4 个元组的空间:8KB / 4 ≈ 2KB。这个保守值沿用至今,好处是一页能塞下更多行、降低 I/O 放大。行只要不超过 2KB,就完全不会触发 TOAST。
核心机制
触发与处理流程
INSERT / UPDATE 一行数据
│
▼
计算元组总长度
│
├─ ≤ TOAST_TUPLE_THRESHOLD(约 2KB)→ 直接原样写入主表页 ✔ 结束
│
▼ 超过阈值
遍历可 TOAST 的变长列,按策略依次处理
│
├─ 步骤 1:压缩(策略允许压缩时)
│ · 用 LZ4 / pglz 压缩该列的值
│ · 压不动(压缩后反而更大)就放弃压缩
│
├─ 重新计算总长度
│ ├─ ≤ TOAST_TUPLE_TARGET(约 2KB)→ 写入主表页 ✔ 结束
│ └─ 仍超阈值
│
├─ 步骤 2:行外存储(策略允许外移时)
│ · 把大字段值切成 ~2KB 的块
│ · 每块作为一行插入 pg_toast.pg_toast_<主表oid>
│ · 主表行内只留一个 18 字节的 TOAST 指针(varlena 头)
│
▼
写入主表页 ✔ 结束这里有一个关键点:压缩与行外存储是两个独立步骤,且顺序固定(先压缩,再外移)。所以一个 100KB 的可压缩文本,可能只靠压缩就回到了 2KB 以内,根本不会产生 TOAST 表数据。
四种存储策略
每一列的存储策略由 ALTER TABLE ... SET STORAGE 控制,也可以在建表时用 STORAGE 子句指定。这是理解 TOAST 行为的核心:
| 策略 | 允许压缩 | 允许行外存储 | 适用场景 | 典型列类型默认值 |
|---|---|---|---|---|
PLAIN | ✗ | ✗ | 定长类型,必须行内 | INTEGER、BOOLEAN、TIMESTAMPTZ |
EXTENDED | ✓ | ✓ | 最灵活,优先压缩再外移 | TEXT、BYTEA、JSONB、VARCHAR |
EXTERNAL | ✗ | ✓ | 不压缩直接外移,读回快 | 需要原样快速取回的大字段 |
MAIN | ✓ | 最后手段 | 优先压缩,尽量留行内 | 中等大小、想避免额外跳转的列 |
策略之间的取舍逻辑:
EXTENDED(默认)
优点:省磁盘,压缩后往往能留在行内,读取无额外跳转
缺点:每次读取都要解压,CPU 有开销
EXTERNAL
优点:不做压缩,取回时无需解压,且可直接读到子串(substring)
对已压缩数据(如 JPEG、gzip 内容)特别有用——反正压不动
缺点:不省磁盘
MAIN
优点:优先靠压缩留行内,减少 TOAST 表访问
缺点:若压缩后仍超阈值,仍会外移,且外移前要做很多尝试
PLAIN
优点:无任何 TOAST 开销,读取最快
缺点:完全不能存大值,超长直接报错
ERROR: row is too big: size 8168, maximum size 8160TEXT 列不是「随便多大都行」
即使 TOAST 有压缩与外移能力,单行最终仍需能放进 8KB 页(TOAST 指针本身很小,这一般不是问题)。真正会报错的是大量 PLAIN 或 MAIN 策略的列挤在一起。把大字段列设为 EXTERNAL 是解决「row is too big」的常用手段。
查看表的列策略
-- 建一张带大字段的示例表
CREATE TABLE articles (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title TEXT NOT NULL,
body TEXT NOT NULL, -- 正文,默认 EXTENDED
cover_image BYTEA, -- 图片二进制,默认 EXTENDED
meta JSONB DEFAULT '{}'::jsonb -- JSONB,默认 EXTENDED
);
-- 修改策略:图片是已压缩格式,改为 EXTERNAL 避免无谓的解压开销
ALTER TABLE articles ALTER COLUMN cover_image SET STORAGE EXTERNAL;
-- 查询各列的存储策略与统计信息
SELECT
a.attname AS 列名,
format_type(a.atttypid, a.atttypmod) AS 类型,
CASE a.attstorage
WHEN 'p' THEN 'PLAIN'
WHEN 'e' THEN 'EXTENDED'
WHEN 'x' THEN 'EXTERNAL'
WHEN 'm' THEN 'MAIN'
END AS 存储策略,
a.attcompression AS 压缩算法,
CASE WHEN a.attlen = -1 THEN '变长' ELSE '定长' END AS 长度特性
FROM pg_attribute a
WHERE a.attrelid = 'articles'::regclass
AND a.attnum > 0
AND NOT a.attisdropped
ORDER BY a.attnum; 列名 | 类型 | 存储策略 | 压缩算法 | 长度特性
-----------+--------------------+----------+----------+----------
id | bigint | PLAIN | | 定长
title | text | EXTENDED | | 变长
body | text | EXTENDED | | 变长
cover_image| bytea | EXTERNAL | | 变长
meta | jsonb | EXTENDED | | 变长
(5 rows)相关参数
| 参数 | 默认值 | 作用域 | 说明 |
|---|---|---|---|
default_toast_compression | pglz | 会话/实例 | 建列时的默认压缩算法,可选 pglz / lz4 |
toast_tuple_target | 2000 | 表级存储参数 | 单表可用,WITH (toast_tuple_target = 1024) |
toast.autovacuum_* | 继承全局 | 表级 | 可单独为 TOAST 表设置 autovacuum 参数 |
toast_tuple_threshold | 约 2KB | 编译期 | 硬编码阈值,不可运行时修改 |
-- 查看 TOAST 相关参数
SELECT name, setting, unit, short_desc
FROM pg_settings
WHERE name LIKE '%toast%'
ORDER BY name; name | setting | unit | short_desc
---------------------+---------+------+--------------------------------------------------
default_toast_compression | pglz | | Sets the default compression method for compressible
| | | values, such as columns of type text or bytea.
toast_tuple_target | 2000 | | Sets the target tuple length for TOAST-able tables.
(2 rows)详细说明
TOAST 表命名与定位
每张含可 TOAST 列的表,都会在 pg_toast 模式下有一张对应的隐藏表。命名规则固定:
-- 找出某张表对应的 TOAST 表(最可靠的方法)
SELECT
n.nspname AS toast模式,
c.relname AS toast表名,
c.oid AS toast_oid,
pg_relation_filepath(c.oid) AS 物理路径
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.oid = (SELECT reltoastrelid FROM pg_class WHERE oid = 'articles'::regclass); toast模式 | toast表名 | toast_oid | 物理路径
-----------+---------------------+-----------+--------------------------
pg_toast | pg_toast_16405 | 16408 | base/16384/16408
(1 row)命名规律是 pg_toast_<主表 relfilenode>。注意这里用的是 relfilenode 而非 oid,因为主表结构变更(如 ALTER TABLE)会重建 relfilenode。
-- 用 pg_class 一次性看全:主表、TOAST 表、TOAST 索引、主表索引
SELECT
c.relname,
c.relkind,
pg_size_pretty(pg_relation_size(c.oid)) AS 大小,
pg_relation_filepath(c.oid) AS 路径
FROM pg_class c
WHERE c.oid IN (
SELECT oid FROM pg_class WHERE relname = 'articles'
UNION ALL
SELECT reltoastrelid FROM pg_class WHERE relname = 'articles'
)
OR c.relname = 'pg_toast_16405_index'
ORDER BY c.relname; relname | relkind | 大小 | 路径
------------------------+---------+---------+---------------------
articles | r | 8192 kB | base/16384/16405
pg_toast_16405 | t | 2048 kB | base/16384/16408
pg_toast_16405_index | i | 16 kB | base/16384/16409
(3 rows)查看 TOAST 的实际内容
TOAST 表结构很简单:三个系统列。
-- 查看 TOAST 表的列结构
\d pg_toast.pg_toast_16405Column | Type | Collation | Nullable | Default
-----------+---------+-----------+----------+---------
chunk_id | oid | | not null |
chunk_seq | integer | | not null |
chunk_data | bytea | | not null |
Indexes:
"pg_toast_16405_index" PRIMARY KEY, btree (chunk_id, chunk_seq)| 列名 | 类型 | 说明 |
|---|---|---|
chunk_id | oid | 指向主表行中 TOAST 指针的标识 |
chunk_seq | integer | 该值切片后的序号,从 0 开始 |
chunk_data | bytea | 切片数据,每片最多 TOAST_MAX_CHUNK_SIZE ≈ 2KB |
-- 直接翻阅 TOAST 表内容(需要 superuser)
SELECT
chunk_id,
chunk_seq,
length(chunk_data) AS 分片字节
FROM pg_toast.pg_toast_16405
ORDER BY chunk_id, chunk_seq
LIMIT 10; chunk_id | chunk_seq | 分片字节
----------+-----------+----------
16405 | 0 | 1996
16405 | 1 | 1996
16405 | 2 | 1996
16405 | 3 | 512
(4 rows)一个 6.5KB 的值被切成了 4 片。TOAST 表上的主键索引 (chunk_id, chunk_seq) 保证了按序读取。
压缩算法
PostgreSQL 支持两种内建压缩算法,从 14 起可选 LZ4:
| 算法 | 引入版本 | 压缩率 | 压缩/解压速度 | 说明 |
|---|---|---|---|---|
pglz | 极早期(一直是默认) | 较高 | 较慢 | 自研算法,无外部依赖 |
lz4 | PostgreSQL 14+ | 略低 | 快得多 | 需编译时启用 --with-lz4 |
-- 查看当前实例是否支持 LZ4(编译期决定)
SELECT
name,
setting,
CASE WHEN setting = '' THEN '未编译支持' ELSE '可用' END AS 说明
FROM pg_settings
WHERE name = 'default_toast_compression';
-- 建表时指定压缩算法(PostgreSQL 14+)
CREATE TABLE logs (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
payload TEXT COMPRESSION lz4, -- 该列用 LZ4
note TEXT COMPRESSION pglz, -- 该列用 pglz
raw TEXT -- 用 default_toast_compression
);
-- 建表后再改压缩算法(只影响之后写入的数据,不会重写历史数据)
ALTER TABLE logs ALTER COLUMN payload SET COMPRESSION lz4; name | setting | 说明
----------------------------+---------+----------
default_toast_compression | lz4 | 可用
(1 row)改压缩算法不会立即瘦身
ALTER COLUMN ... SET COMPRESSION 只改变后续写入的行为。已有的旧数据仍按原算法存储,必须通过 UPDATE 重写行(或 VACUUM FULL / pg_repack)才会真正换算法。这是一个很容易被误解的点。
定位一行是否真的被 TOAST
-- 建表并插入两条对比数据
CREATE TABLE notes (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
body TEXT
);
-- 短文本:不触发 TOAST,行内存储
INSERT INTO notes (body) VALUES (repeat('短', 100));
-- 长且可压缩文本:触发 TOAST,且很可能被压缩后留在行内
INSERT INTO notes (body) VALUES (repeat('这是一段可高度压缩的中文文本。', 500));
-- 随机数据:不可压缩,必然外移到 TOAST 表
INSERT INTO notes (body) VALUES (encode(gen_random_bytes(20000), 'hex'));
-- 用 pg_column_size 看「值在行内的实际字节数」
-- 用 octet_length 看「值的逻辑字节数」(解 TOAST、解压后)
SELECT
id,
length(body) AS 字符数,
octet_length(body) AS 逻辑字节,
pg_column_size(body) AS 行内占用字节,
CASE
WHEN pg_column_size(body) = octet_length(body) AND octet_length(body) <= 2032
THEN '行内未压缩'
WHEN pg_column_size(body) < octet_length(body) AND pg_column_size(body) <= 2032
THEN '行内已压缩'
WHEN pg_column_size(body) < 20
THEN '行外存储(TOAST 指针)'
ELSE '其他'
END AS 存储形态
FROM notes
ORDER BY id; id | 字符数 | 逻辑字节 | 行内占用字节 | 存储形态
----+--------+----------+--------------+--------------------
1 | 100 | 300 | 300 | 行内未压缩
2 | 11000 | 33000 | 232 | 行内已压缩
3 | 40000 | 40000 | 18 | 行外存储(TOAST 指针)
(3 rows)这张输出是理解 TOAST 最好的教材:
- 第 1 行:300 字节,远低于阈值,原样行内。
- 第 2 行:33000 字节文本被压缩到 232 字节,仍留在行内——没有产生任何 TOAST 表数据,读取时只需解压,不需要额外 I/O。
- 第 3 行:随机 hex 完全不可压缩,被外移,主表行内只剩 18 字节的 TOAST 指针。
关键函数对比:
| 函数 | 是否解 TOAST | 是否解压 | 用途 |
|---|---|---|---|
pg_column_size(col) | 否(对行外值返回指针大小) | 否 | 判断值在行内占多少 |
octet_length(col) | 是 | 是 | 值的真实逻辑长度 |
length(col) | 是 | 是 | 值的字符数 |
实战示例
场景:为一篇公告正文选对存储策略,并量化收益
-- ---------- 第 1 步:建一张有代表性的表 ----------
DROP TABLE IF EXISTS notices;
CREATE TABLE notices (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title TEXT NOT NULL,
body TEXT NOT NULL,
attachment BYTEA,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ---------- 第 2 步:插入 1000 行,正文为可高度压缩的重复文本 ----------
INSERT INTO notices (title, body)
SELECT
'公告-' || g,
repeat('本次系统维护时间为凌晨 2:00 至 4:00,请提前保存工作内容。', 200)
FROM generate_series(1, 1000) AS g;
ANALYZE notices;
-- ---------- 第 3 步:量化 TOAST 的贡献 ----------
SELECT
pg_size_pretty(pg_relation_size('notices')) AS 主表大小,
pg_size_pretty(pg_relation_size((SELECT reltoastrelid
FROM pg_class
WHERE oid = 'notices'::regclass))) AS TOAST表大小,
pg_size_pretty(pg_total_relation_size('notices')) AS 含TOAST与索引总大小,
count(*) AS 行数,
pg_size_pretty(avg(pg_column_size(body))::bigint) AS 正文行内平均大小
FROM notices; 主表大小 | TOAST表大小 | 含TOAST与索引总大小 | 行数 | 正文行内平均大小
----------+-------------+---------------------+------+------------------
176 kB | 0 bytes | 296 kB | 1000 | 1746 bytes
(1 row)TOAST 表大小为 0,说明 200 段重复文本被 pglz 压到约 1.7KB 后全部留在行内 —— 压缩完全解决了问题,没有产生任何行外存储。这比「把数据搬到另一张表再 JOIN 回来」高效得多。
-- ---------- 第 4 步:换成不可压缩数据,再观察一次 ----------
TRUNCATE notices;
INSERT INTO notices (title, body)
SELECT
'公告-' || g,
encode(gen_random_bytes(6000), 'base64') -- 近似随机,几乎不可压缩
FROM generate_series(1, 1000) AS g;
ANALYZE notices;
SELECT
pg_size_pretty(pg_relation_size('notices')) AS 主表大小,
pg_size_pretty(pg_relation_size((SELECT reltoastrelid
FROM pg_class WHERE oid = 'notices'::regclass))) AS TOAST表大小,
pg_size_pretty(pg_total_relation_size('notices')) AS 总大小,
round(100.0 * pg_relation_size((SELECT reltoastrelid
FROM pg_class WHERE oid = 'notices'::regclass))
/ pg_total_relation_size('notices'), 1) AS TOAST占比百分比
FROM notices LIMIT 1; 主表大小 | TOAST表大小 | 总大小 | TOAST占比百分比
----------+-------------+---------+-----------------
264 kB | 8192 kB | 8.6 MB | 93.0结论清晰:不可压缩时,绝大多数数据都在 TOAST 表里(93%)。这也解释了为什么 pg_relation_size('notices') 看着很小,但 pg_total_relation_size('notices') 却很大 —— 排查磁盘占用必须用后者。
场景:验证行外存储会影响读取性能
-- 强制走 TOAST:把正文列改为 EXTERNAL(不压缩,直接外移)
ALTER TABLE notices ALTER COLUMN body SET STORAGE EXTERNAL;
VACUUM FULL notices; -- 重写所有行,让新策略对历史数据生效
-- 打开计时,对比「只读主表列」与「读 TOAST 列」
\timing on
-- 只读 id:不需要访问 TOAST,只读主表页
SELECT count(id) FROM notices;
-- 读 length(body):必须解 TOAST,产生额外的 TOAST 表随机 I/O
SELECT sum(length(body)) FROM notices;
\timing off count
-------
1000
(1 row)
Time: 0.412 ms
sum
---------
8054000
(1 row)
Time: 31.680 ms差了两个数量级。这不是 TOAST 的「缺陷」,而是它的设计代价:主表行变小了,扫描主表变快了;只有真正需要那个大字段时才付额外代价。这正是 TOAST 的价值所在 —— 避免为了存一个大字段而拖慢所有不读该字段的查询。
注意事项
- TOAST 阈值约 2KB,不是 8KB。行不超过 2KB 就完全不触发任何 TOAST 处理。
pg_relation_size()不包含 TOAST 表,估算磁盘必须用pg_total_relation_size()。ALTER ... SET COMPRESSION不会重写历史数据,需要VACUUM FULL或pg_repack才生效。EXTERNAL策略下删除或更新会留下孤儿 TOAST 数据,必须等VACUUM清理,这是 TOAST 表膨胀的主因。- 不要直接修改 TOAST 表数据,它由系统维护,手工操作会破坏一致性。
-- 错误!TOAST 表由系统管理,不可直接写入或删除
-- INSERT INTO pg_toast.pg_toast_16405 VALUES (0, 0, '\x00'); -- 错误!
-- DELETE FROM pg_toast.pg_toast_16405; -- 错误!PLAIN策略的列存不下大值时会直接报错,而不是自动外移。给可长大字段设PLAIN是常见误用。
-- 不推荐:给可能很长的 text 列设 PLAIN,超长即报错
-- ALTER TABLE notices ALTER COLUMN body SET STORAGE PLAIN; -- 不推荐- TOAST 表的 autovacuum 是独立的。大字段频繁更新时,需要单独关注 TOAST 表的膨胀。
- 压缩与「应用层压缩」不要重复做。程序里已经 gzip 过的数据,数据库再压几乎没有收益,反而浪费 CPU —— 这类列应考虑
EXTERNAL。
最佳实践
- 大字段列一律保留默认的
EXTENDED,除非有明确性能理由才改。 - 已压缩的二进制(图片、视频、zip、PDF)改用
EXTERNAL,避免无谓的压缩尝试与解压开销。 - 避免对
SELECT *读大表,SELECT *会强制解 TOAST,即使调用方并不需要那个字段。 - 超长文本列拆表:如果一张表既有小字段的热点访问、又有大字段,拆成主表 + 详情表往往比依赖 TOAST 更可控。
- 高写入场景启用 LZ4(PostgreSQL 14+):
default_toast_compression = 'lz4',压缩/解压更快。 - 监控 TOAST 表体积,把它纳入表体积巡检:
pg_total_relation_size与pg_relation_size的差值就是 TOAST 与索引。 - 大字段更新后确认 autovacuum 覆盖到 TOAST 表,避免 TOAST 表持续膨胀。
- 用
pg_column_size()做容量评估,而不是用业务层的字符串长度。 - 不要用大字段做索引或排序键,会带来极差的性能与巨大的索引体积。
- 定期用
VACUUM FULL/pg_repack整理高频更新大字段的表,回收 TOAST 中残留的旧版本。
下一节
继续学习:空闲空间映射(FSM)