Skip to content

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先压缩再判断是否还需要行外存储
切片ChunkTOAST 表中的一行,每片最多约 2KB

TOAST 的设计目标是在不让用户感知的前提下,把「大字段」与「8KB 页」这两个互相冲突的约束协调起来。它由三个关键约束推导而出:

text
约束 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。

核心机制 ​

触发与处理流程 ​

text
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✓最后手段优先压缩,尽量留行内中等大小、想避免额外跳转的列

策略之间的取舍逻辑:

text
EXTENDED(默认)
  优点:省磁盘,压缩后往往能留在行内,读取无额外跳转
  缺点:每次读取都要解压,CPU 有开销

EXTERNAL
  优点:不做压缩,取回时无需解压,且可直接读到子串(substring)
        对已压缩数据(如 JPEG、gzip 内容)特别有用——反正压不动
  缺点:不省磁盘

MAIN
  优点:优先靠压缩留行内,减少 TOAST 表访问
  缺点:若压缩后仍超阈值,仍会外移,且外移前要做很多尝试

PLAIN
  优点:无任何 TOAST 开销,读取最快
  缺点:完全不能存大值,超长直接报错
        ERROR: row is too big: size 8168, maximum size 8160

TEXT 列不是「随便多大都行」

即使 TOAST 有压缩与外移能力,单行最终仍需能放进 8KB 页(TOAST 指针本身很小,这一般不是问题)。真正会报错的是大量 PLAIN 或 MAIN 策略的列挤在一起。把大字段列设为 EXTERNAL 是解决「row is too big」的常用手段。

查看表的列策略 ​

sql
-- 建一张带大字段的示例表
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;
text
  列名     |        类型        | 存储策略 | 压缩算法 | 长度特性
-----------+--------------------+----------+----------+----------
 id        | bigint             | PLAIN    |          | 定长
 title     | text               | EXTENDED |          | 变长
 body      | text               | EXTENDED |          | 变长
 cover_image| bytea             | EXTERNAL |          | 变长
 meta      | jsonb              | EXTENDED |          | 变长
(5 rows)

相关参数 ​

参数默认值作用域说明
default_toast_compressionpglz会话/实例建列时的默认压缩算法,可选 pglz / lz4
toast_tuple_target2000表级存储参数单表可用,WITH (toast_tuple_target = 1024)
toast.autovacuum_*继承全局表级可单独为 TOAST 表设置 autovacuum 参数
toast_tuple_threshold约 2KB编译期硬编码阈值,不可运行时修改
sql
-- 查看 TOAST 相关参数
SELECT name, setting, unit, short_desc
FROM pg_settings
WHERE name LIKE '%toast%'
ORDER BY name;
text
        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 模式下有一张对应的隐藏表。命名规则固定:

sql
-- 找出某张表对应的 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);
text
 toast模式 |      toast表名      | toast_oid |        物理路径
-----------+---------------------+-----------+--------------------------
 pg_toast  | pg_toast_16405      |     16408 | base/16384/16408
(1 row)

命名规律是 pg_toast_<主表 relfilenode>。注意这里用的是 relfilenode 而非 oid,因为主表结构变更(如 ALTER TABLE)会重建 relfilenode。

sql
-- 用 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;
text
        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 表结构很简单:三个系统列。

sql
-- 查看 TOAST 表的列结构
\d pg_toast.pg_toast_16405
text
Column     |  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_idoid指向主表行中 TOAST 指针的标识
chunk_seqinteger该值切片后的序号,从 0 开始
chunk_databytea切片数据,每片最多 TOAST_MAX_CHUNK_SIZE ≈ 2KB
sql
-- 直接翻阅 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;
text
 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极早期(一直是默认)较高较慢自研算法,无外部依赖
lz4PostgreSQL 14+略低快得多需编译时启用 --with-lz4
sql
-- 查看当前实例是否支持 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;
text
 name                       | setting |   说明
----------------------------+---------+----------
 default_toast_compression  | lz4     | 可用
(1 row)

改压缩算法不会立即瘦身

ALTER COLUMN ... SET COMPRESSION 只改变后续写入的行为。已有的旧数据仍按原算法存储,必须通过 UPDATE 重写行(或 VACUUM FULL / pg_repack)才会真正换算法。这是一个很容易被误解的点。

定位一行是否真的被 TOAST ​

sql
-- 建表并插入两条对比数据
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;
text
 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)是是值的字符数

实战示例 ​

场景:为一篇公告正文选对存储策略,并量化收益 ​

sql
-- ---------- 第 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;
text
 主表大小 | TOAST表大小 | 含TOAST与索引总大小 | 行数 | 正文行内平均大小
----------+-------------+---------------------+------+------------------
 176 kB   | 0 bytes     | 296 kB              | 1000 | 1746 bytes
(1 row)

TOAST 表大小为 0,说明 200 段重复文本被 pglz 压到约 1.7KB 后全部留在行内 —— 压缩完全解决了问题,没有产生任何行外存储。这比「把数据搬到另一张表再 JOIN 回来」高效得多。

sql
-- ---------- 第 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;
text
 主表大小 | TOAST表大小 | 总大小  | TOAST占比百分比
----------+-------------+---------+-----------------
 264 kB   | 8192 kB     | 8.6 MB  |            93.0

结论清晰:不可压缩时,绝大多数数据都在 TOAST 表里(93%)。这也解释了为什么 pg_relation_size('notices') 看着很小,但 pg_total_relation_size('notices') 却很大 —— 排查磁盘占用必须用后者。

场景:验证行外存储会影响读取性能 ​

sql
-- 强制走 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
text
 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 表数据,它由系统维护,手工操作会破坏一致性。
sql
-- 错误!TOAST 表由系统管理,不可直接写入或删除
-- INSERT INTO pg_toast.pg_toast_16405 VALUES (0, 0, '\x00');  -- 错误!
-- DELETE FROM pg_toast.pg_toast_16405;                        -- 错误!
  • PLAIN 策略的列存不下大值时会直接报错,而不是自动外移。给可长大字段设 PLAIN 是常见误用。
sql
-- 不推荐:给可能很长的 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)

参考链接 ​