4. 表空间与文件系统容量映射

在 PostgreSQL 中,数据库对象最终都会落到操作系统的某个目录下,而表空间正是连接“逻辑对象”和“物理文件系统”的桥梁。容量治理如果只看 pg_database_size() 或 pg_total_relation_size(),往往无法回答“磁盘为什么满了”“哪个挂载点最先告急”“扩容该加在哪块盘”这类问题。要回答这些问题,必须把表空间布局、数据目录结构、文件系统使用率和具体数据库对象四者关联起来。

一、表空间与数据目录的物理布局

PostgreSQL 初始化后,数据目录(PGDATA,由 initdb 创建)内部有清晰的物理结构。核心目录包括:base/ 存放默认表空间 pg_default 中各个数据库的文件,子目录名即数据库 OID;global/ 存放共享系统目录;pg_tblspc/ 存放自定义表空间的符号链接,链接名是表空间 OID,指向真正的文件系统路径;pg_wal/ 存放 WAL 日志;pg_xact/、pg_multixact/ 等存放事务相关文件。

自定义表空间创建时,PostgreSQL 会在指定路径下建立 PG_版本号_目录版本号/ 子目录,例如 PG_16_202307071/,然后在 pg_tblspc/ 下创建同名 OID 的软链接指向它。因此,一个表空间在文件系统上对应一个真实目录,在 PGDATA 中对应一个软链接,在系统目录 pg_tablespace 中对应一行记录。三者通过 OID 一一对应,这是做容量映射的关键。

表空间 → 数据目录 → 文件系统 → 数据库对象 映射关系 数据库对象 表 orders relfilenode=16385 索引 idx_orders_ts relfilenode=16390 表 logs_2024 relfilenode=16402 TOAST 表 relfilenode=16386 WAL 段文件 pg_wal/00000001... 事务日志 pg_xact/ 表空间 pg_default OID=1663 pg_global OID=1664 ts_hot OID=16500 ts_archive OID=16501 pg_wal 表空间 可选独立挂载 文件系统 / 挂载点 /data/pgdata base/ global/ pg_wal/ pg_xact/ 容量 500G · 已用 78% /data/ts_hot PG_16_202307071/ 容量 1T · 已用 62% /data/ts_archive PG_16_202307071/ 容量 2T · 已用 91% ⚠ /data/wal pg_wal/ 容量 200G · 已用 45% 映射链:pg_class.reltablespace → pg_tablespace.oid → pg_tblspc/软链接 → 文件系统挂载点 → df 使用率

二、用 SQL 建立对象与表空间的关联

要定位“哪个表空间、哪个对象占了多少空间”,可以借助系统目录视图。下面这条查询把数据库、表空间、对象名、对象类型和大小关联起来,按表空间分组排序:

SELECT
  t.spcname                          AS tablespace,
  n.nspname                          AS schema,
  c.relname                          AS object_name,
  CASE c.relkind
    WHEN 'r' THEN 'table'
    WHEN 'i' THEN 'index'
    WHEN 't' THEN 'toast'
    WHEN 'm' THEN 'matview'
    ELSE c.relkind::text
  END                                AS obj_type,
  pg_size_pretty(pg_relation_size(c.oid)) AS size
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_tablespace t ON t.oid = c.reltablespace
WHERE c.relkind IN ('r','i','t','m')
ORDER BY pg_relation_size(c.oid) DESC
LIMIT 20;

注意 pg_class.reltablespace = 0 表示对象位于数据库默认表空间(通常是 pg_default),因此上面的 JOIN 会漏掉这类对象。更完整的写法应使用 COALESCE(NULLIF(c.reltablespace,0), d.dattablespace) 回退到数据库默认表空间。此外,pg_relation_size() 只统计主文件,索引、TOAST 需要分别统计,或用 pg_total_relation_size() 汇总。

三、把表空间映射到文件系统使用率

表空间本身不携带容量信息,容量来自它所在的挂载点。因此需要先查出每个表空间的物理路径,再与 df 输出对照。查询表空间路径:

SELECT spcname, oid,
       pg_tablespace_location(oid) AS location
FROM pg_tablespace;

对于 pg_default 和 pg_global,pg_tablespace_location() 返回空字符串,它们实际位于 PGDATA 下的 base/ 和 global/。自定义表空间则返回真实路径。拿到路径后,在操作系统侧执行:

df -h /data/pgdata /data/ts_hot /data/ts_archive /data/wal

把 df 的 Use% 与表空间对应起来,就能得到“表空间—挂载点—使用率”的映射表。例如 ts_archive 位于 /data/ts_archive,该挂载点已用 91%,而 ts_hot 仅 62%,说明归档表空间是当前容量风险点。

四、关联分析:从使用率反查对象

当某个挂载点使用率偏高时,需要进一步定位是哪些对象贡献了增长。可以按表空间汇总对象大小:

SELECT t.spcname,
       pg_size_pretty(SUM(pg_total_relation_size(c.oid))) AS total_size,
       COUNT(*) AS obj_count
FROM pg_class c
JOIN pg_tablespace t
  ON t.oid = COALESCE(NULLIF(c.reltablespace,0),
                      (SELECT dattablespace FROM pg_database
                       WHERE datname = current_database()))
WHERE c.relkind IN ('r','i','m')
GROUP BY t.spcname
ORDER BY SUM(pg_total_relation_size(c.oid)) DESC;

再结合 pg_stat_user_tables 的 n_dead_tup、n_live_tup 以及 pg_statio_user_tables 的读取情况,可以判断增长是来自业务写入、索引膨胀还是死元组堆积。例如某表 pg_total_relation_size 为 120GB,其中索引占 45GB,而 n_dead_tup 高达 3000 万,说明该表既需要 VACUUM 回收,也可能需要评估索引是否冗余。

建议把上述查询固化为巡检脚本,每天采集一次表空间大小、挂载点使用率和 Top 对象,写入容量基线表。这样当使用率环比上升超过阈值(如 5%/天)时,可以提前介入,而不是等到磁盘写满。

五、容量映射的实践要点

  • OID 是纽带:数据库 OID、表空间 OID、relfilenode 共同决定文件路径,排查时先取 OID 再拼路径,避免凭目录名猜测。
  • 软链接要跟到底:pg_tblspc/ 下是软链接,用 ls -l 或 readlink -f 解析真实路径,否则 du 统计会失真。
  • 区分逻辑大小与物理占用:pg_relation_size 是逻辑大小,文件系统占用还受块大小、稀疏文件、WAL 和临时文件影响,两者不必相等。
  • 预留安全水位:单挂载点使用率建议不超过 80%,WAL 所在盘不超过 70%,为 VACUUM FULL、pg_basebackup 等操作留出临时空间。
  • 表空间不是备份:表空间只改变存储位置,不提供冗余。容量治理仍需配合备份、归档和监控。

通过“对象 → 表空间 → 软链接 → 挂载点 → 使用率”这条完整链路,容量问题就能从模糊的“磁盘快满了”落到具体的表空间、具体的对象和具体的增长原因上,为后续的扩容、迁移和清理提供明确依据。