云计算百科
云计算领域专业知识百科平台

DeepSeek总结的PG Phriday:烤焦的 TOAST

PG Phriday:烤焦的 TOAST

Shaun Thomas | 2026年10月31日

2010 年,一家金融公司雇我修改他们的 Postgres 集群架构。在那里工作期间,我学会了对他们的 event_log 表怀有一种健康的敬畏。但也许“敬畏”在这里用词不当,因为它更像是一种深深的厌恶。你看,这张表是系统事件的规范账本,由数百万行组成。

行数多本身不是问题。我们保持了很紧的可见性窗口并经常归档。不幸的是,其中一列是不受约束的 TEXT 类型,通常持有大块 XML。因此,从这张表进行的任何获取都极其缓慢。写入同样痛苦。我们费尽心思调整应用本身,以避免触碰它,除非绝对必要。它是邪恶的化身。

这张表的性能最初让我困惑和愤怒——直到我想起了 TOAST。Postgres 喜欢无害地把这个反向首字母缩略词称为“超尺寸属性存储技术”(The Oversized-Attribute Storage Technique),但现场的 DBA 对这个系统保持着爱恨交加的关系。这是否合理是另一个问题。

表面上,它是一种将大块数据存储在其他地方以保持元组小、避免超过相对较小的 8KB 数据页大小的方法。Postgres 为庞大内容创建一个影子表,将其分成块,并在原始行中存储一个指针。这是对紧迫问题的一个优雅解决方案,但有深远的性能影响。

那么,让我们深入探究为什么 DBA 暗中畏惧大型文本列吧?

变得 TOASTy

为了真正理解我的 event_log 案例中发生了什么,最好重建一些类似的东西。

CREATE TABLE event_log
(
event_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
ticker VARCHAR(4) NOT NULL,
strike INT NOT NULL,
shares INT NOT NULL,
event_data JSON NOT NULL — 我们的是 XML,但现代了……
— 更多列在这里
);

然后我们可以用 10 万行引导这张表。刚好够让人烦!数据列需要大,但不是绝对巨大。让我们从最多 1000 个数据点开始:

INSERT INTO event_log (ticker, strike, shares, event_data)
SELECT left(md5((a.id % 100)::TEXT), 4), a.id % 100, a.id % 100 * 10,
(SELECT jsonb_object_agg('field_' || f, random(1, 1000))
FROM generate_series(1, id % 1000 + 1 ) f)
FROM generate_series(1, 100000) a (id);

太好了!现在所有这些数据去了哪里?如果你以为,“当然是进入 event_log 表!”你错了。相反,Postgres 贴心地为其中大部分创建了一个 TOAST 表。让我们检查 pg_class 目录中的其他对象:

SELECT c.relname, t.relname AS toast_table, i.relname AS toast_index
FROM pg_class c
JOIN pg_class t ON t.oid = c.reltoastrelid
JOIN pg_index x ON x.indrelid = t.oid
JOIN pg_class i ON i.oid = x.indexrelid
WHERE c.relname = 'event_log';

relname | toast_table | toast_index
———–+—————-+———————-
event_log | pg_toast_16401 | pg_toast_16401_index

一份价钱三个对象:原始表、一个 toast 表和一个 toast 表索引。这并不总是发生,但如果你的表有一个没有最大长度的文本类列,它很可能发生了。在这种情况下,是因为无界的 JSON 列可以包含任何东西,所以 Postgres 先发制人地初始化了一个 TOAST 表来存储“任何东西”。

放进去的东西更微妙一些,也是麻烦开始酝酿的地方。

复合黄油

有一种固有的层级结构决定数据如何被管理。pg_type 目录中的 typstorage 列解释了这对 Postgres 理解的每种类型如何工作。有四种有效的存储方法:

  • PLAIN §:值保持内联且未压缩。这是 INT 和 TIMESTAMPTZ 等固定宽度类型的默认值。
  • MAIN (m):允许压缩,TOAST 仅作为最后手段。NUMERIC 的默认值。
  • EXTERNAL (e):允许 TOAST,但不允许压缩。
  • EXTENDED (x):任何方式都可以。这是 TEXT、BYTEA、JSONB 和大多数其他变长类型的默认值。

Postgres 对元组中的所有文本类列类型施加一个循环,反复应用直到整行低于标准的 8KB 页阈值(减去开销)。它看起来像这样:

PostgreSQL TOAST 存储流程图

作为 JSON 的默认值,我们所有的数据都遵循 EXTENDED 的规则。我们可以通过检查几行来看它实际运作:

SELECT ctid, event_id, length(event_data::TEXT) AS chars,
pg_column_size(event_data) AS stored_bytes,
pg_column_toast_chunk_id(event_data) AS toast_id
FROM event_log
WHERE event_id BETWEEN 335 AND 340
ORDER BY event_id;

ctid | event_id | chars | stored_bytes | toast_id
——–+———-+——-+————–+———-
(57,2) | 335 | 5907 | 1981 |
(57,3) | 336 | 5930 | 2004 | 16414
(57,4) | 337 | 5942 | 1987 | 16415
(57,5) | 338 | 5953 | 2005 | 16416
(57,6) | 339 | 5978 | 1996 | 16417
(57,7) | 340 | 5988 | 2034 | 16418

toast_tuple_target 存储设置告诉 Postgres 何时 toast 列内容。默认值约为 2KB 的压缩数据,之后的任何内容都会自动移到 TOAST。上面的示例有一行压缩后正好低于该限制,因此从未被移动。

如果我们专门检查第 57 页,这就更加清楚了:

CREATE EXTENSION IF NOT EXISTS pageinspect;

SELECT lp, lp_len
FROM heap_page_items(get_raw_page('event_log', 57))
LIMIT 5;

lp | lp_len
—-+——–
1 | 2022
2 | 2029
3 | 66
4 | 66
5 | 66

没有被 toast 的行的元组大小远大于被 toast 的。这是因为一个指针替换了被 toast 的值。这还不是全部。被 toast 的数据也被分成块。每个被 toast 的值被分成 1996 字节的块,并按顺序作为行存储在 toast 表中。

现在考虑这对读取意味着什么:

  • 任何被 toast 的值意味着至少一次从 toast 表获取。每个块是一次对 toast 索引的读取和一次对 toast 表本身的读取。
  • 块必须被重新组装。那是额外的内存使用,根据块数可能很显著。
  • 压缩数据还必须被解压缩。那既是内存开销也是 CPU 开销。

直觉上读起来,TOAST 总是更糟。但真是这样吗?

加热

让我们做个实验!我们已经有一张表,其中一些数据被 toast 了。如果我们强制 Postgres 选择所有数据会发生什么?

这需要一些设置。首先,我们通过将 event_data 列设置为使用 MAIN 存储来创建一个未 toast 的表。这允许它被压缩,但除非整个元组超过 Postgres 页大小,否则不会被 toast。像这样:

CREATE TABLE non_toast (LIKE event_log INCLUDING INDEXES);
ALTER TABLE non_toast ALTER event_data SET STORAGE MAIN;
INSERT INTO non_toast SELECT * FROM event_log;

然后我们可以直接比较从数据中提取 field_999 的成本:

EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, SUMMARY OFF)
SELECT count(event_data–>>'field_999') FROM event_log;

Aggregate (actual time=6589.068..6589.070 rows=1.00 loops=1)
Buffers: shared hit=246472 read=41655
–> Seq Scan on event_log (actual time=0.221..17.022 rows=100000.00 loops=1)
Buffers: shared read=5969

EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, SUMMARY OFF)
SELECT count(event_data–>>'field_999') FROM non_toast;

Aggregate (actual time=6166.877..6166.880 rows=1.00 loops=1)
Buffers: shared read=40754
–> Seq Scan on non_toast (actual time=0.180..51.434 rows=100000.00 loops=1)
Buffers: shared read=40754

看看那个!共享缓冲区在这上面真的救了我们的命。被 toast 的列的总缓冲区读取需要 288127 次读取,而未 toast 版本为 40754 次。相反,被 toast 的表小得多:

SELECT pg_size_pretty(pg_relation_size('event_log')) AS event_log,
pg_size_pretty(pg_relation_size('non_toast')) AS non_toast;

event_log | non_toast
———–+———–
47 MB | 318 MB

虽然 Postgres 可以更快地读取堆,但它随后必须花大量时间在 toast 表中搜索。这种大小差异转化为一些非直觉的行为。例如,与被 toast 的表的大多数交互实际上快得多。考虑计数所有行:

EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, SUMMARY OFF)
SELECT count(event_data) from event_log;

Aggregate (actual time=26.397..26.398 rows=1.00 loops=1)
Buffers: shared hit=282 read=5567
–> Seq Scan on event_log (actual time=0.369..13.726 rows=100000.00 loops=1)
Buffers: shared hit=282 read=5567

EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, SUMMARY OFF)
SELECT count(event_data) from non_toast;

Aggregate (actual time=91.840..91.841 rows=1.00 loops=1)
Buffers: shared hit=282 read=40670
–> Seq Scan on non_toast (actual time=0.303..74.908 rows=100000.00 loops=1)
Buffers: shared hit=282 read=40670

那是 26ms 对 91ms。或者跳过偏移:

EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, SUMMARY OFF)
SELECT * from event_log LIMIT 1 OFFSET 99999;

Limit (actual time=16.579..16.581 rows=1.00 loops=1)
Buffers: shared hit=377 read=5592
–> Seq Scan on event_log (actual time=0.215..12.908 rows=100000.00 loops=1)
Buffers: shared hit=377 read=5592

EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, SUMMARY OFF)
SELECT * from non_toast LIMIT 1 OFFSET 99999;

Limit (actual time=53.276..53.277 rows=1.00 loops=1)
Buffers: shared hit=188 read=40566
–> Seq Scan on non_toast (actual time=0.240..49.551 rows=100000.00 loops=1)
Buffers: shared hit=188 read=40566

现在是 16ms 对 53ms。甚至更新:

\\timing on

UPDATE event_log
SET shares = shares + 1
WHERE ticker = 'b53b';

Time: 62.412 ms

UPDATE non_toast
SET shares = shares + 1
WHERE ticker = 'b53b';

Time: 128.282 ms

每当 Postgres 必须直接处理堆时,性能就会大幅下降。Toast 指针可以作为一种优化,特别是当不与被 toast 的列交互时,因为它们使堆上的每一行小得多。

这一点有点让我大开眼界。事实证明,event_log 表的问题不一定是 TOAST 系统,而是 event_data 列本身。事实证明,有一种方法可以解决这个问题。

达到金黄色

当时,“解决这个问题”意味着拆分表。我们的玩具示例只有 10 万行,但真实的有数百万行,仅堆就超过 1GB。对那些巨大的 XML blob 包括 TOAST 使总表大小超过 20GB。

当一个应用充满 SELECT * 并反复查找和解码被 toast 的数据时,它最终会付出代价。即使我们忽略磁盘 IO,那也是大量额外的网络带宽。“只要指定列名!”你可能会说,我同意。要是修改 ORM 生成的查询和五年积累的手写查询那么容易就好了。

手术看起来像这样:

CREATE TABLE event_new
(
event_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
ticker VARCHAR(4) NOT NULL,
strike INT NOT NULL,
shares INT NOT NULL
— 更多列在这里
);

CREATE TABLE event_log_data
(
event_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
event_data JSON NOT NULL
);

INSERT INTO event_new (event_id, ticker, strike, shares)
SELECT event_id, ticker, strike, shares
FROM event_log;

INSERT INTO event_log_data (event_id, event_data)
SELECT event_id, event_data
FROM event_log;

ALTER TABLE event_log RENAME TO event_log_old;
ALTER TABLE event_new RENAME TO event_log;

我们从中获得了什么?首先,我们不再需要在主应用表中携带 TOAST 指针。其次,由于 toast_tuple_target 截止值,这一列混合了被 toast 和未被 toast 的值,甚至未被 toast 的值也被迁移了。我们可以在新表堆大小中看到这一点:

SELECT pg_size_pretty(pg_relation_size('event_log')) AS event_log,
pg_size_pretty(pg_relation_size('event_log_data')) AS non_toast;

event_log | non_toast
———–+———–
5096 kB | 46 MB

所有操作现在都将大幅加快,因为页密度现在保证更高。每页容纳更多行,表示表主要核心所需的页更少。整个堆现在是 5MB 而不是 47MB,节省了 9 倍以上。如果在高性能系统中完全可以将大列迁移到可选的 JOIN 中,那可能值得再看一眼。

如果你认为我们可以通过将 toast_tuple_target 任意设低来达到类似效果,再想想。如果行值短于 TOAST_TUPLE_THRESHOLD(一个编译时值,充当硬性最小值),TOAST 系统甚至不会激活。低于此值的值甚至不会被考虑 toast。我能说什么呢?Postgres 是个挑食者。

明显的反对意见是应用现在需要一个连接。好吧,Postgres 计划器已经为你考虑到了:

CREATE OR REPLACE VIEW event_log_all AS
SELECT e.*, d.event_data
FROM event_log e
LEFT JOIN event_log_data d USING (event_id);

EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, SUMMARY OFF)
SELECT event_id, strike, shares
FROM event_log_all WHERE event_id = 42;

Index Scan using event_new_pkey on event_log e (actual time=0.032..0.034 rows=1.00 loops=1)
Index Cond: (event_id = 42)
Index Searches: 1
Buffers: shared hit=3

诀窍是 LEFT JOIN,使事件数据可选,即使我们知道每一行那里都有值。不指定该列的查询会剪掉 JOIN,每个人都很高兴。

脆脆的涅槃

从 Postgres 14 起,甚至可以选择压缩类型。坚持使用古老的内置 pglz,或者如果它已编译进你的二进制文件,使用 lz4。整个服务器的默认值来自 default_toast_compression:

SHOW default_toast_compression;

default_toast_compression
—————————
pglz

或者可以在 CREATE TABLE 或 ALTER TABLE 期间使用 COMPRESSION 装饰器设置。

关于压缩类型的一个有趣说明是 pglz 有点古怪。它跳过小于 32 字节的值,要求至少节省 25%,如果在前 1KB 中找不到任何可压缩的内容就完全放弃。最后一条规则在实际中看到有点奇怪:

CREATE EXTENSION pgcrypto;

CREATE TABLE squeeze (method TEXT, payload TEXT);

ALTER TABLE squeeze ALTER payload SET COMPRESSION pglz;
INSERT INTO squeeze VALUES ('pglz', encode(gen_random_bytes(1024), 'base64') || repeat('Postgres! ', 1000));

ALTER TABLE squeeze ALTER payload SET COMPRESSION lz4;
INSERT INTO squeeze VALUES ('lz4', encode(gen_random_bytes(1024), 'base64') || repeat('Postgres! ', 1000));

SELECT method, length(payload) AS chars,
pg_column_size(payload) AS stored,
pg_column_compression(payload) AS compression,
pg_column_toast_chunk_id(payload) AS toasted_chunk
FROM squeeze;

method | chars | stored | compression | toasted_chunk
——–+——-+——–+————-+—————
pglz | 11385 | 11385 | | 415076
lz4 | 11385 | 1517 | lz4 |

还有一些相关的性能影响。考虑:

— 第一次用 pglz 测试

TRUNCATE TABLE squeeze;

ALTER TABLE squeeze ALTER payload SET COMPRESSION pglz;

\\timing on

INSERT INTO squeeze
SELECT 'pglz', repeat('Postgres!', 10000)
FROM generate_series(1, 100000);

Time: 52179.203 ms (00:52.179)

SELECT sum(length(payload)) FROM squeeze;

sum
————
9000000000

Time: 17108.284 ms (00:17.108)

— 第二次用 lz4 测试

TRUNCATE TABLE squeeze;

ALTER TABLE squeeze ALTER payload SET COMPRESSION lz4;

\\timing on

INSERT INTO squeeze
SELECT 'lz4', repeat('Postgres!', 10000)
FROM generate_series(1, 100000);

Time: 1774.048 ms (00:01.774)

SELECT sum(length(payload)) FROM squeeze;

sum
————
9000000000
(1 row)

Time: 20929.714 ms (00:20.930)

处理 10 万行 10k 压缩值时,lz4 解压缩稍慢,在这种情况下大约慢 20%,但压缩快 30 倍以上。那绝对令人震惊。

最终想法

看,我们都想玩指责游戏。我总是把应用 event_log 表性能差归咎于 TOAST 系统。每次有人抱怨。任何一次编写糟糕的查询通过应用并以某种方式触碰表,显然激怒了它,导致每次页面加载翻四倍或完全超时。在我的脑海里,TOAST 有错。

讽刺的是,在某种程度上确实是 TOAST 的错,因为它不够激进。想想看。Postgres 需要 TOAST_TUPLE_THRESHOLD 来避免激活解构、压缩和将数据移到外部表的昂贵机制。但想象一下,如果任何东西都可以任意卸载,情况会怎样。不幸的是没有这样的选项。在 PLAIN、MAIN、EXTENDED 和 EXTERNAL 中,没有一个覆盖“移动任何超过 100 字节的东西,如果超过 2k 也压缩它”的情况。不过有一个论点支持对中等大小但“较大”的列使用 EXTERNAL:我们获得更小堆的好处,而小于 1kb 的数据本来就不太容易压缩好。

相反,我们必须妥协我们的模式设计,并为实际与庞大列交互的应用部分引入一个额外的连接。视图在某种程度上绕过了这一点,但与保持表完整相比,这有点像一个丑陋的 hack。我们也不能每次出现大列时都利用这个策略,否则我们的模式最终会变成一个可怕的烂摊子。并非所有列都足够“可选”以被驱逐。

TOAST 提供的是一个合适的中间地带。以指针的代价,我们基本上可以忘记表中存在一大块文本。堆更快乐,我们也更明智。对于切换到 lz4 的人来说,压缩速度也显著更快。

所以不要害怕 Postgres TOAST;它是最好的那种。

赞(0)
未经允许不得转载:网硕互联帮助中心 » DeepSeek总结的PG Phriday:烤焦的 TOAST
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!