1. 从一次“物理备份差点翻车”说起
先说个我早年的真实经历。当时接手一套业务系统,里面有一张核心流水表,每条记录都存了一份完整的请求响应快照,字段类型是text。单条数据不大,平均也就几十KB,但表总行数到了上亿,整张表物理体积轻松突破几百GB。最要命的是,每次跑全量备份,耗时从原来的半小时一路涨到四五个小时,存储空间也吃紧。我一开始以为是单纯的数据量大,直到某天翻pg_relation_size发现一个诡异现象:主表大小只有几GB,但旁边一张名字里带pg_toast_的附属表,体积居然是主表的几十倍。
那时候我才认认真真去研究 PostgreSQL 的大字段存储机制——也就是标题里写的 TOAST(The Oversized-Attribute Storage Technique,超尺寸属性存储技术)。搞懂它之后,很多之前“说不清为什么”的现象都通了:为什么主表那么小?为什么某些查询快、某些查询慢?为什么压缩字段有时反而拖后腿?这篇就把我踩过的坑和梳理过的原理一起讲清楚,适合正在做 PostgreSQL 运维、表结构设计,或者想深入理解数据库存储层的朋友。
提示:这篇文章涉及的命令都是基于 PostgreSQL 常用版本,具体函数名和系统视图在各版本间差异很小,可以放心直接用。
2. 为什么需要TOAST:8KB块和2KB行的硬约束
2.1 PostgreSQL的存储模型决定了天花板
要理解 TOAST,先得理解 PostgreSQL 底层的存储规则。PostgreSQL 的数据文件是按“块(block)”组织的,默认一个块是 8KB,这个值由编译参数BLCKSZ决定,绝大多数发行版都是这个值。表里的每一行数据,最终要落到这些 8KB 的块里,而且一条记录不能跨块存放(TOAST 是唯一的例外机制,后面会讲)。
但“不能跨块”不等于“最大行就是满打满算 8KB”。每条记录在块里还要额外携带行头信息,比如事务ID、行指针、NULL 位图等等,这部分固定开销通常有个几十字节。再加上块本身还有页头、行指针数组需要占用。所以严格算下来,普通堆表中一行数据可用空间大概在 1.9KB 左右。也就是说,如果你有一行数据超过这个大小,直接塞进普通堆表块里就是不可能的。
你可以做个小实验验证一下,建一张只有int列的表,去查pg_column_size看一行要占多少字节,然后把一列改成text并塞入一个较大的字符串,再对比系统表中的信息。实测下来,单行可用空间很低,超过某个阈值就会被自动处理,这个阈值不是 2KB 整,而是BLCKSZ / 4再减去一部分固定开销,大约在 1960 字节上下。
2.2 为什么 mobile开发里常见的JSON、日志、图片字段最容易触发
业务开发中真正会触发 TOAST 的,几乎都是“大字段”,典型的有这几种:
text和varchar:超长文本、JSON 字符串、日志全文。bytea:图片二进制、文件内容、加密数据。jsonb和json:嵌套复杂的接口返回值、请求响应快照。- 自定义复合类型或数组类型,元素特别多时也会变大。
这些字段的共同特点是:本身可变长,而且可能很大。小数值类型的int、bigint完全不存在这个问题,因为它们的长度上限远低于 TOAST 阈值,PostgreSQL 也会直接给它们标记为PLAIN存储策略,不做行外处理。
2.3 TOAST的全称和设计思路
TOAST 的全称是The Oversized-Attribute Storage Technique,设计核心就是一句话:把大字段从主表行里挪出去,必要时先压缩,然后在主表中只保留一个指针。这样主表的一行数据仍然可以保持很小,不会突破块的限制,而大字段本体被拆成若干分片,放在一个隐藏的附属表里。
关键点在于:这个机制对应用完全透明。你插入一个几十MB的text字段,SQL 不需要做任何特殊处理;你查询这张表时,PostgreSQL 会在需要时自动把 TOAST 表里的分片拼回来,解压后返回给你。应用层面根本感知不到底层经历了“压缩→行外→分片→重组→解压”这一整套流程。这也是 TOAST 最精髓的地方:能用底层机制解决问题,就不给应用增加负担。
2.4 TOAST是“表”还是“机制”?
严格说,TOAST 并不是一张固定的表,而是一套存储行为。每张需要支持大字段的表,在首次出现超阈值数据时,PostgreSQL 会在pg_class.reltoastrelid里登记一个关联附件表的 OID,这个附件表的名字就是pg_toast_<主表OID>。主表与 TOAST 表通过“隐式JOIN”关联,但对用户来说,平时查询是看不到也不需要关心这张表存在的。
你可以在系统视图里找到这些信息:
SELECT relname, reltoastrelid FROM pg_class WHERE relname = 'user_events';如果reltoastrelid不为0,就说明这张表有 TOAST 表。直接查这张附属表的大小:
SELECT pg_relation_size(reltoastrelid) AS toast_size_bytes FROM pg_class WHERE relname = 'user_events';我在实际排查时,经常会接到一个需求:“为什么我的表主文件只有 500MB,备份出来却要 5GB?” 十有八九就是 TOAST 表在作祟,用上面这个查询一秒钟就能定位。
3. TOAST完整流程:压缩、行外存储和分片重组
3.1 触发阈值到底是怎么算出来的
TOAST 的触发阈值并非拍脑袋定的,而是从块大小推导来的。默认块大小是 8KB,PostgreSQL 的设计目标是让主表行的最大长度不超过块大小的四分之一,也就是约 2KB,这样每块至少能放下四行有代表性的数据。然后减去行头开销和块内管理开销,最后得到的值就是触发阈值。
你可以通过编译参数BLCKSZ直接决定块大小。如果BLCKSZ是 16KB,那么 TOAST 阈值大约就是 4KB 左右。这个参数在编译时固定,后续无法修改,所以平时我们说的“2KB左右”指的是默认 8KB 块下的情况。
如果一行数据小于这个阈值,PostgreSQL 直接用普通存储,不启用 TOAST;一旦超过,TOAST 流程就开始介入。
3.2 先压缩还是先存出去:策略排序问题
TOAST 的存储流程有固定的优先级:如果选择了压缩策略,先尝试压缩;压缩之后如果仍然大于阈值,再进行行外存储。这个顺序很关键,因为压缩可以大幅缩减体积,很多时候压缩完就直接缩到阈值以内,根本不需要动用行外存储。
举个例子:一段 10KB 的重复性 JSON 文本,用内置压缩算法通常能压到 1KB 以下,压缩后直接留在主表行里,不占用 TOAST 表空间,读取时也更快。
但如果字段本身是不可压缩的,比如已经加密的数据、压缩过的图片(JPEG、PNG)、随机数生成的二进制串,压缩后体积基本不变,这时候就会触发行外存储。数据本体被挪到 TOAST 表,主表行内只留一个约 18 字节的指针(用来指向TOAST表数据的定位信息)。
你可以查一个字段压缩前的实际体积和压缩后的实际体积来验证:
SELECT pg_column_size(body) AS raw_size, pg_column_compression(body) AS compression FROM user_events LIMIT 1;pg_column_compression会告诉你这个字段实际用的是哪种压缩算法。如果返回空,说明没有压缩生效,可能被EXTERNAL策略绕过了,也可能字段本身已压缩。
3.3 行外存储的chunk分片机制
当字段被决定行外存储后,PostgreSQL 会把大字段切成一片片的小块,写入 TOAST 表。TOAST 表的结构可以用这个查询看到:
SELECT attname, atttypid::regtype FROM pg_attribute WHERE attrelid = 'pg_toast.pg_toast_<主表OID>'::regclass ORDER BY attnum;TOAST 表通常包含三个核心列:
chunk_id:关联回主表行内指针的标识符。chunk_seq:分片的顺序号,0、1、2……用于拼回原始数据时排序。chunk_data:分片的本体内容。
默认情况下,每个分片大约是 2KB,也就是和 TOAST 阈值差不多大。把一个大字段切成多个 2KB 的分片,每条记录都指向同一个chunk_id,查询时按chunk_seq依次读回来,最后重组。
这里有一个值得留意的特点:TOAST 表不是“一个字段对应一行”,而是“一个字段的多片分别占多行”。所以如果某个字段特别大,比如一个 50MB 的bytea,它在 TOAST 表里可能有上万行分片。这类表的行数会非常夸张,整体体积也惊人。
3.4 四种存储策略:PLAIN / EXTENDED / EXTERNAL / MAIN
TOAST 的行为通过存储策略来控制,每种列都可以单独设置。策略一共有四种:
| 策略名 | 压缩 | 行外存储 | 适用场景 |
|---|---|---|---|
| PLAIN | 禁止 | 禁止 | 短数值、定长类型,不参与TOAST |
| EXTENDED | 允许 | 允许 | 默认策略,先压缩再行外 |
| EXTERNAL | 禁止 | 允许 | 数据本身已压缩,绕过CPU压缩开销 |
| MAIN | 允许 | 尽量留在主表 | 优先放主表,压缩后仍超阈值才行外 |
默认情况下,支持TOAST的字段基本都是EXTENDED。但实践中我经常会把一些特殊字段改成EXTERNAL。比如某个字段存的是已经压缩过的数据(JSON 序列化时如果已经整体 gzip 过,或者存储的是图片缩略图),再让 PostgreSQL 压缩一遍就是纯浪费 CPU。设成EXTERNAL后,数据会直接走到行外存储,不做压缩。
修改策略的命令很简单:
ALTER TABLE user_events ALTER COLUMN body SET STORAGE EXTERNAL;3.5 主表只留指针:对查询性能的双刃剑
TOAST 最大的收益是让主表行变小,每一页能放更多行,全表扫描时 IO 次数大幅减少。但代价是访问大字段本体时要额外去 TOAST 表读取,多一次随机IO,读取多个分片还要处理重组。
好在 PostgreSQL 做了一个优化:只在真正需要某个大字段列时才去读TOAST表。如果查询只取id和created_at,完全不会碰 TOAST 表。这就是“宽表但是查询快”的原因。反过来,如果应用层写了个SELECT *,把大字段也带回来,那 TOAST 表的读取就躲不掉了,查询自然慢下来。
我排查慢查询时经常遇到这类情况:业务方反馈“查询很慢”,执行计划一跑,扫描倒是走索引了,但每条记录返回前都要去 TOAST 表把几十KB的字段捞出来解压,事务耗时自然低不了。这种问题的解法通常不是加索引,而是把大字段拆分到独立表,或者改成按需查询。
4. 压缩算法:pglz与LZ4的选择
4.1 默认压缩算法pglz是怎么工作的
PostgreSQL 最早只支持一种内置压缩算法,叫pglz。这个名字大多数人没听说过,因为它不是独立的第三方库,而是 PostgreSQL 内部实现的一套基于 LZ 家族的压缩方案,专门为“短数据块、低延迟”场景优化。
pglz 的压缩率在同级别算法里不算顶尖,但它的设计目标是**“尽量快”**。TOAST 场景有一个特点:数据往往是分散的小分片,每个分片只有 2KB 左右,压缩和解压的单元很小。如果算法本身太重,解压时间甚至可能超过读取时间,那就得不偿失了。pglz 在这个场景下表现均衡,一直沿用到今天。
4.2 LZ4进场:从PG14开始的选项
从 PostgreSQL 14 开始,官方把 LZ4 引入为可选的 TOAST 压缩算法。LZ4 在压缩率上通常略低于 pglz,但压缩和解压速度有压倒性优势,尤其适合高并发、大字段读取极其频繁的业务。选择算法不是改一个全局参数那么简单,因为pglz和lz4并存时,必须保证数据可互操作——也就是旧数据不管用什么算法存的,查询时都能正确解压。PostgreSQL 在存储分片时会额外记录压缩算法标识,解压时根据标识选择对应算法。
默认压缩算法由default_toast_compression参数控制:
SHOW default_toast_compression;默认是pglz。你可以在建表时为特定字段指定lz4:
CREATE TABLE event_snapshot ( id bigint PRIMARY KEY, body text COMPRESSION lz4 );也可以建表后再改:
ALTER TABLE event_snapshot ALTER COLUMN body SET COMPRESSION lz4;这里要特别提醒一句:这个修改只影响后续新写入的数据,已存在的行仍然沿用旧压缩算法。如果想让全表数据都切到新算法,得重写表或逐步迁移,不能指望一条 DDL 就完成全量转换。
4.3 怎么确认数据实际用了哪种压缩
想知道一个字段实际压缩成什么样,两个函数就够了:
SELECT pg_column_size(body) AS raw_compressed_size, pg_column_compression(body) AS used_algorithm FROM event_snapshot WHERE id = 123;pg_column_compression返回pglz或lz4,如果返回空(NULL),说明这条记录没有应用压缩。什么情况下会NULL?比如字段策略是EXTERNAL,或者数据在写入时确定不需要压缩。
我遇到过一种情况:字段设了lz4,但查了很多行,pg_column_compression全是 NULL。原因就是数据量太小,不超过 TOAST 阈值,根本没有触发 TOAST。字段设了压缩算法,不表示每条记录都压缩;只有真正触发 TOAST 流程的才有机会分析压缩率。
4.4 pglz和lz4怎么选:几个实测方向
如果一定要给个选择思路,我可以分享下我的做法:
- 字段很大且读取频繁,比如每次都要捞 JSON 回应用户,优先考虑
lz4,读延迟会直观下降。 - 字段偏文本且重复度高,压缩率更重要,就继续用
pglz,它对文本类数据压得更狠。 - 字段内容本身已经是压缩格式(gzip 过的文件、图片),无论哪种算法都压不动,直接设
EXTERNAL,别让数据库白费 CPU。
实际压测时,可以同时建两张结构一样的表,一个用 pglz、一个用 lz4,插入相同的数据,比较pg_column_size和查询耗时。数据库内部原理是一回事,真实环境的数据特征往往差异很大,实测最可靠。
5. 存储策略实操:什么时候该换EXTERNAL或MAIN
5.1 EXTERNAL:给“已经压缩过的数据”松绑
前面提到,EXTERNAL是最容易被忽略但实用价值很高的策略。它禁止压缩,但允许行外存储。适合两种典型场景:
- 数据量很大且结构性很强,比如 JSONB 文档、log 原文,虽然可以压缩,但你的查询需要频繁读取其中一段,压缩反而增加每次读取的解压开销。
- 字段压缩收益极小,比如加密数据、随机串、图片音频二进制。
举个例子,某个表存了加密后的用户敏感字段,长度非常稳定,比如固定 4KB,而且内容随机分布。用EXTENDED策略时,PostgreSQL 每写一条就要尝试压缩,但压缩率接近1:1,纯纯的 CPU 浪费。改成EXTERNAL之后,数据直接行外存储,省掉压缩环节,写性能提升明显。
ALTER TABLE user_secrets ALTER COLUMN encrypted_blob SET STORAGE EXTERNAL;5.2 MAIN:尽量留在主表的取舍
MAIN策略的语义是:优先把数据留在主表,只有当压缩后仍然超过阈值时才行外存储。听起来很“友好”,但要注意:MAIN 是允许压缩的,只是不允许“先存出去”。在某些访问模式下一个坑很容易踩:
假设某字段设为MAIN,而且数据能压到阈值以内,那么它确实一直留在主表里。但如果这个字段体积较大、压缩率又不高,每次行外存储的判定都会比对阈值,边缘情况下可能产生大量主表行,块利用率下降,后续更新还会引发行迁移。
所以我的建议是:非必要别优先用 MAIN。默认EXTENDED已经权衡得很好,你只需要在“是否能压缩”这个维度上做微调,也就是换成EXTERNAL或保持EXTENDED,绝大多数场景就够了。
5.3 如何验证你的策略切换生效了
改完存储策略,最直观的验证就是看主表和 TOAST 表体积变化。插入一批相同的数据,分别统计:
SELECT pg_relation_size('user_events') AS main_size, pg_total_relation_size('user_events') AS total_size;pg_total_relation_size包含主表、TOAST 表和索引。对比main_size和total_size的差值,就能看出 TOAST 表占了多少。策略切换后,同一批数据产生的total_size可能有明显不同,这正是你要关注的变化。
另一个实用操作是直接查看 TOAST 表里的分片信息:
SELECT chunk_id, count(*) AS chunk_count, sum(octet_length(chunk_data)) AS data_total_bytes FROM pg_toast.pg_toast_<主表OID> GROUP BY chunk_id ORDER BY chunk_count DESC LIMIT 5;如果某些chunk_id对应的分片特别多,说明有一个超大字段正在占用大量 TOAST 空间;如果整体分片行数不多,说明大部分数据在压缩后已经留在主表里了。
6. 实操记录:一张定义混乱的表如何完成TOAST瘦身
6.1 一张“问题表”的体检过程
我遇到过一个真实案例,这张表叫order_snapshot,表结构大概如下:
CREATE TABLE order_snapshot ( id bigint PRIMARY KEY, order_id bigint, status smallint, payload text );业务方反馈两个问题:一是表体积无限膨胀,两周就翻一倍;二是包含payload的查询耗时严重不稳定。
我做了三个步骤:
第一,查整体体积构成:
SELECT pg_relation_size('order_snapshot') AS main_bytes, pg_total_relation_size('order_snapshot') AS total_bytes;结果主表只有 1.2GB,total_bytes却到了 9.8GB。也就是说,接近 90% 的空间全在 TOAST 表里。
第二,看payload字段的实际大小分布:
SELECT count(*) AS total_rows, count(*) FILTER (WHERE pg_column_size(payload) > 1960) AS toast_rows, avg(pg_column_size(payload))::bigint AS avg_payload_size FROM order_snapshot;发现 95% 的行payload都在几KB到几十KB之间,平均 8KB 左右。这意味着几乎每行都触发了 TOAST 流程。
第三,看压缩情况:
SELECT pg_column_compression(payload) AS algo, count(*) FROM order_snapshot GROUP BY 1;结果很有意思:大部分行的算法是pglz,但还有一部分行算法为 NULL,说明这些行写入时因为数据不足阈值或策略问题没有压缩。再查一下pg_attribute的attstorage字段,才知道payload的存储策略是默认的EXTENDED,但实际压出来的体积仍然很大。
6.2 问题根因:频繁UPDATE导致TOAST表膨胀
进一步深挖后,发现问题核心不在存储策略,而在业务模式。这张表的payload会随着订单状态变化被反复更新,每次更新都会生成新版本的整行数据。TOAST 表里的旧分片并不会立即被清理,而是要等 VACUUM 回收。如果更新频率远超 VACUUM 频率,TOAST 表就疯狂膨胀。
解决办法分两步:
第一步,把payload从EXTENDED改成EXTERNAL。因为订单快照里的 JSON 内容通常是序列化后的结果,内部已经有大量重复字段,压缩率不高,但每次解压还带来额外 CPU。改成EXTERNAL后,至少省掉了压缩环节的 CPU 开销。
第二步,调快 VACUUM。对这张频繁更新的表,我把autovacuum_vacuum_scale_factor调小,同时单独设置表的fillfactor降低到 60,减少更新时的行迁移概率。修改后,TOAST 表体积的增长趋势明显放缓。
这里有朋友可能会问:直接VACUUM FULL不行吗?VACUUM FULL会把整张表和 TOAST 表重建一次,确实能把体积压下来,但代价是长时间锁表,在线业务根本接受不了。所以线上大表优先靠pg_repack或逐步迁移,而不是VACUUM FULL。
6.3 验证结果:主表与TOAST表的体积变化
优化后一周,我再查体积构成:
- 主表 1.2GB 仍然是 1.2GB,因为
payload走行外存储后主表行大小稳定在阈值以内。 - TOAST 表的体积增速从“每天约 700MB”降到“每天约 150MB”。
- 查询耗时从原来的平均 800ms 降到 300ms 左右,CPU 使用率也下降了不少。
这个案例让我印象特别深,因为它说明了一个问题:TOAST 不是单纯“省空间”的机制,更像是一套存储策略的平衡器。不同业务模式下,它的表现差异很大,必须结合具体的写入模式、压缩收益、读取频率来做调整。
7. 监控TOAST:从系统表读到有用信息
7.1 核心系统视图与字段速查
如果你想系统性地监控 TOAST,下面几个查询可以长期保留。
查询每张表的TOAST大小:
SELECT n.nspname AS schema_name, c.relname AS table_name, pg_relation_size(c.reltoastrelid) AS toast_size_bytes, pg_total_relation_size(c.oid) AS total_size_bytes FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.reltoastrelid <> 0 ORDER BY toast_size_bytes DESC LIMIT 20;查询某列的实际存储策略:
SELECT a.attname, a.attstorage, CASE a.attstorage WHEN 'p' THEN 'PLAIN' WHEN 'e' THEN 'EXTENDED' WHEN 'x' THEN 'EXTERNAL' WHEN 'm' THEN 'MAIN' END AS storage_strategy FROM pg_attribute a WHERE a.attrelid = 'order_snapshot'::regclass AND a.attnum > 0 AND NOT a.attisdropped;attstorage字段是理解列存储策略的直接入口,p/e/x/m分别对应四种策略。
7.2 观察VACUUM与TOAST表回收
TOAST 表的大小和 VACUUM 行为强相关。如果发现 TOAST 表一直很大,可以用:
SELECT relname, n_live_tup, n_dead_tup, last_vacuum, last_autovacuum FROM pg_stat_user_tables WHERE relname = 'order_snapshot';n_dead_tup如果长期居高不下,说明 VACUUM 没能及时回收旧版本,TOAST 表膨胀只是连锁反应之一。这时候要先解决 VACUUM 效率问题,再谈 TOAST 优化。
7.3 什么时候该考虑“把大字段移出主表”
TOAST 虽然能处理大字段,但不代表你可以无限制地在表里堆大字段。如果一个字段非常大且高频读取,它始终会对查询造成影响。我常用的一个判断标准是:如果单个字段平均大小超过主表一行其他字段总和的几十倍,且它被查询的频率又很高,就该考虑拆表。
拆表有两种典型方案:
- 垂直拆分:把大字段单独放到一张附属表,通过主键关联。查询时按需 JOIN。
- 分区或归档:把旧数据按时间迁移到冷表,减少在线表体积。
TOAST 是底层机制,拆表是业务层设计,两者互相配合,才能在大字段场景下把性能做到最好。
8. 避坑指南与实战经验总结
8.1 踩过的大坑:EXTERNAL设了但不生效
有次我在某张表上设置了EXTERNAL,但过了一段时间发现数据仍然被压缩了。排查下来发现原因很直接:那张表当时还没有任何数据,我先做了ALTER TABLE改策略,后来导数据的时候用的是INSERT INTO ... SELECT,而源表里的一些列带有显式的压缩属性,导入时把压缩属性也带过去了,覆盖了目标列的EXTERNAL设置。
所以给列设置压缩策略时,要确认你的数据导入链路有没有隐式覆盖。最稳妥的做法是导入后再查一次pg_attribute.attstorage和数据实际压缩状态:
SELECT pg_column_compression(payload), count(*) FROM order_snapshot GROUP BY 1;如果还有大量非 NULL 结果,说明策略没生效,需要重新处理。
8.2 关于压缩率的两个常见误解
误解一:压缩率高就一定好。实际上,压缩率提升意味着读取时解压开销更高,对于高频小范围读取的场景,宁可压缩率低一点,也要解压更快。
误解二:所有text字段都适合压缩。如果内容本身是已加密的、已压缩的或者随机生成的,压缩只会消耗 CPU,不产生任何收益。这类字段正确策略是EXTERNAL。
8.3 块大小不是你想调就能调
BLCKSZ在编译时确定,线上环境想改块大小,基本等于要重新编译一套 PostgreSQL。默认8KB是绝大多数场景下的最优选择,别为了“大字段能存更多”去单独调大块大小,收益有限,反而可能影响缓存命中率、TOAST 阈值、页内行数等多个指标。我没有见过哪个生产环境因为块大小调大而获得质的提升,倒是见过不少因为编译参数改完忘了维护成本增加而头疼的团队。
8.4 更新频繁的大字段:避免行迁移与TOAST碎片
如果一个大字段所在的表更新非常频繁,可以考虑降低fillfactor。fillfactor表示数据页的填充率,默认是100%,也就是新行尽可能把页面填满。但更新一行数据时,如果页内空间不足,PostgreSQL 会把新版本放到其他页,导致行迁移,间接增加 TOAST 访问和碎片。把fillfactor调低,比如 70 或 80,可以留出足够空间给 HOT 更新(Heap-Only Tuple),减少行迁移。
ALTER TABLE order_snapshot SET (fillfactor = 70);这个操作不用重写数据,只影响后续插入和更新行为,算是低成本高收益的调优手段。
8.5 查询时如何避免“误触”TOAST解压
记住一个原则:不要轻易 SELECT *。SELECT *会把所有大字段列一并带回,哪怕你只需要其中一个小字段。只要返回结果包含 TOAST 列,数据库就得去 TOAST 表读取分片并解压。
我发现很多慢查询问题都出在代码里随手写的SELECT *上。把字段列表写明确,是避免 TOAST 性能问题最简单、也最容易被忽略的手段。
8.6 备份和迁移时TOAST表带来的额外开销
TOAST 表在备份和逻辑导出时都算在内。pg_dump导出大表时,TOAST 表内容会占据极大比例。如果一次性导出一张包含巨量 TOAST 数据的表,可能几十分钟都跑不完。遇到这种情况,可以考虑分批导出,或者用COPY按条件分段处理。
物理备份(比如pg_basebackup)则没有这个问题,因为它直接复制整个数据目录,TOAST 表和主表一视同仁。差别在于物理备份期间,如果写入频繁,TOAST 表大量膨胀,备份出来的体积也会异常大。所以备份前观察 TOAST 表大小变化,能帮你判断是不是该做一次清理了。
9. 写在最后:理解TOAST不是终点
我个人在实际排查和调优中体会到,TOAST 机制的价值不只是让你知道“大字段被压缩了”,更重要的是让你建立起一套判断存储问题的框架:看到主表很小、总表很大,先想 TOAST;看到大字段查询慢,先想是不是每次都在解压;看到表体积持续增长,先想更新模式和 VACUUM 回收节奏。这些判断链条一旦建立起来,很多数据库怪现象就不再神秘了。
最后再分享一个小技巧:每次调整完存储策略或压缩算法,记得留一份前后对比记录,至少包含主表大小、TOAST 表大小、平均查询耗时、CPU 使用率这几项指标。数据库调优效果好不好,不能靠感觉,得靠这些数字说话。我见过太多“感觉变快了”但一查监控指标几乎没有变化的案例,指标才是唯一的裁判。