3. 数据库对象级容量盘点

库级容量只能告诉我们“哪个库在涨”,却无法回答“到底是哪张表、哪个索引在占空间”。对象级容量盘点的目标,就是把 pg_database_size() 给出的总量,逐层拆解到表、索引、TOAST 等具体对象上,形成一张可排序、可追踪的“空间账单”。PostgreSQL 为此提供了一组内置函数,理解它们各自的统计口径,是避免误判的第一步。

三个核心函数的统计口径

最常用的三个函数是 pg_relation_size()、pg_total_relation_size() 和 pg_database_size(),它们覆盖了从对象到库的三个层级:

  • pg_relation_size(oid):只统计单个关系(表、索引、TOAST 表、物化视图)主文件(main fork)的字节数,不含 TOAST、不含空闲空间映射(FSM)和可见性映射(VM)。
  • pg_total_relation_size(oid):统计该表及其所有附属对象,包括主表、TOAST 表、TOAST 索引以及全部索引,是“一张表真正占了多少磁盘”的口径。
  • pg_database_size(oid):统计整个数据库目录下所有文件的总和,包含系统目录、WAL 之外的全部数据文件,是库级总量。

三者关系可以概括为:pg_database_size ≈ 所有对象 pg_total_relation_size 之和 + 系统目录与未归属空间。因此做对象级盘点时,应以 pg_total_relation_size 作为“表占用”的主口径,用 pg_relation_size 区分主表与索引,用 pg_database_size 做总量校验。

注意:这些函数返回的是磁盘上已分配的物理大小,不是逻辑行数或压缩后的逻辑体积。删除大量行后若不执行 VACUUM FULL 或 REINDEX,文件不会自动缩小,盘点结果会持续偏高。

定位大表:按总占用排序

最直接的盘点方式,是查询 pg_class 与 pg_namespace,按 pg_total_relation_size 降序排列。下面这段 SQL 会列出当前库中占用最大的 20 张表,并同时给出主表、索引、TOAST 的拆分:

SELECT
  n.nspname AS schema,
  c.relname AS table_name,
  pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
  pg_size_pretty(pg_relation_size(c.oid))       AS main_size,
  pg_size_pretty(pg_total_relation_size(c.oid)
                 - pg_relation_size(c.oid))     AS index_toast_size,
  pg_size_pretty(pg_indexes_size(c.oid))        AS index_size
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
  AND n.nspname NOT IN ('pg_catalog','information_schema')
ORDER BY pg_total_relation_size(c.oid) DESC
LIMIT 20;

结果中若 index_toast_size 远大于 main_size,说明瓶颈在索引或 TOAST,而不是表数据本身。例如某订单表主表 8 GB、索引合计 22 GB,那么优化重点应放在索引而非表。反之,若主表远大于索引,则要考虑是否可以通过分区、归档或压缩降低主表体积。

定位大索引:逐索引拆解

表级排序只能看到索引总量,要进一步定位到具体索引,需要查询 pg_index 与 pg_class 的关联:

SELECT
  tn.nspname AS table_schema,
  tc.relname AS table_name,
  ic.relname AS index_name,
  pg_size_pretty(pg_relation_size(ic.oid)) AS index_size,
  i.indisunique AS is_unique,
  i.indisprimary AS is_primary
FROM pg_index i
JOIN pg_class ic ON ic.oid = i.indexrelid
JOIN pg_class tc ON tc.oid = i.indrelid
JOIN pg_namespace tn ON tn.oid = tc.relnamespace
WHERE tc.relkind = 'r'
  AND tn.nspname NOT IN ('pg_catalog','information_schema')
ORDER BY pg_relation_size(ic.oid) DESC
LIMIT 20;

这张结果表能直接回答“哪个索引最贵”。常见的大索引来源包括:对高基数列建了重复索引、对宽文本列建了 B-tree 索引、以及长期未重建导致膨胀的索引。判断索引是否值得保留,可以结合 pg_stat_user_indexes 中的 idx_scan:若某索引体积很大但 idx_scan 长期为 0,就是典型的“占空间不干活”。

库级校验与对象级汇总

对象级盘点完成后,应与库级总量做一次对账,确认没有遗漏。可以先用 pg_database_size 取当前库总量,再用聚合查询汇总所有用户对象:

SELECT pg_size_pretty(pg_database_size(current_database())) AS db_total;

SELECT
  pg_size_pretty(SUM(pg_total_relation_size(c.oid))) AS user_objects_total
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r','m','i')
  AND n.nspname NOT IN ('pg_catalog','information_schema');

两者之差即为系统目录、TOAST 元数据、空闲空间等未单独列出的部分。若差值异常大,通常意味着存在大量已删除但未回收的空间,或存在临时文件残留,需要进一步排查。

对象级容量盘点:从库到表到索引的拆解路径 pg_database_size() 库级总量 · 对账基准 pg_total_relation_size() 表 + TOAST + 全部索引 · 表占用主口径 按此列降序 → 定位大表 pg_relation_size() 主表 main fork 不含 TOAST / FSM / VM pg_indexes_size() 该表全部索引合计 索引膨胀快速判断 pg_relation_size(TOAST) 宽列 / 大文本溢出存储 total − main − index pg_index + pg_class 逐索引拆解 按 pg_relation_size(indexrelid) 降序 → 定位大索引 结合 pg_stat_user_indexes.idx_scan 判断是否闲置 索引占比高 → 重建 / 删除冗余索引 REINDEX / DROP INDEX 主表占比高 → 分区 / 归档 / 压缩 PARTITION / VACUUM FULL

把盘点结果变成可执行的判断

拿到排序结果后,不要只看“谁最大”,而要看“谁大得不合理”。一个实用的判断顺序是:先看索引总量是否超过主表,若是,优先检查索引;再看单索引是否远大于同表其他索引,若是,检查是否重复或低效;最后看 TOAST 占比,若 TOAST 异常大,说明存在宽列或大文本,可考虑拆分冷热列或改用外部存储。

下面是一份可直接套用的对象级盘点记录表,建议每次盘点时填写并留存,便于对比增长趋势:

对象类型对象名总大小主表大小索引大小TOAST 大小近 30 天增长处置建议
表public.orders30 GB8 GB22 GB0.2 GB+4 GB检查冗余索引
索引idx_orders_remark9 GB—9 GB—+1.2 GBidx_scan=0,建议删除
表public.logs18 GB6 GB3 GB9 GB+6 GB按天分区 + 归档
盘点时务必区分“已分配大小”和“有效数据大小”。pg_total_relation_size 统计的是文件分配量,若表刚经历大批量删除,结果会明显偏大。此时可结合 pg_stat_user_tables.n_dead_tup 判断膨胀程度,再决定是否执行 VACUUM FULL 或 REINDEX。

对象级盘点的价值不在于一次性列出所有大对象,而在于建立“库 → 表 → 索引/TOAST”的逐层下钻能力。当库级容量告警时,你能在几分钟内回答:是 orders 表在涨,还是它的某个索引在涨,还是 logs 表的 TOAST 在涨。这个判断链条一旦建立,后续的清理、分区、归档和索引治理才有明确的靶点。