
周末在家处理一个线上问题客户那边一台数据库服务器磁盘快满了让我帮忙看看空间都去哪儿了。我登录上去连了数据库第一件事就是跑了一串空间统计SQL几分钟就把罪魁祸首揪出来了一个业务操作日志表占了整个库六成以上的空间而且一半是死数据。这种排查体验只要用 PostgreSQL 的人都应该掌握毕竟存储空间不会撒谎但你要知道去哪看、怎么看、看完怎么理解这些数字。这篇文章就把我在实际项目里反复用到的 PostgreSQL 空间占用排查方法整理一遍从数据库级的总览到表级的明细再到数据、索引、TOAST 各占多少以及数字背后隐藏的膨胀问题怎么判断。全部是能直接复制运行的 SQL还会解释每个统计口径的差异看完你也能在自己环境里马上上手。1. 为什么要花心思统计空间占用三个绕不开的业务场景1.1 磁盘告警时要先回答的三个问题磁盘空间接近阈值的时候运维或者开发的第一反应往往是先看系统层的df -h然后顺着目录一层层往下找最后确认到底是哪个数据目录在膨胀。但到了 PostgreSQL 这一层很多人就卡住了裸目录里全是 16KB 一个的关系文件文件名是一串 OID 数字根本不知道哪个文件对应哪张表更不知道这个文件是表数据还是索引。所以真正的问题其实是三个哪个数据库最大哪个表最大这个表是“真胖”还是“虚胖”“真胖”指的是有效数据本身就多业务增长导致“虚胖”指的是大量空间被死元组、废旧索引、TOAST 膨胀占着清理一下能回收很多。这三个问题不搞清楚贸然加磁盘或者盲目删除数据都不稳妥。加磁盘是把问题往后拖盲目 delete 可能又碰上空间不回收的坑越删越糊涂。1.2 空间统计必须建立的两个核心概念很多人第一次查空间时会困惑为什么同一个表用不同函数查出来的数字不一样。我建议脑海里先建立两个概念。第一一张表的磁盘占用不是指“表主体那一个文件”而是由三部分构成表本身的数据文件、表上所有索引的索引文件、以及 TOAST 文件。TOAST 是 PostgreSQL 的一种行外存储机制专门处理超长字段后面会详细讲。所以“这个表到底占多少空间”严格来说必须用pg_total_relation_size它把三块都算了进去。第二PostgreSQL 统计的“占用空间”本质上是关系文件里物理页面的数量乘以块大小。默认块大小是 8KB一个表文件有 100 万个数据页那它的pg_relation_size就是 8KB 乘以 100 万约 7.6GB。这个口径非常接近文件系统里的实际大小但又不完全等同因为文件系统层面还可能涉及稀疏文件之类的情况对日常排查来说直接把函数返回值当作磁盘占用来理解就够了。有了这两个概念再看下面的函数、视图和 SQL思路就顺了。2. 核心函数与系统视图空间统计工具箱2.1 五个核心函数的区别与选择PostgreSQL 提供了一组专门用于空间统计的系统函数名字相近容易混淆我把它们整理成一张对比表。函数统计范围典型使用场景pg_relation_size(regclass)单单指传入的表或索引本体文件查某个索引到底多大或者只关心表数据本身pg_table_size(regclass)表本体 附属的 TOAST 表判断表数据含行外存储的总量排除索引干扰pg_indexes_size(regclass)表上全部索引的总和排查索引是不是占用过大pg_total_relation_size(regclass)表本体 TOAST 所有索引回答“这张表总共占多少空间”pg_database_size(nameoid)整个数据库内所有对象这五个函数之间的包含关系可以这么记pg_total_relation_size一定大于等于pg_table_size而pg_table_size又大于等于pg_relation_size。如果一个表没有索引、没有 TOAST这三个值会相等但实际业务表几乎不可能出现这种情况。我用一个简单示例验证一下区别。假设有一张名为ops_log的表执行三行查询SELECT pg_relation_size(public.ops_log) AS table_only, pg_table_size(public.ops_log) AS table_with_toast, pg_indexes_size(public.ops_log) AS index_total, pg_total_relation_size(public.ops_log) AS all_total;在我的测试环境里结果大致是table_only 约 5.2GBtable_with_toast 约 5.9GBindex_total 约 3.1GBall_total 约 9.0GB。可以看到光看“表数据”和看“全部占用”差了快一倍排查时如果只看前者很容易严重低估。2.2 配套视图与格式化函数函数负责计算单个对象要把整库、整组对象遍历统计出来就需要配合系统视图使用。日常最常用的是pg_stat_user_tables因为它已经提前算好了relid还带了schemaname、relname、n_live_tup、n_dead_tup、last_vacuum这些统计信息写 SQL 最顺手。如果想要更底层的视角可以用pg_class搭配pg_namespace。pg_class里有个reltoastrelid字段指向这张表附属的 TOAST 表的 OID这是精确拆分 TOAST 空间的关键字段在pg_stat_user_tables里拿不到。另外还有两个格式化函数必须认识pg_size_pretty(byte)把字节数转成人类易读的“GB”“MB”避免一长串数字看到眼晕pg_size_bytes(text)方向相反把“1GB”这样的字符串解析成字节数适合写脚本做阈值判断。实际 SQL 里我几乎总是用pg_size_pretty包一层再去展示只保留排序时用原始字节数。2.3 函数背后的实现逻辑为什么统计的是物理页数有读者可能会问pg_relation_size为什么能这么精确它其实就是按照“文件系统里这个关系实际占了多少个块”来计算的。PostgreSQL 的每个表和索引在磁盘上对应一个或多个文件文件内部按固定大小切页默认 8KB。函数内部读取这个关系的物理文件大小换算成页面数再乘以一个页的实际大小返回字节数。这也解释了另一个常见疑问为什么用du -sh查看数据目录时得到的总大小和所有库的pg_database_size加起来对不上。因为数据目录里除了堆表、索引文件还包含 WAL 日志pg_wal、事务日志、统计信息文件、临时文件、日志归档等这些都不算在任何数据库对象的“关系大小”内。所以库级别的统计相加只反映了逻辑数据占用不等于整个数据目录的磁盘占用排查磁盘空间时要额外看一眼pg_wal目录特别是启用了大量长事务或备库落后的场景WAL 堆积几十 GB 都很正常。3. 一套可直接复现的空间盘点方案3.1 全局概览查看所有数据库占用大小我登录一台新环境时习惯先跑这一段 SQL把实例里所有数据库按大小排个序SELECT datname AS database_name, pg_size_pretty(pg_database_size(datname)) AS total_size FROM pg_database ORDER BY pg_database_size(datname) DESC;输出会类似这样database_nametotal_sizebiz_main86 GBbiz_report24 GBtemplate17.5 MBpostgres7.5 MB注意一个细节pg_database_size的参数可以是库名也可以是数据库 OID字符串参数直接用库名最方便但当前连接用户需要有对应库的 CONNECT 权限否则查不到。正常情况下 superuser 或者具备pg_read_all_stats角色的用户都能查得全。这一步能快速确认是不是只有某一个库特别大还是所有库都均衡增长均衡增长往往意味着全局性策略问题比如没有归档清理、没有分区裁剪。3.2 按表排行定位库内吃空间的 Top N确定大库之后我会把连接切到那个库里然后执行这个最常用的“大表排行榜”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 table_data, pg_size_pretty(pg_indexes_size(relid)) AS index_size FROM pg_stat_user_tables ORDER BY pg_total_relation_size(relid) DESC LIMIT 20;LIMIT 20 是因为通常只看头部就能定位问题。这里我故意把total_size、table_data、index_size三列一起展示就是方便一眼看出“这张表到底是大在数据上还是大在索引上”。比如某一个频繁按条件过滤的大宽表可能数据只有 2GB索引却堆到 18GB那优先考虑的就不是清数据而是看索引有没有冗余、以及索引膨胀是否严重。实际跑出来的一张典型结果可能长这样schema_nametable_nametotal_sizetable_dataindex_sizepublicops_log86 GB61 GB20 GBpublicuser_event12 GB8 GB4 GBpublicarchive_bill8 GB7 GB1 GBops_log 这种名字一看就是日志类表数据量大是合理的但它通常有鲜明的生命周期特征——只需要保留最近 N 天。这种表往往是分区表最典型的改造对象。3.3 精确拆分表数据、索引、TOAST 分别占多少排行榜里只能看到数据和索引的总和那 TOAST 占多少怎么看pg_stat_user_tables里没有直接用现成字段需要回到pg_class通过reltoastrelid手动关联。SELECT c.relname AS table_name, pg_size_pretty(pg_relation_size(c.oid)) AS table_data, pg_size_pretty(pg_indexes_size(c.oid)) AS index_size, pg_size_pretty(pg_relation_size(c.reltoastrelid)) AS toast_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 LIMIT 20;这里有一个容易忽略的点c.reltoastrelid如果是 0说明这张表没有 TOAST 表pg_relation_size(0)会返回 NULL展示出来就是空的。TOAST 表会在什么情况下产生当表里存在变长字段text、jsonb、bytea甚至超长的 varchar且一行数据超过约 2KB 时PostgreSQL 会把大字段压缩后挪到附属的 TOAST 表里原表只留一个指针。对大多数业务表来说TOAST 占比很小可以忽略。但有几个场景它会变成大头大量写入 JSONB 大文档、存储 base64 图片内容、日志表里带长 message 字段。我在一个项目里就碰到过一张“消息记录表”表数据只有 12GBTOAST 却有 8GB因为业务把每次请求的完整响应报文都塞进一个 text 字段里。这种情况下想优化思路就不是去 vacuum 表而是考虑对报文做截断、压缩或者转移到对象存储单纯清表意义不大。3.4 按 Schema 汇总与业务分类大库看完全局按 Schema 聚合也是一个常见视角尤其是同一台实例里跑了多个业务模块每个模块独占一个 Schema 的场景。SELECT schemaname, pg_size_pretty(SUM(pg_total_relation_size(relid))) AS total_size, COUNT(*) AS table_count FROM pg_stat_user_tables GROUP BY schemaname ORDER BY SUM(pg_total_relation_size(relid)) DESC;这种方法特别适合回答“我们哪个业务模块占空间最多”这种管理侧问题。我之前帮客户做容量规划时就是用这个 SQL 把各个模块的租户数据分别统计出来做成一张趋势表每周跑一次看增量是否异常比等磁盘告警再被动处理舒服得多。顺带还可以按索引单独排个榜看哪些表的索引最“重”SELECT schemaname, relname, indexrelname, pg_size_pretty(pg_relation_size(indexrelid)) AS index_size FROM pg_stat_user_indexes ORDER BY pg_relation_size(indexrelid) DESC LIMIT 20;这个查询在排查“某张表写入慢、索引比表还大”的场景里特别有用往往能直接看到几个惊人的大索引再结合执行计划判断它是不是被真正用到了。4. 数字之外的真相膨胀、死元组与索引陷阱4.1 表很大不代表数据多堆表空间回收机制空间统计查出来一张表有 80GB是不是就代表里面有 80GB“有效数据”不一定。PostgreSQL 的堆表在更新和删除时旧版本的行数据并不会立刻从物理文件里消失而是被标记为“死元组”留在原页面里。autovacuum 进程会定期清理这些死元组让页面里的空间可以被后续插入复用但清理掉的死元组只是页面内部腾空了位置物理文件并不会自动收缩。这就是为什么你会遇到这样的怪现象一张表 DELETE 掉 90% 的数据执行完发现pg_total_relation_size一点没变文件还是几十 GB。只有继续执行VACUUM FULL把整个表重写一遍物理文件才会真正瘦身。所以判断一张表是不是“虚胖”光看大小不够还要配合统计信息里的死元组数量来看。SELECT relname, n_live_tup, n_dead_tup, last_vacuum, last_autovacuum FROM pg_stat_user_tables WHERE n_dead_tup 10000 ORDER BY n_dead_tup DESC LIMIT 10;如果看到n_dead_tup长期维持在一个高位比如几百万甚至上千万说明表的 UPDATE/DELETE 频率很高而且 autovacuum 处理得不够及时。这时候表的空间占用里就包含大量“等待复用”的死空间。把死元组清掉以后后续写入可以直接复用这些空间但物理文件还是要靠 VACUUM FULL 才能还给操作系统。值得强调的是VACUUM FULL会获取表的独占锁在线业务要谨慎执行一般建议放在维护窗口或者用pg_repack这类在线重建工具替代。最彻底的方案还是把大表改造成分区表按时间批量DETACH旧分区连锁都不用长时间持有。4.2 悄无声息的索引膨胀索引比数据更容易膨胀但很多人查空间时只盯着表数据忽略了索引。为什么索引更容易膨胀因为索引页面里的元组更新不会像堆表那样可以复用任意空位delete 标记的死索引项被清理后页面即便被重新整理也经常无法把空间完全紧凑回来反复更新的索引页久而久之就变得松散文件越来越大。判断索引是否膨胀可以用 PostgreSQL 自带的pgstattuple扩展做一个体检CREATE EXTENSION IF NOT EXISTS pgstattuple; SELECT * FROM pgstattuple(public.ops_log_idx_created_at);重点看free_space和dead_tuple_percent两个字段。如果dead_tuple_percent很高说明索引里大量条目已经失效如果free_space占比很大说明页面内部碎片多索引处于“虚胖”状态。处理方法是重建索引REINDEX TABLE public.ops_log;或者针对单个索引重建REINDEX INDEX public.ops_log_idx_created_at;在 PostgreSQL 12 及以后的版本里REINDEX已经支持CONCURRENTLY模式可以避免长时间锁表但它需要更多的 I/O 和临时空间执行时要注意磁盘余量。4.3 定位空间问题后的常用处理动作空间排查做完接下来就是动作决策。我把常见场景和处理手段整理成一套自己的判断顺序。第一步先看业务数据是否需要全量保留。日志表、流水表、中间结果表通常都有生命周期超过保留期限的数据直接按时间分区批量清理成本最低。第二步如果数据必须保留再判断“膨胀”占比大不大。死元组多、索引碎片多就做VACUUM FULL或REINDEX CONCURRENTLY。第三步如果清理之后过段时间又涨回来说明是业务写入模型的问题比如频繁 UPDATE 超大字段、大量临时表消耗这时就要从应用层面优化该改表结构改表结构该做异步归档做异步归档。还有一个容易忽略的细节删除数据时尽量避免用大批量 DELETE尤其是一次性 DELETE 上亿行。这种操作会制造海量死元组触发大量垃圾回收而且事务要长时间持有快照很可能把相关表卡住。更推荐的做法是分批小事务删除或者直接 TRUNCATE 整个分区这也是我坚持建议核心业务表都做成分区表的原因。5. 常见问题排查速查表我把自己在群里被问得最多的几个空间问题整理成一张速查表基本覆盖了日常 90% 的疑惑。现象可能的原因解决思路表只有几百万行占用却几十 GBTOAST 超大字段或死元组膨胀拆分 TOAST 看占比统计死元组考虑 VACUUM FULL删除大量数据后文件大小没变堆表物理文件不会自动收缩执行 VACUUM FULL 或 pg_repack注意锁pg_relation_size很小但磁盘显示占用很高没算索引和 TOAST改用pg_total_relation_size看整体索引比表还大索引冗余、索引膨胀、索引字段过多用 pgstattuple 检查膨胀删除无用索引REINDEX所有库大小加起来和 du 对不上数据目录还有 WAL、临时文件、统计信息等单独看 pg_wal 目录大小检查长事务查某个表大小报权限错误当前用户对该表没有权限换超级用户或授予相应权限统计结果里 TOAST 为 NULL表没有 TOAST 表reltoastrelid 为 0属正常情况不用处理这张表不是万能的但它适合贴在团队内部文档里新同学排查空间问题时先对照一遍能少踩很多坑。最后再分享一个我的个人习惯给每台核心实例准备一个空间巡检脚本每天或每周自动跑一遍把库级大小、Top 20 表大小、死元组统计、索引异常这些结果落到一张表里持续记录趋势。这样下次领导突然问“我们库是不是快满了”或者客户半夜发现磁盘告警你不需要临时动手写 SQL直接把趋势表拉出来就能讲清楚哪个模块在哪天开始异常增长。数据库空间问题不是靠一次排查就能一劳永逸的持续观察比事后再分析要省心太多。