![]()
一、一张附件表,两个对不上的数字
一个 165 GB 的 PostgreSQL 数据库,最大的一张表吃掉 156 GB。运维的第一反应通常是:这张表里堆了 156 GB 的附件。
作者 Amit Kumar 起初也是这么想的,然后他去量了一下——活数据只有 11 GB。5703 条记录,156 GB 的磁盘占用,中间空着 145 GB。
更值得琢磨的是这张表的构成:表本身的堆只有 31 MB,索引 13 MB。两个加起来 44 MB,占 156 GB 的万分之三不到。也就是说,这 156 GB 里几乎一个字节都不在看得见的地方,全在一张平时根本不会去翻的表里——TOAST。
这篇文章把原文的排查过程完整复现了一遍,跑在本机的 PostgreSQL 18.6 上。原结论站得住,但有三处它没说透的地方,实测下来比结论本身更有用。
二、六步排查,每一步到底在量什么
先把原文的路径走一遍,同时说清每一步的数字是什么口径。
第一步,量整个库。pg_database_size() 给出 165 GB。
第二步,找最大的表。从 pg_statio_user_tables 里按 pg_total_relation_size() 倒序取前 20,附件表毫无悬念地排第一。
第三步,把这张表拆开。原文在这里用了三个体积函数:
SELECT pg_size_pretty(pg_total_relation_size('schema.table_name')) AS total_size, pg_size_pretty(pg_relation_size('schema.table_name')) AS heap_size, pg_size_pretty(pg_indexes_size('schema.table_name')) AS index_size;结果是 total 156 GB、heap 31 MB、index 13 MB。
这里补一句官方函数定义,很多人卡在这一步:pg_total_relation_size 算的是"包含全部索引和 TOAST 数据"的磁盘占用,pg_relation_size 只算主数据分支。所以这两个数字之间的差额就是 TOAST 表——156 GB 减去 44 MB,缺口全在 TOAST 里。
原文是到第六步用 reltoastrelid 关联 pg_class 才坐实这一点的。其实第三步就能看出来,写法还可以更直接:
SELECT c.relname, pg_size_pretty(pg_relation_size(c.oid)) AS heap, pg_size_pretty(pg_relation_size(c.reltoastrelid)) AS toast, pg_size_pretty(pg_indexes_size(c.oid)) AS indexFROM pg_class c WHERE c.relname = 'your_table';一行拆成三块,不用猜。
第四步,量活数据。原文把所有行的大小加了起来:
SELECT count(*) AS rows, pg_size_pretty(sum(pg_column_size(t.*))::bigint) AS live_sizeFROM schema.table_name t;得到 5703 行、11 GB。156 除以 11,约等于 14 倍。
这一步的 pg_column_size 是最容易看错的地方。官方对它的定义是"存储单个数据值所用的字节数",后面还跟了一句关键的话:直接作用在表的列上时,这个结果会反映所做的压缩。
也就是说,它给的是落盘后的大小,不是这段数据的原始大小。为了确认这一点,本机跑了一组对照:三类附件、每类 40 行,原始体积在 40 MB 到 115 MB 之间。
载荷类型
逻辑体积
pg_column_size 合计
TOAST 实际落盘
随机二进制 PDF/JPG/ZIP
40 MB
40 MB
41 MB
随机十六进制文本 base64/hash 类
40 MB
40 MB
41 MB
重复中文正文 XML/JSON 类
115 MB
1349 kB
1440 kB
前两类压不动,三个数字几乎一样。第三类逻辑上 115 MB,落到盘上只剩 1349 kB,压掉了 98.8%。
这组数字有两个用处。其一,原文那个 11 GB 是已经压过的落盘数字,所以 156 除以 11 这个比值成立——它比的是"盘上占了多少"和"这些数据实际需要多少盘",口径是对得上的。其二,反过来提醒:如果拿 octet_length() 求出原始字节数再算倍数,会得到一个虚高得多、却毫无操作意义的数字。
再看第三行:同样叫"附件",文本型内容和二进制内容落盘能差几十倍。所以"这张表有 N GB 附件"这句话,本身不告诉你任何事。
第五步,算可回收空间。156 减 11 等于 145 GB。
第六步,定位到 TOAST。用 reltoastrelid 关联 pg_class,确认绝大部分占用都在 TOAST 表里。
根因链条原文写得清楚:附件被归档或删除,行被标记为已删除,autovacuum 清理掉死元组,空间变成可复用,但物理文件仍然留在那里。
按原文的每行 1.93 MB 折算,145 GB 大约相当于 7.5 万条已经被删掉的附件。
三、三件原文没说透的事 第一件:普通 VACUUM 有时候自己就把文件缩了
原文直接跳到了 VACUUM FULL。但官方文档里有一句常被跳过的话:标准 VACUUM 不会把空间还给操作系统,"除非表中有一个或多个位于末尾的页完全空闲,并且能顺利获得排他表锁"。
这句话的意思是,文件会不会缩,取决于被删掉的数据落在文件的哪个位置。本机做了个 A/B。
场景 A,先灌 300 行、每行 1 MiB 不可压缩数据,TOAST 表 308 MB,然后删掉最后 260 行,先不 VACUUM 量一次:
对象
文件大小
活元组
死元组
死元组字节
主表
24 kB
40
260
15,541
TOAST 表
308 MB
21,040
136,760
277,553,120
然后只跑一次普通 VACUUM,耗时 210 毫秒:主表从 24 kB 降到 8192 字节,TOAST 表从 308 MB 降到 41 MB。文件缩掉了 267 MB,没有锁表,没有停机。
场景 B,同样 300 行,删掉前 260 行,再插入 260 行新数据把文件尾部占住,然后 VACUUM:
阶段
主表
TOAST
合计
穿插删改后 VACUUM
40 kB
575 MB
582 MB
死元组清零了,文件一个字节没缩。按原文那套算法算:582 除以 300,1.94 倍。
两个场景唯一的区别,就是被删掉的数据在不在文件末尾。所以排查顺序应该是:先跑一次普通 VACUUM,再量一次文件大小,不行才考虑重写全表。前者成本是几百毫秒,后者要停业务。
第二件:官方其实不建议随便上 VACUUM FULL
原文把 VACUUM FULL 列为首选方案,并提醒要安排维护窗口、准备足够空闲空间、注意排他锁。这些提醒都对,但官方文档的措辞比"提醒"重得多:
• 管理员应当尽量使用标准 VACUUM,避免 VACUUM FULL。
• 如果这张表将来还会重新长大,做这件事没什么意义。
• 它要写一份新副本,旧副本在新副本完成前不能释放,所以临时需要大约等于表大小的额外磁盘空间。
第二条尤其值得抄下来贴在案头。空间收回来之后,只要附件继续往里灌,那些空间迟早会再被吃掉——你付出的是一次停机加一份额外磁盘,换来的是一段时间的账面好看。
实测代价。拿场景 B 那张 582 MB 的表(其中 267 MB 是空闲空间)跑一次 VACUUM FULL:
项目
实测结果
耗时
5.88 秒
表体积
582 MB 降到 312 MB
期间读这张表
2.5 秒超时被取消
期间读同库另一张表
正常返回
数据目录峰值增量
约 325 MB
"读这张表被取消"不是形容:VACUUM FULL 拿的是 ACCESS EXCLUSIVE 锁,连 SELECT 都要排队。它不是"有点慢",是这张表在整个过程中对外不可用。同一时刻同库另一张表照常可读,被挡住的只有这一张。
第三件:监控里根本看不到这些死空间
这一条是复现时意外发现的,也是最值得补进原文的一处。
原文的排查路径是"先看表大,再看活数据小"。但运维天天会看的那个视图,给的是完全不同的画面。造一张 60 行、每行 1 MiB 的表,删掉 48 行,然后在同一时刻看四个地方:
数据来源
看到的东西
pg_stat_user_tables 主表
死元组 48 条
pg_stat_all_tables TOAST 表
死元组 25,248 条
pgstattuple 主表
死元组字节 2,832
pgstattuple TOAST 表
死元组字节 51,240,576
主表 48 条死元组、2,832 字节,放在任何一份监控大盘上都是"健康"。而同一时刻,TOAST 表里躺着 25,248 条死块、48.9 MB——这张表总共才占 62 MB,死空间占了 79%。
原因很直白:一条带附件的记录被删掉,主表只少一行;而 1 MiB 的附件在 TOAST 表里是 526 个分块,每个分块都是一行独立记录。删 48 条业务记录,等于在 TOAST 表里干掉两万五千多行。按这个比例,原文那些 1.93 MB 的附件,一块就是一千多个分块,7.5 万条折过去是七千多万行死块。
所以,所有基于主表 n_dead_tup 的膨胀监控,对附件表天然是失明的。
真正能一眼看穿的,是 pgstattuple 扩展,它可以作用在 TOAST 表上:
SELECT table_len, tuple_count, dead_tuple_count, dead_tuple_len, free_spaceFROM pgstattuple((SELECT reltoastrelid FROM pg_class WHERE relname='your_table'));还有一个前提问题:这 145 GB 是浪费还是预留官方对这件事的表述是"稳态使用":每张表占的空间等于它的最小尺寸,加上两次 vacuum 之间用掉的部分。VACUUM 标为空闲的空间可以被复用,不是永久损失。
实测验证一下:场景 B 里 TOAST 表有 267 MB 空闲空间,再往里灌 200 行新附件,TOAST 表占用从 575 MB 到 575 MB——一个字节都没涨,新数据把这 267 MB 吃回去了。
所以"145 GB 可回收"这句话要分两种情况看。这张表还会继续长,那这些空间本来就会被用回去,回收的价值主要在磁盘账面;这张表已经不再增长,那 145 GB 才真的是一笔可以从采购清单上划掉的钱。
四、下次遇到,按这个顺序走
1. 先拆体积。用 pg_class 一行拆成主表、TOAST、索引三块,别猜。
2. 用 pgstattuple 同时量主表和 TOAST 表的死元组与空闲空间。这一步直接告诉你"有多少能收",比全表扫一遍求和轻得多。
3. 先跑一次普通 VACUUM,再量文件。文件尾部的空闲页会被自动截断,有时候问题自己就没了,成本是几百毫秒。
4. 普通 VACUUM 无效,才考虑重写全表。要准备三样东西:排他锁带来的停写停读、大约等于表大小的额外磁盘、以及"这张表以后还会不会长"这个问题的答案。
5. 如果这张表是按周期整体清空的,直接用 TRUNCATE,它立即释放空间,不需要后续 VACUUM。这是官方专门给出的提示。
6. 长期看要问的是另一个问题:为什么每个 1.93 MB 的附件,要住在一张关系表里。
PostgreSQL 本身开源免费,采用 PostgreSQL License,当前版本 18.6,支持到 2030 年 11 月,主仓库 github.com/postgres/postgres 有 22,282 个 star。
五、互动
一句话总结:表大不等于数据多,数据多不等于盘上占得多,盘上占得多也不等于这笔空间丢了。
你手上的库有没有那种"常年几百 GB、业务量却没怎么变"的表?说说表名后面跟着的数字,下次挑几个典型的拆一拆。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.