接到磁盘告警、df -h一看使用率飙到90%的时候,我相信大多数人第一反应不是慌,而是想搞清楚一件事:到底是哪张表把空间吃掉了?PostgreSQL统计表占用空间,听起来不就是一条SQL的事吗,但真到排查的时候,很多人却卡在不知道该信哪个函数、为什么数字对不上、VACUUM之后空间也没降下来。这篇文章我就把PostgreSQL统计表占用空间的完整方法、底层原理和实战踩坑一次性讲透,适合DBA、后端开发和运维同学直接照着用。
1. 为什么需要统计表占用空间:从一次磁盘告警说起
1.1 告警背后的真实痛点
我经历过一次印象很深的故障。那天下午某业务库的磁盘告警连续刷了五条,登录服务器一看,数据目录所在分区已经用了92%。当时第一反应是用pg_database_size()看一眼数据库整体多大,结果显示主库200多G,确实不小,但库里躺着几百张业务表,总不能一张张手工试下去,光是构造查询语句就够折腾半天。
那个时候才意识到,PostgreSQL统计表占用空间这件事,绝不只是“查一个数字”那么简单。它背后承载的是容量规划、大表定位、表膨胀治理、清理效果验证这一整套日常运维动作。如果你要回答“磁盘快满了怎么办”这个问题,第一步必须是快速、准确地列出所有表的空间占用排行,找到真正的大头,而不是靠猜。
另外,PostgreSQL和MySQL在空间统计上的思路完全不一样。MySQL的information_schema.tables里直接有data_length和index_length字段,看起来很直观;PostgreSQL把这些信息打散到了系统目录、系统视图和专用的统计函数里,需要组合使用。这也是很多从MySQL转过来的同学一开始觉得别扭的原因。
1.2 统计表空间本质要回答的四个问题
我在实际工作中,会把空间统计的需求分成四类,每一类对应不同的操作重点:
- 容量趋势判断:表未来三个月会不会涨到磁盘扛不住?这需要连续观测表大小变化,而不是只看当前值。
- 大表识别与定位:哪几张表占了全库80%以上的空间?这需要全库范围排序。
- 膨胀检测:表里实际有效数据只有50G,但文件躺在磁盘上占了120G,中间的差距哪来的?这需要结合死元组比例做膨胀分析。
- 清理效果验证:执行了
VACUUM FULL或pg_repack之后,空间到底降了多少?这需要清理前后的数值对比。
工具层面,PostgreSQL其实把“弹药”都备齐了,关键是你得知道怎么组合:pg_relation_size负责单段文件大小,pg_indexes_size负责索引占用,pg_total_relation_size负责表相关全部空间总和,pg_size_pretty负责把字节换成人类可读的单位。我后面会逐个拆。
1.3 先把存储模型讲透:8KB数据块、索引独立文件、TOAST
在写任何统计SQL之前,建议你先理解一张PostgreSQL表在磁盘上到底放了哪些东西。表层逻辑上是一张表,物理上却是一组文件。
默认情况下,PostgreSQL把表数据切分成固定大小的数据块,也叫页面(page),每个块默认8KB。表有多少个块,基本决定了表文件有多大。系统目录pg_class里有一个relpages字段,记录的就是表的块数,虽然这个值是估算值,但乘以8KB就能得到一个粗略的数据大小,适合快速预估。
同时,索引不是像某些数据库那样“附在表内部”,而是独立的物理文件。一个表上如果有三个索引,那就有三个独立的索引文件。索引文件同样由8KB的块组成。
还有一个容易被忽略的东西叫TOAST(The Oversized Attribute Storage Technique),专门用来处理超长字段。比如一个text字段塞了几MB的文档,PostgreSQL不会把整条数据硬塞进一个8KB的块里,而是把大字段拆出来存到独立的TOAST表中,原表里只留一个指针。这意味着,你统计表大小时如果漏掉TOAST,结果可能差出好几倍。
理解了这三块——主表数据段、索引段、TOAST段,你才算真正具备了看懂统计结果的基础。接下来我按工具到实战的顺序,把每一步都展开讲。
2. 核心工具:四个空间统计函数与格式化函数
2.1 一个函数一张表,先分清职责
PostgreSQL内置的空间统计函数有好几个,刚接触的人容易混淆,我把最常用的整理成了下面的对照表:
| 函数 | 作用范围 | 返回单位 | 典型场景 |
|---|---|---|---|
pg_relation_size(relation) | 只统计主表数据段文件 | 字节 | 想单独看表本身的数据占多少 |
pg_indexes_size(relation) | 统计该表所有索引的合计大小 | 字节 | 评估索引开销,定位索引膨胀 |
pg_total_relation_size(relation) | 主表 + 索引 + TOAST表 + 可见性映射 + 空闲空间映射 | 字节 | 判断一张表在磁盘上真正占了多少,最常用 |
pg_database_size(database) | 整个数据库的物理大小 | 字节 | 看一个库总体多大 |
pg_tablespace_size(tablespace) | 整个表空间的物理大小 | 字节 | 跨库查看某个表空间用量 |
注意单位,这些函数默认返回的都是字节,一个整数。如果你直接查,会看到一串很长的数字,比如123456789,光看着都费劲,更别说横向比较。所以实际使用中必须搭配pg_size_pretty()做格式化,稍后会讲。
这里特别强调一点:上面函数里的relation参数,既支持表的OID(对象标识符),也支持用文本形式的表名。比如pg_total_relation_size('users')和pg_total_relation_size('users'::regclass)效果一样。但我更推荐后一种写法,因为它能利用regclass类型转换自动完成表名到OID的解析,还带上schema限定,避免不同模式下同名表带来的混淆。
2.2pg_total_relation_size为什么是首选,以及一个细节陷阱
如果只能记住一个函数,我建议你记住pg_total_relation_size。它统计的是一个表的“全家桶”:
- 主表数据段文件
- 全部索引文件
- 对应的TOAST表文件及其索引
- 可见性映射(VM)文件
- 空闲空间映射(FSM)文件
换句话说,pg_total_relation_size给出的就是这张表在磁盘上占用的真实物理空间总和。你在做容量评估、大表排行、收费核算(比如按库容计费)的时候,都应该以它为准。
但它有一个细节陷阱:它包含索引大小。有时候你只想看“表里的数据行占了多少空间”,尤其在做数据清理前后对比时,如果清理的是数据行,索引并不会同步缩小,这时候用pg_total_relation_size会得到一个“清理没什么效果”的假象。正确做法是单独看pg_relation_size的变化,同时用pg_indexes_size观察索引情况。
还有一点,pg_total_relation_size不会区分表里有多少是有效数据、多少是死元组。它统计的是物理文件大小,也就是说即使你删掉了一大批行,只要这些空间还没有被复用或回收,文件大小基本不会变。理解这个逻辑很重要,否则你会被“删了几百G数据,文件还是那么大”的现象吓到。
2.3 别让可读性拖后腿:pg_size_pretty与pg_size_bytes
查出来的原始字节数可读性太差,我习惯的写法是把统计函数套进pg_size_pretty()里:
SELECT pg_size_pretty(pg_total_relation_size('users'::regclass)) AS total_size;输出结果会变成127 MB或者2.5 GB这样的格式,一眼就能看懂。
有时候正好反过来,你想把一个人类可读的大小字符串转回字节做计算,比如监控脚本里拿到512 MB要参与比较,可以用pg_size_bytes():
SELECT pg_size_bytes('512 MB');这个函数在解析带单位的字符串时非常实用,但要注意它只接受合法的格式,比如512 MB、1 GB、1024 kB,写成512MB(缺空格)或者小写mb就会报错。我建议在采集脚本里统一用大写单位加空格,减少解析问题。
顺着这个思路,一个比较好的组合查询是把多个函数放在一起,格式化之前先保留原始字节值,这样既能排序又能展示:
SELECT c.relname AS table_name, pg_total_relation_size(c.oid) AS total_bytes, pg_size_pretty(pg_total_relation_size(c.oid)) AS total_pretty FROM pg_class c WHERE c.relkind = 'r' ORDER BY pg_total_relation_size(c.oid) DESC;核心思路是:排序用原始字节数,展示用格式化后的字符串。这套写法你接下来会反复用到。
3. 单表空间构成拆解:主表、索引、TOAST三件套
3.1 主表数据段:你以为是全部,其实只是开始
主表数据段是表文件里最核心的部分,存的是每一行数据的堆(heap)。它的物理大小主要取决于三个因素:行数、平均行宽、填充因子。
我要重点说一下填充因子。PostgreSQL默认的fillfactor是100,意味着每个8KB数据块尽量塞满。但如果你把填充因子调成70、80,每个块会预留一部分空闲空间,目的是让后续UPDATE能直接在当前块内找到位置,减少页分裂和行迁移。代价就是表文件会更大。
所以你在统计表大小时,如果发现某张表的总大小明显超过“行数×平均行宽”的理论值,不要第一时间怀疑数据有问题,先看看reloptions里是否有自定义的填充因子。记录一个我遇到过的案例:某一张业务配置表只有3万行,每行几十字节,理论大小应该很小,实际却占了500MB以上。排查很久才发现是历史遗留的批量UPDATE导致死元组堆积,加上填充因子被改成50,双重因素叠加,才把空间撑了起来。
在pg_class.relpages字段里可以看到估算的块数,relpages * 8192可以快速估算主表大小。注意它是不含索引和TOAST的,而且可能不够精确,但它有一个优势:查询代价极低,适合在几十万张表上做大范围粗筛。
3.2 索引段:一个能占半壁江山的隐藏消耗
很多刚接触PostgreSQL的同学,统计表空间时会惊讶地发现,索引怎么这么大?这很正常。索引文件的大小取决于索引字段类型、索引数量、数据分布和填充情况,和表大小并不严格成正比。
比如一张100GB的业务大表,上面挂了五六个B-tree索引,每个索引可能都有10-20GB,加起来接近甚至超过表本身的大小。这种场景下,pg_indexes_size统计出来的结果,往往会让你重新审视自己的索引设计——是不是有很多冗余索引?是不是有长期不用的索引在白白消耗磁盘?
我见过一个真实的教训:某张订单表上有一个几乎不会被查询使用到的状态字段索引,单独占了8GB。因为所有业务查询都会加时间条件,状态索引压根用不上。后来用pg_indexes_size逐个索引核对后,把这个索引删掉,直接省出8GB磁盘空间。所以日常巡检时,建议对每张表跑一下索引大小明细,把每个索引的大小单独列出来,而不是只看一个总和。
3.3 TOAST段:大字段仓库,很多人统计时漏掉的“隐形空间”
TOAST表的工作原理值得多说两句。当一行数据的总宽度超过约2KB时(具体阈值和字段类型有关),PostgreSQL会把最宽的大字段拆出来,压缩后存到TOAST表里。TOAST表本身还有自己的TOAST索引,这些统统算在pg_total_relation_size里。
TOAST表长得比较特别,它的命名格式是pg_toast.pg_toast_<主表oid>,在主表的relnamespace对应的pg_toast模式下面。如果你单独查pg_relation_size(主表),永远看不到这部分空间。
我遇到过一张日志明细表,字段里存了大量JSONB和长文本,主表文件只有十几GB,TOAST表却膨胀到了上百GB。因为日志数据只增不改,旧的大文本一遍遍堆积在TOAST表里,而TOAST表本身不会被普通VACUUM及时收缩。最后只能通过清理历史分区加VACUUM FULL才把空间降下来。
所以,凡是表里有大字段——text、jsonb、bytea、数组这类,统计空间时一定要把TOAST部分纳入视野。用pg_total_relation_size是最稳妥的,因为它自动把TOAST计入在内。
3.4 三件套对照SQL:一条语句看清表的空间收支
如果你想直观看出主表、索引、TOAST分别占了多少,可以用下面这条SQL:
SELECT c.relname AS table_name, pg_size_pretty(pg_relation_size(c.oid)) AS data_size, pg_size_pretty(pg_indexes_size(c.oid)) AS index_size, pg_size_pretty(pg_total_relation_size(c.oid) - pg_relation_size(c.oid) - pg_indexes_size(c.oid)) AS toast_and_aux_size, pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size FROM pg_class c WHERE c.relkind = 'r' AND c.relnamespace = 'public'::regnamespace ORDER BY pg_total_relation_size(c.oid) DESC;注意,toast_and_aux_size那部分是pg_total_relation_size减去主表和索引后剩下的,这里面主要是TOAST表、TOAST索引以及FSM/VM等辅助文件。如果这个值占比很高,说明大字段存储占了很大比重,值得进一步排查。
这条SQL虽然简单,但它是我每次排查空间问题第一个跑的工具。跑完基本就能判断:空间大头是数据本身、索引开销,还是TOAST里的历史大字段,下一步处理方向也就清晰了。
4. 直接抄作业:全库大表统计脚本与结果解读
4.1 TOP 20大表统计脚本,推荐直接落地
下面这条SQL是我在几乎所有PostgreSQL实例上都会用的压箱底脚本,你直接复制到生产库或只读从库上就能跑:
SELECT schemaname AS schema_name, relname AS table_name, pg_size_pretty(pg_total_relation_size(relid)) AS total_size, pg_size_pretty(pg_relation_size(relid)) AS data_size, pg_size_pretty(pg_indexes_size(relid)) AS index_size, pg_size_pretty(pg_total_relation_size(relid) - pg_relation_size(relid) - pg_indexes_size(relid)) AS toast_size, n_live_tup AS estimated_rows FROM pg_stat_user_tables ORDER BY pg_total_relation_size(relid) DESC LIMIT 20;几个关键点解释一下:
- 这里用的是
pg_stat_user_tables而不是pg_class,好处是它自带relid和行数估算字段n_live_tup,一行查询就能拿到表和大小信息,不用再做关联。 relid可以直接传给pg_total_relation_size等函数,避免了表名解析的麻烦。n_live_tup是优化器统计信息里的存活行数估算值,来自最近一次ANALYZE,不代表精确行数,但用于横向对比完全够用。- 如果你想看TOP 50而不是TOP 20,改
LIMIT就行。
实际操作中,我会把这条SQL保存成top_big_tables.sql,在需要巡检时直接执行,几秒钟出结果,效率比手工写各种拼接语句高得多。
4.2 只看当前模式下的表,排除系统干扰
上面脚本用的是pg_stat_user_tables,已经自动排除了系统表,但在一个库里存在多个业务模式时,你可能只想看某个模式下的表。这时候把视图换成pg_stat_all_tables,加上schemaname过滤:
SELECT relname AS table_name, pg_size_pretty(pg_total_relation_size(relid)) AS total_size, pg_size_pretty(pg_indexes_size(relid)) AS index_size, n_live_tup AS estimated_rows FROM pg_stat_all_tables WHERE schemaname = 'your_schema' ORDER BY pg_total_relation_size(relid) DESC LIMIT 20;如果你的库存在多个业务模式,建议每个模式单独跑一遍,方便各业务方认领自己的大表。还有一个实用技巧:把结果导出成CSV或存到一张统计表里,方便后续对比趋势。
4.3 结果怎么看:从六个维度判断是否需要处理
跑出排行榜之后,关键的是解读。我通常关注以下六个维度:
- 总大小绝对值:单张表超过1GB就要引起重视,超过100GB基本属于大表重点观察对象。
- 行数与大小是否匹配:如果一张表只有几百万行,大小却有几个GB,先怀疑行宽是否过大、是否有大字段。
- 索引占比:索引大小除以总大小超过40%时,考虑是否有冗余索引。
- TOAST占比:TOAST部分明显偏大时,检查是否有大字段持续写入且没有清理机制。
- 膨胀风险:有一张表的
total_size排第一,但实际活跃数据只有三分之一,说明死元组堆积严重。 - 清理历史:如果
pg_stat_user_tables里last_vacuum或last_autovacuum时间很近,说明系统最近在持续做垃圾回收;如果last_vacuum是几个月前甚至为NULL,空间问题大概率源自回收不及时。
这六个维度配合起来,基本能定位绝大多数空间异常问题。不要单看一个数字就下结论,尤其是不要看到总大小大就认为数据真的大,膨胀和索引开销会严重干扰判断。
5. 实战经验:三种常见误判与排查技巧实录
5.1 误判一:把information_schema当MySQL用
从MySQL转过来的同学最容易踩这个坑。在MySQL里,information_schema.tables里直接有data_length、index_length,但在PostgreSQL里,information_schema.tables这个视图压根没有这两个字段。
如果你硬要用兼容性写法去查,得到的不是NULL就是错误。PostgreSQL正确的打开方式是回到系统目录pg_class、pg_namespace,配合前面讲的那组统计函数。这背后的设计差异是,MySQL把表空间信息集中存在数据字典里,而PostgreSQL选择通过函数动态计算,各有利弊,但你只能顺应它的设计。
我的建议很简单:凡是涉及空间统计,一律使用pg_total_relation_size这组函数,不要再依赖information_schema。
5.2 误判二:执行完VACUUM,空间一点没变小,以为系统出Bug
这是一个几乎人人都会遇到的困惑。先说结论:普通VACUUM只会把已经删除的行留下的空间标记为可复用,并不会把文件末尾的空闲空间还给操作系统。所以执行VACUUM之后,你用pg_total_relation_size查大小,数字几乎不变,这是正常现象,不是出Bug了。
想把空闲空间真正还给操作系统,只有两条路:
VACUUM FULL:重写整张表,把数据重新紧凑排列,然后释放文件末尾空间。缺点是执行期间会持有表的排他锁,业务写入会被阻塞,大表上耗时常以分钟甚至小时计。pg_repack:在线重建表,不阻塞读写,但需要额外的磁盘空间存放新旧两份数据,流程也更复杂,需要额外安装扩展。
我实际处理过一个案例:某张表有60G死元组,普通VACUUM跑完,文件大小纹丝不动,值班同事一度怀疑VACUUM没生效。后来我确认了表的死元组比例,在维护窗口执行了VACUUM FULL,文件从120G降到了55G,问题才真正解决。
这里要特别提醒:VACUUM FULL需要表大小等量甚至更大的空闲磁盘空间,因为它要在磁盘上重写一份新的表数据。如果磁盘本身已经快满了,这操作很可能直接撑爆磁盘。空间紧张时优先考虑pg_repack或通过清理分区来释放空间,而不是硬着头皮执行VACUUM FULL。
5.3 误判三:分区表统计父表得到的结果没有意义
PostgreSQL的分区表在逻辑上是父表加若干子分区,但物理存储都在子分区上。直接对父表执行pg_total_relation_size,得到的结果要么是0,要么非常小,因为父表本身不存数据。
正确做法是把所有子分区的空间累加起来。手工写SQL当然可以逐张加,但一张父表有几十个分区的时候,最好用递归或动态拼接来自动统计:
SELECT pg_size_pretty(SUM(pg_total_relation_size(child.relid))) AS total_size FROM pg_inherits i JOIN pg_class parent ON i.inhparent = parent.oid JOIN pg_class child ON i.inhrelid = child.oid WHERE parent.relname = 'orders' GROUP BY parent.relname;这段SQL的原理是:pg_inherits记录了父表和子分区的继承关系,通过这个系统目录就能把所有子表收集起来,再做聚合。这样统计出来的才是分区表真正的总大小。
5.4 排查膨胀的官方视图:pg_stat_user_tables
膨胀是PostgreSQL空间管理里最隐蔽的问题之一,它不像文件大小那么直观。判断一张表是否需要紧急处理,我建议养成分三步看的习惯。
第一步,看死元组数量:
SELECT relname, n_live_tup, n_dead_tup, CASE WHEN n_live_tup > 0 THEN round(n_dead_tup * 100.0 / n_live_tup, 2) ELSE 0 END AS dead_ratio FROM pg_stat_user_tables WHERE n_dead_tup > 10000 ORDER BY dead_ratio DESC LIMIT 20;第二步,看上次自动清理时间。如果last_autovacuum是NULL,说明这张表可能从来都没被自动清理过,要么是表太大导致清理超时,要么是自动清理参数被调得太保守。
第三步,结合空间统计函数确认膨胀规模。如果pg_total_relation_size显示20G,而真实数据只有5G,那15G就是膨胀空间,需要评估是否执行VACUUM FULL或pg_repack。
膨胀率高是PostgreSQL空间问题的老大难,它的根源在于MVCC(多版本并发控制)机制——更新、删除操作会产生旧版本数据,这些旧版本不能立即清除,必须等所有可能还在使用它们的事务结束之后,才能被后续VACUUM回收。理解这一点,你就知道定期清理、防止长事务、合理设置autovacuum参数,比事后手工清理重要得多。
6. 进阶:空间趋势采集与监控告警
6.1 定时采集:从临时排查变成日常巡检
如果你只在磁盘告警时才去跑空间统计,那永远是被动挨打。我更推荐把表空间采集做成定时任务,让数据自己会说话。
最简单的方案是建一张历史统计表,比如:
CREATE TABLE table_space_history ( stat_time timestamptz NOT NULL DEFAULT now(), schema_name text, table_name text, total_size bigint, data_size bigint, index_size bigint, toast_size bigint, estimated_rows bigint );然后每天固定时间插入一次全库统计。你可以用系统cron调用psql -c执行,也可以安装pg_cron扩展让数据库内部调度,两种方式效果差不多,看你的运维习惯。
有了这张历史表,统计就变成了趋势分析。比如你想看某张核心表最近30天的增长曲线:
SELECT stat_time::date AS day, pg_size_pretty(total_size) AS total_size FROM table_space_history WHERE table_name = 'your_core_table' AND stat_time > now() - interval '30 days' ORDER BY stat_time DESC;看到斜率的曲线,你就能提前预判这张表什么时候会膨胀到不可控,而不是等磁盘满了才手忙脚乱。这也是我从被动排查转向主动管理之后,个人觉得最值钱的一个习惯。
6.2 趋势判断:磁盘什么时候会满
光有历史数据还不够,关键是从趋势里得出结论。我通常的做法是取最近7天同一时刻的大小数据,计算平均每日增长量,然后拿当前表空间剩余容量除以日增长量,得到一个粗略的“剩余天数”。
这个数字不需要精确,只要能给你一个预警提前量就够了。比如剩余天数小于30天,就该考虑扩容、清理历史数据或做分区归档了。千万别等到剩余天数只剩一两天才动手,数据库大表操作普遍耗时,预留缓冲非常必要。
6.3 我对日常空间管理的一点建议
这几年的实战下来,我对PostgreSQL空间管理总结了几条实用准则,分享给大家:
- 第一,永远别只依赖磁盘告警,主动巡检比被动响应省心太多。
- 第二,统计大小一律以
pg_total_relation_size为准,别用单个函数以偏概全。 - 第三,大表一定要考虑分区,分区不只是查询性能的问题,更是空间清理的利器——直接DROP一个历史分区,比在几百GB的单表上做VACUUM FULL不知道快多少倍。
- 第四,把
autovacuum参数理解透再调,很多空间膨胀问题,其实是自动清理跟不上写入速度导致的。 - 第五,每次做完清理操作,记得重新执行
ANALYZE更新统计信息,否则优化器可能继续沿用清理前的行数估算,影响执行计划。
这些建议都是我从实际故障里一条条换来的,希望你看完能少踩几个坑。
最后再分享一个冷门但好用的小技巧:有些时候你不需要精确到字节,只想知道哪张表最大、大概有多大,用pg_class.relpages * 8192做粗估算已经很快很准了,尤其在连接pg_stat_user_tables时要避免大量调用重量级统计函数带来的性能开销。不过涉及到容量规划和清理决策时,一定要回到精确的pg_total_relation_size上来,估算值只用来探路,不能用来拍板。