Skip to content

磁盘文件结构(PGDATA / relfilenode) ​

数据库的内存是易失的,最终所有数据都要落到磁盘上。本节拆开 PostgreSQL 的数据目录(PGDATA),讲清 base/global/pg_wal/pg_xact 等子目录分别存放什么、表和索引在磁盘上如何用 OID(对象标识符)命名、为什么一张表会变成多个 1GB 的段文件,并给出用 pg_relation_filepath() 等函数把「逻辑表」映射到「物理文件」的实操方法。

前置知识

阅读本节前,建议先了解:内存结构(Shared Buffer / WAL Buffer)

基础概念 ​

PostgreSQL 把整个实例的所有数据都放在一个目录里,这个目录叫数据目录(Data Directory),环境变量名是 PGDATA。理解磁盘结构的关键在于:PostgreSQL 不按「表名」存文件,而是按「对象标识符」存文件。

术语英文名说明
数据目录PGDATA, Data Directory一个实例的全部数据所在目录,由 initdb 创建
对象标识符OID, Object Identifier系统为每个对象分配的 32 位无符号整数唯一 ID
关系文件节点relfilenode表/索引物理文件的主文件名,通常等于其 OID,但会被重写操作改变
数据块Block / Page磁盘与内存之间读写的最小单位,默认 8KB
段文件Segment File单个物理文件上限 1GB,超出后自动切分出的 .1、.2 文件
分叉文件Fork同一个 relfilenode 的旁路文件,如 _fsm、_vm、_init
表空间Tablespace把数据文件放到 PGDATA 之外的其他磁盘路径
目录版本号Catalog Version编译期生成的内部版本标识,用于隔离不同大版本的物理格式
控制文件pg_control记录集群状态、最新检查点、WAL 位置等元信息的二进制文件

不要手动碰 PGDATA 里的任何文件

PGDATA 是 PostgreSQL 的私有领地。直接删除、重命名、复制里面的文件(尤其是 pg_wal/、pg_xact/、pg_filenode.map、global/pg_control)会导致集群无法启动甚至数据永久损坏。唯一的备份手段是 pg_basebackup 或文件系统级快照,而不是 cp -r PGDATA。

PGDATA 目录总览 ​

一个由 initdb 初始化、跑过一段时间的实例,目录结构大致如下:

text
$PGDATA/
├── PG_VERSION              # 主版本号,内容就是一行 "18"
├── postgresql.conf         # 主配置文件
├── postgresql.auto.conf    # ALTER SYSTEM 写入的配置(优先级更高)
├── pg_hba.conf             # 客户端认证规则
├── pg_ident.conf           # 认证用户名映射
├── postmaster.pid          # 运行中的实例信息(PID、端口、数据目录)
├── postmaster.opts         # 上次启动时用的命令行参数
├── base/                   # 各数据库的数据文件,子目录名 = 数据库 OID
│   ├── 1/                  # template1
│   ├── 4/                  # template0
│   ├── 5/                  # postgres
│   └── 16384/              # 用户创建的数据库
│       ├── 16385           # 某个表的 relfilenode 文件
│       ├── 16385_fsm       # 该表的空闲空间映射
│       ├── 16385_vm        # 该表的可见性映射
│       └── pg_filenode.map # 系统目录的 relfilenode 映射表
├── global/                 # 跨数据库共享的系统目录(pg_database、pg_authid 等)
├── pg_wal/                 # 预写日志(WAL)段文件
│   ├── 000000010000000000000001
│   ├── archive_status/     # 归档状态标记
│   └── summaries/          # PostgreSQL 17+:WAL 摘要(增量备份用)
├── pg_xact/                # 事务提交日志(CLOG),记录每个事务的最终状态
├── pg_multixact/           # 多事务状态(行级锁等待、共享锁)
├── pg_subtrans/            # 子事务状态
├── pg_commit_ts/           # 提交时间戳(仅 track_commit_timestamp = on 时使用)
├── pg_tblspc/              # 表空间:指向 PGDATA 外部目录的符号链接
├── pg_replslot/            # 逻辑复制槽的持久化状态
├── pg_logical/             # 逻辑解码的快照与映射文件
├── pg_twophase/            # 两阶段提交的预备事务状态
├── pg_notify/              # LISTEN/NOTIFY 的持久化队列
├── pg_serial/              # 可串行化事务的冲突信息
├── pg_snapshots/           # 导出的快照(pg_export_snapshot())
├── pg_stat/                # 统计信息的持久化文件
└── pg_dynshmem/            # 动态共享内存的元数据

版本差异:两个改过名的目录

  • PostgreSQL 10 之前:pg_wal 叫 pg_xlog,pg_xact 叫 pg_clog。老文章里的这两个名字指的就是现在的目录。
  • PostgreSQL 15 起:统计收集器进程(stats collector)被移除,统计信息改为存放在共享内存中并定期落盘,pg_stat/ 目录的内容随之改变。
  • PostgreSQL 17 起:新增 pg_wal/summaries/ 目录,用于支持增量备份(pg_basebackup --incremental)。

核心机制 ​

数据目录的划分逻辑 ​

四个主目录的分工非常清晰,理解它们就理解了一半:

目录存放内容是否可跨库访问命名方式
base/普通数据库的所有表、索引、TOAST 等否,各自隔离在数据库子目录内子目录名 = 数据库 OID,文件名 = relfilenode
global/集群级系统目录:pg_database、pg_authid、pg_tablespace 等是,所有数据库共享文件名 = relfilenode
pg_wal/预写日志段文件是,全局唯一序列24 位十六进制名,含时间线、逻辑段号
pg_xact/事务提交状态(每个事务 2 bit)是,全局共享4 位十六进制名,每个文件 256KB

base/ 下的每个子目录就是一个数据库。这也解释了为什么 PostgreSQL 不能跨库查询——两个数据库的数据物理上锁在不同的目录里,没有统一的执行计划能同时扫描两边。

从表名到磁盘文件的完整链路 ​

一条 SQL 里的表名,要经过四次映射才能变成磁盘上的字节:

text
t_user(表名)
  ↓ 查 pg_class(在 pg_namespace 中按 search_path 定位到具体 schema)
pg_class.oid = 16385      ← 关系 OID
  ↓ 取 relfilenode 字段
pg_class.relfilenode = 16385   ← 物理主文件名(绝大多数情况等于 oid)
  ↓ 拼接数据库目录(pg_database.oid)
base/16384/16385
  ↓ 加上分叉后缀
base/16384/16385      (main, 主数据)
base/16384/16385_fsm  (空闲空间映射)
base/16384/16385_vm   (可见性映射)
base/16384/16385_init (init fork,仅无日志表有)

只需要一条内置函数就能拿到最终路径:

sql
SELECT pg_relation_filepath('t_user');
text
 pg_relation_filepath
----------------------
 base/16384/16385
(1 row)

为什么需要 relfilenode 而不是直接用 oid? 因为有些操作需要整体重建物理文件:TRUNCATE、VACUUM FULL、CLUSTER、REINDEX。如果文件名恒等于 OID,重建时就无法原子切换;引入一层可变的 relfilenode,新文件写好、元数据一提交、旧文件延迟删除——整个替换过程对并发事务是原子的。

段文件切分:为什么一张表会变多个文件 ​

单个物理文件存在上限。PostgreSQL 约定每个文件最大 1GB,超出部分自动切分为 xxx.1、xxx.2……

text
base/16384/
├── 16385        # 第 1 个 1GB:0 ~ 1073741823 字节
├── 16385.1      # 第 2 个 1GB
├── 16385.2      # 第 3 个 1GB
├── 16385_fsm    # 空闲空间映射(自身也可切分为 16385_fsm.1)
└── 16385_vm     # 可见性映射

这样设计有三个好处:绕开部分文件系统对单文件大小的限制(如老式 32 位系统、某些 NFS)、便于按需增长、pg_relation_size() 只需累加段文件即可得到表大小。

文件(分叉)无后缀(main)_fsm_vm_init
内容表/索引的数据块本身空闲空间映射(Free Space Map)可见性映射(Visibility Map)无日志表的初始状态
哪类对象有所有有存储的对象表、物化视图表、物化视图仅无日志表(UNLOGGED)
是否可缺省否是(重建前可能为空)是(VACUUM 前为空)是(非无日志表没有)

详细说明 ​

base/ 与 global/:数据文件的家 ​

base/ 下层是数据库,global/ 层直接就是文件。两者的文件名规则相同,都是 relfilenode。

sql
-- 数据库 OID 与名称的对应关系,决定了 base/ 下的子目录名
SELECT oid, datname, pg_size_pretty(pg_database_size(datname)) AS size
FROM pg_database
ORDER BY oid;
text
  oid  |   datname   |  size
-------+-------------+---------
     1 | template1   | 7696 kB
     4 | template0   | 7613 kB
     5 | postgres    | 7833 kB
 16384 | app_prod    | 24 MB
(4 rows)
bash
# ── 用 OID 去磁盘上找对应的数据库目录 ──
ls -1 "$PGDATA/base" | sort -n | head -n 10

# ── 查看某个数据库目录里的数据文件(按大小排序,最大的就是最热的表) ──
ls -lhS "$PGDATA/base/16384" | head -n 15
text
# base/16384 目录下的真实文件(节选)
-rw-------  1 postgres postgres  1.0G 16385         # 单表已达 1GB,即将切分
-rw-------  1 postgres postgres  1.0G 16385.1       # 第二个段文件
-rw-------  1 postgres postgres  256M 16386
-rw-------  1 postgres postgres  1.1M 16385_fsm
-rw-------  1 postgres postgres  136K 16385_vm
-rw-------  1 postgres postgres  8.0K pg_filenode.map

base/ 和 global/ 下各有一个 pg_filenode.map 文件。它保存着关键系统目录的 relfilenode 映射——因为系统目录的 OID 是硬编码在源码里的(如 pg_class 的 OID 固定为 1259),但它们的物理文件名会变,需要这张映射表来兜底。这个文件被损坏时集群直接无法启动,绝不能手工编辑或替换。

sql
-- 查看系统目录的 OID 与 relfilenode 是否一致
SELECT oid, relname, relfilenode, relkind
FROM pg_class
WHERE relname IN ('pg_class', 'pg_database', 'pg_attribute', 'pg_proc')
ORDER BY oid;
text
  oid  |    relname    | relfilenode | relkind
-------+---------------+---------------+---------
  1255 | pg_proc       |          1255 | r
  1259 | pg_class      |          1259 | r
  1249 | pg_attribute  |          1249 | r
  1262 | pg_database   |          1262 | r
(4 rows)

pg_database 为什么在 global/ 里

pg_database、pg_authid、pg_tablespace、pg_shdescription 这类目录需要被所有数据库看到(否则连不上其他库都不知道有哪些库),所以它们放在 global/ 而不是某个 base/<oid>/ 里。

relfilenode 会变的三种情况 ​

这是排查磁盘问题时最容易踩的坑:pg_class.relfilenode 不等于 pg_class.oid 时,说明这张表被重建过。

sql
-- 建表并记录初始 relfilenode
CREATE TABLE t_user (id int PRIMARY KEY, name text);
SELECT oid, relname, relfilenode FROM pg_class WHERE relname = 't_user';
text
  oid  | relname | relfilenode
-------+---------+-------------
 16385 | t_user  |       16385
(1 row)
sql
-- TRUNCATE 之后,OID 不变,但 relfilenode 变了(物理文件被整体替换)
TRUNCATE t_user;
SELECT oid, relname, relfilenode FROM pg_class WHERE relname = 't_user';
text
  oid  | relname | relfilenode
-------+---------+-------------
 16385 | t_user  |       16402   -- 变了!旧文件 16385 已被删除
(1 row)
操作relfilenode 是否改变原因
TRUNCATE是直接丢弃旧文件,建一个新的空文件,避免逐页清理
VACUUM FULL是重写整表以彻底回收空间,完成后替换文件
CLUSTER是按索引顺序重写整表
REINDEX(索引)是索引重建为新文件
ALTER TABLE ... SET TABLESPACE是物理文件搬到另一个表空间
INSERT / UPDATE / DELETE否原地修改页面
普通 VACUUM否只在页内整理,不重建文件

relfilenode = 0 的情形

以下对象没有物理文件,relfilenode 为 0,pg_relation_filepath() 返回 NULL:

  • 视图(relkind = 'v')、外部表('f')
  • 分区表的父表('p',数据都在各个分区子表里)
  • 序列在部分版本中的占位形态

排查「磁盘上没有这个文件」时,先确认对象类型,别急着怀疑数据损坏。

pg_wal/、pg_xact/ 与其他状态目录 ​

这四类目录决定数据库能否从崩溃中恢复,是备份策略的核心对象。

(1)pg_wal/:预写日志

sql
-- 当前正在写的 WAL 文件与位置
SELECT pg_current_wal_lsn() AS cur_lsn,
       pg_walfile_name(pg_current_wal_lsn()) AS cur_file,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), '0/0')) AS written;
text
 cur_lsn  |           cur_file           | written
----------+------------------------------+---------
 0/1A2B3C8 | 000000010000000000000001     | 26 MB
(1 row)
bash
# ── WAL 段文件命名:时间线(8) + 逻辑段号(8) + 段内序号(8),共 24 位十六进制 ──
# 000000010000000000000001
#   |      |       └── 1 亿分之一…… 实际是段内编号
#   |      └── 逻辑段号(logical log id)
#   └── 时间线(timeline),主库通常为 00000001
ls -lh "$PGDATA/pg_wal" | head -n 10
text
-rw-------  1 postgres postgres 16M 000000010000000000000001
-rw-------  1 postgres postgres 16M 000000010000000000000002
-rw-------  1 postgres postgres 16M 000000010000000000000003
drwx------  2 postgres postgres  30 archive_status   # 每个待归档段对应一个 .ready/.done 标记

WAL 段文件默认 16MB(编译期 --with-wal-segsize 可改,初始化后不可变),文件名中的时间线(Timeline)用于区分主库与从库的 WAL 分支——做过主备切换后会看到 00000002、00000003 等。

(2)pg_xact/:事务提交日志(CLOG)

每个事务只需 2 bit 表示四种状态:进行中、已提交、已中止、子事务已提交。按此密度,一个 256KB 的文件可覆盖约 100 万个事务。

bash
# ── 4 位十六进制文件名,每个文件固定 256KB ──
ls -lh "$PGDATA/pg_xact" | tail -n 5
text
-rw-------  1 postgres postgres 256K 0000   # 覆盖 XID 0 起的一段事务
-rw-------  1 postgres postgres 256K 0001

pg_xact 的空间占用会随着事务量增长,但不能手工清理——只有 VACUUM 完成冻结(freeze)后,老的事务号才可以被回收。这也是「XID 环绕」风险的物理根源:如果一直不 VACUUM,pg_xact/ 会持续膨胀,最终逼近 20 亿事务的上限而强制停机。

(3)其余状态目录

目录用途空间特征
pg_multixact/多事务 ID,行级共享锁同时被多事务持有时使用含 members/ 与 offsets/ 两个子目录,异常增长往往意味着长事务持有大量共享锁
pg_subtrans/子事务(SAVEPOINT)的父事务映射每个文件 256KB
pg_commit_ts/事务提交时间戳,需 track_commit_timestamp = on(需重启)默认不使用
pg_replslot/逻辑/物理复制槽状态,决定 WAL 能否被回收槽不推进即为 WAL 堆积的根因
pg_twophase/两阶段提交的预备事务状态文件每个预备事务一个文件,文件名为 GID
pg_logical/逻辑解码的快照与系统目录映射snapshots/、mappings/
pg_notify/LISTEN/NOTIFY 的持久化队列,仅在崩溃恢复时使用通常很小
pg_serial/可串行化隔离级别的冲突信息通常很小
pg_snapshots/pg_export_snapshot() 导出的快照用完即删
pg_stat/统计信息持久化文件,重启后用于恢复累计计数很小
pg_dynshmem/动态共享内存的元数据很小
sql
-- 逻辑复制槽:WAL 无法回收的最常见原因
SELECT slot_name, plugin, active, wal_status,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
FROM pg_replication_slots;
text
 slot_name |  plugin  | active | wal_status | retained_wal
-----------+----------+--------+------------+--------------
 slot_a    | pgoutput | t      | reserved   | 2.1 GB
 slot_stale| pgoutput | f      | extended   | 48 GB        -- 长期未消费,WAL 堆积
(2 rows)

实战示例 ​

示例 1:把一张表完整映射到磁盘文件 ​

目标:从 SQL 一路追到 ls 看到的文件,验证整条链路。

sql
-- 1. 建一张有 TOAST 潜力的表并插入一些数据
CREATE TABLE t_disk_demo (
  id     bigserial PRIMARY KEY,
  note   text
);
INSERT INTO t_disk_demo (note)
SELECT repeat('pg-disk-', 300) FROM generate_series(1, 2000);

-- 2. 查询逻辑信息与物理路径
SELECT c.oid,
       c.relfilenode,
       pg_relation_filepath(c.oid)                    AS filepath,
       pg_size_pretty(pg_relation_size(c.oid))        AS main_size,
       pg_size_pretty(pg_relation_size(c.oid, 'fsm')) AS fsm_size,
       pg_size_pretty(pg_relation_size(c.oid, 'vm'))  AS vm_size,
       pg_size_pretty(pg_total_relation_size(c.oid))  AS total_size
FROM pg_class c
WHERE c.relname = 't_disk_demo';
text
  oid  | relfilenode |     filepath      | main_size | fsm_size | vm_size | total_size
-------+-------------+-------------------+-----------+----------+---------+-----------
 16410 |       16410 | base/16384/16410  | 440 kB    | 24 kB    | 8192 bytes | 1.8 MB
(1 row)
bash
# 3. 到磁盘上核对(会看到 main/fsm/vm 三个文件,以及被 TOAST 拆出去的大字段)
ls -lh "$PGDATA/base/16384/16410"*
text
-rw-------  1 postgres postgres 440K 16410
-rw-------  1 postgres postgres  24K 16410_fsm
-rw-------  1 postgres postgres 8.0K 16410_vm

total_size 明显大于 main_size,差额来自 TOAST 表——note 字段被压缩并搬到了独立的 TOAST 表里,它有自己的 relfilenode 与自己的一套磁盘文件。

sql
-- 4. 找到这张表对应的 TOAST 表,它也是普通的 pg_class 记录
SELECT c.relname AS toast_table,
       pg_relation_filepath(c.oid) AS toast_filepath,
       pg_size_pretty(pg_relation_size(c.oid)) AS toast_size
FROM pg_class t
JOIN pg_class c ON c.oid = t.reltoastrelid
WHERE t.relname = 't_disk_demo';
text
    toast_table     |    toast_filepath     | toast_size
--------------------+-----------------------+------------
 pg_toast_16410     | base/16384/16411      | 1344 kB
 pg_toast_16410_idx | base/16384/16412      |
(2 rows)

示例 2:观察段文件切分 ​

目标:制造一张超过 1GB 的表,看磁盘上出现 .1、.2 段文件。

sql
-- 写入约 1.2GB 数据(需保证盘上有足够空间,建议在测试库执行)
CREATE TABLE t_seg_demo (id int, payload text);
INSERT INTO t_seg_demo
SELECT g, repeat('x', 1000)
FROM generate_series(1, 1200000) AS g;

SELECT pg_size_pretty(pg_relation_size('t_seg_demo')) AS table_size,
       pg_relation_filepath('t_seg_demo') AS filepath;
text
 table_size |    filepath
------------+-----------------
 1256 MB    | base/16384/16420
(1 row)
bash
# ── 1GB 处切分:主文件 + .1 段文件 ──
ls -lh "$PGDATA/base/16384/16420"*
text
-rw-------  1 postgres postgres 1.0G 16420     # 0 ~ 1073741823
-rw-------  1 postgres postgres 232M 16420.1   # 溢出的部分
-rw-------  1 postgres postgres  24K 16420_fsm
-rw-------  1 postgres postgres 8.0K 16420_vm
sql
-- 段文件数量也可由「文件大小 / 1GB」推算,但索引与 TOAST 不计入 pg_relation_size
SELECT (pg_relation_size('t_seg_demo') / 1024 / 1024 / 1024)::int + 1 AS approx_segments;

示例 3:核对表空间的实际落盘位置 ​

表空间是唯一能让数据文件离开 PGDATA 的机制,pg_tblspc/ 里存的只是符号链接。

sql
-- 1. 查看表空间清单与真实路径
SELECT spcname, oid, pg_tablespace_location(oid) AS location FROM pg_tablespace;
text
   spcname    |  oid  |        location
--------------+-------+-------------------------
 pg_default   |  1663 |                         -- 空表示就在 PGDATA/base 下
 pg_global    |  1664 |                         -- 空表示就在 PGDATA/global 下
 ts_fast      | 16430 | /data/pg_tablespaces/ts_fast
(3 rows)
sql
-- 2. 把一张表挪进新表空间(会重写整表,relfilenode 改变)
ALTER TABLE t_seg_demo SET TABLESPACE ts_fast;

SELECT pg_relation_filepath('t_seg_demo') AS new_filepath, relfilenode
FROM pg_class WHERE relname = 't_seg_demo';
text
                  new_filepath                  | relfilenode
------------------------------------------------+-------------
 pg_tblspc/16430/PG_18_202506071/16384/16435   |       16435
(1 row)
bash
# 3. pg_tblspc/16430 只是一个软链接;版本号子目录用于隔离不同大版本的物理格式
ls -l "$PGDATA/pg_tblspc/"
text
lrwxrwxrwx 1 postgres postgres 30 pg_tblspc/16430 -> /data/pg_tablespaces/ts_fast

注意路径中的 PG_18_202506071 是目录版本号(Catalog Version)目录:不同大版本共享同一个表空间物理目录时,各自的版本子目录互不干扰,避免降级/并存时的物理格式冲突。

示例 4:定位「磁盘上有文件但表里查不到」 ​

这是运维中很常见的一类问题,通常有三个原因。

sql
-- 原因 A:对象属于另一个数据库(base/<oid> 与库名对不上)
SELECT d.datname, c.relname, c.relfilenode
FROM pg_database d
JOIN pg_class c ON true       -- 示意:实际需连到目标库中查询
WHERE false;

-- 原因 B:这是被删除但尚未清理的「孤儿文件」,通常是崩溃后遗留下的
--    PG 在重启时会扫描并尝试归并/删除它们,日志里会出现:
--    WARNING:  could not remove file "base/16384/16410": ...
--    或 LOG:  removing orphaned temporary file ...
bash
# 原因 C:这是临时文件或未提交的段文件,属于正常现象
ls -1 "$PGDATA/base/16384/" | grep -E '^pgsql_tmp' | head

排查顺序建议:先按文件名查 pg_class.relfilenode,没有结果再看日志中的 orphan 提示,最后才考虑数据损坏。pg_relation_filepath() 是这一步最可靠的工具。

注意事项 ​

  • PGDATA 里的文件不能手工增删改:pg_control、pg_filenode.map、pg_wal/ 的任何手工干预都可能导致实例无法启动。
  • PGDATA 本身不能是符号链接:initdb 会拒绝在软链接目录上初始化;但 pg_wal、pg_tblspc/<oid> 可以是软链接(这是把 WAL 与数据分离到不同磁盘的官方手段)。
  • relfilenode 不等于 oid 时不要当成异常:TRUNCATE、VACUUM FULL、CLUSTER、REINDEX 都会改变它,这是设计行为。
  • pg_relation_size() 只算 main 分叉:不包含 _fsm、_vm、TOAST 与索引;要算全量请用 pg_total_relation_size()。
  • pg_relation_filepath() 对无存储对象返回 NULL:视图、外部表、分区父表的 relfilenode 为 0,属正常现象。
  • 段文件大小 1GB 与 WAL 段 16MB 是两回事:前者是数据文件切分粒度(编译期 --with-segmentsize),后者是 WAL 的粒度(--with-wal-segsize),初始化后都不可改。
  • pg_xact/ 膨胀是 XID 环绕的前兆:它不会自动收缩,只有 VACUUM 冻结事务后老段才能回收。
  • 复制槽目录会锁住 WAL:pg_replslot/ 中失效的槽会让 pg_wal/ 无限增长,是磁盘打满的头号原因。
  • archive_status/ 中堆积 .ready 文件说明归档失败:此时 pg_wal/ 无法回收,需立刻检查归档命令与 pg_stat_archiver。
  • 不要用 cp -r 或 tar 备份正在运行的 PGDATA:必须用 pg_basebackup 或文件系统快照,否则拿到的是撕裂的物理状态。
bash
# 不推荐:手工删除「看起来没用」的 WAL 文件
# rm -f "$PGDATA/pg_wal/00000001000000000000000A"   # 不推荐,会导致崩溃恢复失败
sql
-- 不推荐:把 relfilenode 当作稳定的对象标识符持久化到业务表里
-- SELECT relfilenode FROM pg_class WHERE ...;   -- 不推荐,重建后即失效
-- 推荐:业务系统只应保存 oid 或直接保存表名,物理文件路径属于内部实现

最佳实践 ​

  • pg_wal 放到独立磁盘或独立卷:WAL 是顺序写且对延迟敏感,与随机读写的数据文件分离能显著降低提交延迟。
  • pg_tblspc 按 IO 特性分层:热表放 SSD 表空间,归档类大表放机械盘表空间,用 ALTER TABLE ... SET TABLESPACE 在线迁移。
  • 用 pg_relation_filepath() 定位问题表:磁盘告警时,按文件大小排序后反查 pg_class.relfilenode,能快速找出膨胀的表与索引。
  • 定期统计 pg_class 与磁盘实际占用的差异:差异过大通常意味着存在大量已删除对象残留或未回收的 TOAST 文件。
  • 监控三个目录的增长趋势:pg_wal/(归档/复制槽是否正常)、pg_xact/(VACUUM 是否跟上)、pg_multixact/(长事务是否过多)。
  • 备份必须覆盖整个 PGDATA 加上表空间目录:pg_basebackup 会自动处理,手工备份方案则必须额外纳入 pg_tblspc 指向的外部路径。
  • data_checksums 在 initdb 时就要决定:开启后可通过 pg_checksums 检测静默损坏,事后开启需要停机重写全部数据文件。
  • 文件系统选择 XFS 或 ext4:避免使用对 fsync 语义做手脚的文件系统;不要在 NFS 上跑 PGDATA(可靠性无法保证)。
  • 给 PGDATA 留足 inode:一张大表会拆成多个段文件,宽表 + 大量索引会快速消耗 inode。
  • 升级大版本时不要复制 base/ 目录:必须走 pg_upgrade 或逻辑复制,物理格式不跨大版本兼容。

下一节

继续学习:查询执行流程

参考链接 ​