很多朋友第一次打开 PostgreSQL 的数据目录,都会愣一下:base、global、pg_wal 一堆文件夹,想找自己建的那张 orders 表,却根本看不到 orders.db 这样的文件。这个困惑我解释过太多次了。实际上 PostgreSQL 把表、索引、序列、物化视图统一抽象为"关系(relation)",每个关系通过数字标识映射到数据目录下的裸文件,文件内部再按 8KB 页面组织数据。这套 physical storage structure 表面上不起眼,但一旦看懂了,后面理解 WAL、vacuum、表膨胀、备份恢复都会顺很多。这篇就按从目录到文件、从页面到元组的顺序,把 PostgreSQL 的物理存储结构完整过一遍,适合正在学 PG 原理、或者被膨胀和清理问题折磨过的开发与运维同学。
1. 一张表到底躺在哪个文件里:relfilenode 与文件路径映射
1.1 为什么数据目录里找不到"表名.db"
PostgreSQL 的设计哲学里,对象标识(OID)是贯穿逻辑层和物理层的关键线索。建表时系统给表分配一个 OID,同时在 pg_class 系统目录里记录一行元数据,其中relfilenode字段决定了这个表在磁盘上的文件名。大多数情况下,新建表的 relfilenode 与 OID 相等;但执行过VACUUM FULL、CLUSTER或者TRUNCATE之后,表会被重写,relfilenode 会变成一个全新的值,而 OID 不变。这也是为什么你在文件系统里看到的数字,和pg_class.oid对不上时不用惊讶。
要查一个表的真实文件路径,最直接的办法是这条 SQL:
SELECT pg_relation_filepath('orders');返回的结果形如:
base/16384/16402意思就是:数据库目录 base 下,以数据库 OID 16384 命名的子目录里,文件 16402 就是 orders 表的主文件。因为一个数据库就是一个物理子目录,所以同一数据库里的所有表、索引都堆在这个目录下。
1.2 表、索引、序列、物化视图都是"关系"
很多人容易忽略的是,PostgreSQL 并没有为索引、序列单独设计一套文件存储格式。它们统统是"关系",都在 pg_class 里各占一行,都有自己的 relfilenode,走的是同一套页面存储逻辑。区别只在于页面内部的内容格式:堆表页存的是元组(Heap Tuple),索引页用的是索引元组 + 特殊空间(Special Space)。
所以排查问题时可以统一对待:无论是索引膨胀还是表膨胀,找文件路径的方式都一样。
SELECT relname, pg_relation_filepath(oid) FROM pg_class WHERE relname IN ('orders', 'orders_pkey');1.3 表空间:文件路径的第一层分流
表空间是 PostgreSQL 在文件和逻辑对象之间加的一层抽象。默认有两个内置表空间:pg_default和pg_global。pg_default落到数据目录的base/下,pg_global落到global/下,用于放少数共享的系统目录表(比如 pg_database)。
自定义表空间则是另一种玩法:pg_tblspc/目录下会出现以表空间 OID 命名的符号链接,指向你创建表空间时指定的外部目录,例如/data/pg_tbs1。而在这个外部目录下,又会按数据库 OID 再分一层子目录:
/data/pg_tbs1/16384/16402这个设计和 InnoDB 那种单一表空间文件的做法很不一样。好处是你可以把低频数据放到慢速盘、高频数据放到快速盘,互不干扰。坏处是物理备份时要记得把pg_tblspc的软链接目标一并带齐,否则恢复出来的集群会缺文件。因为pg_basebackup默认只复制数据目录本身,表空间软链接指向的外部目录需要额外处理。
2. 数据目录解剖:base、global、pg_wal 和表空间各自管什么
2.1 顶层目录的职责速查
一个初始化完成的 PostgreSQL 数据目录(通常叫$PGDATA),顶层结构相对固定。这里列一下主要目录和文件的用途:
| 名称 | 类型 | 职责 |
|---|---|---|
| PG_VERSION | 文件 | 记录主版本号,例如 17 |
| pg_control | 文件 | 全局控制文件,包含检查点位置、数据库系统标识等关键状态 |
| postgresql.conf | 文件 | 主配置 |
| postgresql.auto.conf | 文件 | ALTER SYSTEM 写入的配置覆盖项 |
| postmaster.pid | 文件 | 服务进程的 PID、共享内存地址等;存在即代表实例在运行 |
| base/ | 目录 | 默认表空间对应的数据库目录,每个数据库一个子目录 |
| global/ | 目录 | 共享系统目录与全局对象 |
| pg_wal/ | 目录 | WAL 日志段文件,崩溃恢复和复制依赖的数据 |
| pg_xact/ | 目录 | 事务提交状态日志,用来判断事务是否已提交 |
| pg_multixact/ | 目录 | 多事务状态(行锁组合)的日志 |
| pg_subtrans/ | 目录 | 子事务状态日志 |
| pg_tblspc/ | 目录 | 自定义表空间的符号链接 |
| pg_twophase/ | 目录 | 两阶段提交的预备事务状态 |
| pg_notify/ | 目录 | LISTEN/NOTIFY 使用的队列文件 |
| pg_stat/、pg_stat_tmp/ | 目录 | 统计信息持久化与临时统计文件 |
| pgsql_tmp/ | 目录 | 运行期排序/哈希溢出的临时文件 |
pg_control值得单独提一句。实例启动时,postmaster会先读它,拿到最近一次检查点的 WAL 位置和系统标识;如果这个文件损坏或丢失,整个集群都无法启动。日常备机恢复时,偶尔会看到"control file is corrupt"一类的报错,本质上就是它坏了或者拷贝错了版本。
2.2 base 下的数据库目录怎么对应
base/里每个子目录的名字就是一个数据库的 OID。要快速对应,跑一句:
SELECT oid, datname FROM pg_database;输出中oid=16384的数据库,对应的就是base/16384。注意模板库 template0、template1 和 postgres 数据库也都在base/下各有目录。所以在做文件级备份或归档时,不应该凭目录名猜库,而应该先通过系统表把映射关系查清楚。
2.3 pg_wal 为什么值得单独理解
WAL 目录在 PG10 之前叫pg_xlog,后来改名成pg_wal。里面是按顺序编号的 WAL 段文件,每个段默认 16MB,文件名形如:
000000010000000000000001前缀是时间线 ID,后面两部分是日志序号。写事务时,数据页的变更并不会立刻落盘到属于它的那个 relfilenode 文件里;真正先落盘的是 WAL 记录。后台刷新进程和检查点再把脏页逐步写回数据文件。理解这一点,就明白为什么物理备份时一般要求先做pg_basebackup而不是直接拷贝整个数据目录——文件之间存在"WAL 先行"的时序关系,拷贝时机不对就可能得到一份崩溃后无法恢复的数据。
3. 一个关系不止一个文件:段文件、FSM 与可见性映射
3.1 主文件、段文件与 1GB 分片
每个关系至少有一个主文件,文件名就是 relfilenode 的数字。默认单文件上限是 1GB,一旦超过,后续部分会生成编号后缀的段文件:
16402 16402.1 16402.2这个 1GB 是编译期常量,不是配置项。设置这个上限的目的一是绕过某些文件系统对超大文件的限制,二是方便物理备份按文件粒度处理。比如某张 6.4GB 的表,你会看到16402到16402.6共 7 个文件。查询的时候 PostgreSQL 会按段去读,对应用完全透明,但运维在清理或归档单个表文件时要清楚它是分段的。
查看某张表所有段文件的最快方式:
SELECT pg_relation_filepath(oid) FROM pg_class WHERE relname = 'orders';然后用ls -lh看这个路径的目录,把所有以该 relfilenode 开头的文件列出即可。
3.2 FSM 文件:空闲空间的"目录册"
每个关系都可能伴随两个后缀文件:_fsm和_vm。FSM(Free Space Map)记录的是页面的空闲空间分布。PostgreSQL 插入新元组时,不是随便挑一个页面硬塞;它会查 FSM 找到合适空闲空间的目标页面,然后尝试放进去。FSM 本身也是按页面组织的,每个堆页对应一个字节,表示空闲空间档位,整体形成多层树状结构,查询开销很小。
这个文件不是建表时就存在的,通常第一次VACUUM或需要空间搜索时才会创建。所以你在备份清单里看到某些表有_fsm后缀文件、某些还没有,是正常现象。
3.3 VM 文件:加速可见性判断的"布告栏"
VM(Visibility Map)用比特位标记某个页面的所有元组是否对任何活跃事务都可见。如果一页全可见,VACUUM就可以跳过它,INDEX ONLY SCAN也可以少做"回表验证可见性"的步骤。一个 bit 就能让一个 8KB 页面的可见性判断从逐行扫描变成 O(1) 查询,性价比极高。
3.4 临时文件不算正式资产
base/<数据库OID>/pgsql_tmp/目录下会生成排序、哈希连接、CREATE INDEX 等操作产生的临时文件。因为它们是运行期存在,进程退出或崩溃后可能残留,需要人工留意清理。官方没有自动兜底机制,所以在长期运行的实例上,偶尔检查一下这个目录的大小是值得的。
4. 8KB 页面的内部结构:从页头到行指针的"文档柜"模型
4.1 为什么偏偏是 8KB
PostgreSQL 的数据库页默认大小是 8KB,同样是一个编译期常量(BLCKSZ)。这个值是在磁盘 IO 粒度和共享内存利用效率之间取的一个经典折中:太小则每次 IO 读到的数据有限,太大则共享缓冲池能容纳的页数变少,随机读的缓存命中率会下降。对绝大多数 OLTP 场景来说,8KB 是经过长期验证的合理值。
4.2 页内四段式布局
一个 8KB 页的逻辑结构可以理解为:
+------------------+ <- 页起始 | PageHeaderData | 32 字节(PG14+) +------------------+ | ItemIdData 数组 | 每个行指针 4 字节,从页头向后生长 +------------------+ | 空闲空间 | 行指针区和元组区的中间地带 +------------------+ | 元组数据区 | 实际的行数据或索引数据,从页尾向前生长 +------------------+ | Special Space | 索引页使用;堆表页通常为 0 +------------------+ <- 页结束关键在"两端向中间生长"。行指针数组(ItemId 数组)从前往后分配,元组数据从后往前分配。删除元组后中间会留下空洞,后续新插入的元组优先从尾部占用空间,而不是回填空洞。这个机制决定了堆表页内的物理顺序和逻辑顺序没有关系,全表扫描读出的是物理排列,而不是主键排列。
4.3 页头字段逐个说明
PG14 之前的页头是 24 字节,PG14 引入了事务 ID 基值优化之后变成 32 字节。各字段含义如下:
| 字段 | 字节数 | 说明 |
|---|---|---|
| pd_lsn | 8 | 最近一次修改该页的 WAL 记录位置 |
| pd_checksum | 2 | 数据校验和(若启用了 data checksums) |
| pd_flags | 2 | 页面级标志位,例如是否有空闲行指针 |
| pd_lower | 2 | 行指针数组的尾部偏移量 |
| pd_upper | 2 | 元组区的起始偏移量 |
| pd_special | 2 | Special Space 的起始偏移量 |
| pd_pagesize_version | 2 | 页面布局版本号 |
| pd_prune_xid | 4 | 页内最早需要被清理的活跃事务 ID |
| pd_xid_base | 4 | 页内元组事务 ID 的基值(PG14+) |
| pd_multi_base | 4 | 页内 multixact 事务 ID 的基值(PG14+) |
pd_lsn是崩溃恢复里非常重要的定位信息:恢复进程扫描 WAL 时,会依据页面的 LSN 判断这条日志是否已经应用到数据页。pd_upper和pd_lower之间的空间,就是当前页的空闲空间大小,对应的就是 FSM 需要记录的值。
4.4 行指针:4 字节找到一行数据
行指针(ItemId)本身只有 4 字节,里面保存了元组在页内的偏移、长度以及标志位。通过行指针访问元组,是 PostgreSQL 能把"物理位置"和"逻辑内容"解耦的基础。索引扫描时,索引项里存的是ctid,即"页号 + 页内行号"。根据ctid找到页后,再通过行指针跳到元组实际位置,整个过程不需要遍历页内其他元组,这也是回表快速的原因。
5. 一行记录的物化形态:元组头部、隐藏列与 TOAST 策略
5.1 HeapTupleHeader:一行数据前面那 24 字节
每个堆表元组都有一个固定头部,PG14 之后算术长度为 23 字节,经过最大对齐后实际占用 24 字节。关键字段:
| 字段 | 大小 | 作用 |
|---|---|---|
| t_xmin | 4 | 创建该版本的事务 ID |
| t_xmax | 4 | 删除/锁定该版本的事务 ID,0 表示未被删除 |
| t_field3 | 4 | 命令 ID(cmin/cmax)与空闲事务 ID 的组合 |
| t_ctid | 6 | 指向自身或更新后新版本的物理位置(页号,行号) |
| t_infomask2 | 2 | 属性数量、HOT 相关标志 |
| t_infomask | 2 | 行级标志:有无 NULL、有无变长字段、xmin/xmax 是否已提交等 |
| t_hoff | 1 | 元组头总长度,便于解析数据起始位置 |
t_infomask里有一组非常重要的位标志,比如HEAP_XMIN_COMMITTED和HEAP_XMIN_INVALID。事务提交状态不可能每次都去查事务日志,那样太慢;所以当一个事务提交后,hot standby 或随后的访问会把"已提交"这个信息直接刻到元组的t_infomask里,下次再判断可见性时就不用查日志。这也是常规SELECT会修改数据页的原因——它可能只是在页面上更新标志位,于是产生了写 WAL 和脏页。
5.2 隐藏列:每个用户都看不见但真实存在
每个普通堆表都有几个隐藏列,随手就能验证:
SELECT xmin, xmax, cmin, cmax, ctid, * FROM orders;xmin是插入这个版本的事务 ID,xmax是删除或锁定的事务 ID,ctid是当前版本所在位置。通过这些列,你可以直接观察到一行数据经历过多少次更新:新版本插入后,旧版本的t_ctid会指向新版本的物理位置,形成一条版本链。
5.3 TOAST:超长行数据的外部仓库
堆表元组不能超过页大小,所以 PostgreSQL 对超大字段做了一套"拆出去存"的机制,叫 TOAST(The Oversized Attribute Storage Technique)。触发阈值大约是页面可用空间的四分之一,在 PG14 之后约 2040 字节。只要一行数据超过这个范围,系统就会按列策略决定是压缩、外移或者两者都做。
四种策略的区别值得背下来:
| 策略 | 含义 | 适用场景 |
|---|---|---|
| PLAIN | 禁止压缩和行外存储 | int、float 等定长类型 |
| MAIN | 优先压缩到行内,实在不行才外移 | 希望尽量留在主表的场景 |
| EXTERNAL | 允许行外存储,但不允许压缩 | 字段已经是压缩格式,或由应用自行压缩 |
| EXTENDED | 先压缩,再行外存储 | 默认策略,适合大文本、大 bytea |
流程是这样的:插入时发现元组超阈值,先对允许压缩的列做压缩;压缩后如果还超阈值,就把剩余放不下的部分切块放到pg_toast_xxx表里,主表只保留一个指向 toast 的指针。每个 toast chunk 也走同样的 8KB 页面存储,所以一张含大字段的表,在pg_toast_16384这类表名下会看到自己的 relfilenode 文件。
实操中想确认一个字段到底存没存到行外,可以用:
SELECT pg_column_size(remark) FROM orders WHERE id = 1;再对比:
SELECT pg_column_size(oid) FROM pg_class WHERE relname = 'orders';如果实际字段大小远大于页面能容纳的尺寸,说明它大概率已经在 toast 表里了。对性能敏感的大字段查询,后续尽量只SELECT需要的列,不要把几 MB 的文本拖进内存再丢弃。
6. 更新和删除如何改写磁盘:版本链、HOT 与表膨胀的根源
6.1 UPDATE 不是"改",而是"另起一行"
关系型数据库的经典 MVCC 套路,在 PostgreSQL 的实现是:更新一行时,逻辑上删除旧版本、插入新版本。物理上则是旧元组头部t_xmax被写成当前事务 ID,新元组被追加到合适的页面,旧元组的t_ctid指到新元组位置。如果新元组和旧元组在同一个页面,这个过程成本很低;如果不在同一页,索引项也得跟着更新。
6.2 HOT 更新带来的优化
HOT(Heap Only Tuple)是 PostgreSQL 针对"索引键没变"的更新做的重要优化。如果更新前后的索引字段都一样,新元组又恰好能放到旧元组所在页面,那么 PostgreSQL 不会为每个索引单独新增索引项,而只是在新元组旁做一个 HOT 重定向。这样索引扫描可以先跳到旧元组位置,再顺着链接找到新元组。
HOT 更新的前提比较苛刻:所有索引键都不变,且页内有空间放新版本。如果经常出现 HOT 更新失败,就说明索引建得多或者页面空闲空间不足,需要适当调整填充因子fillfactor,让每个页面刻意留出一部分空间给 HOT 更新。
6.3 DELETE 留下的坑:死元组与膨胀
DELETE 在物理上同样不会立即移除元组,它只是设置t_xmax并让该版本对之后的事务不可见。于是被删除的空间和更新留下的旧版本,会一直占用数据文件,直到VACUUM来回收。为什么膨胀难处理?因为VACUUM默认只是标记页面里的空间为"可复用",文件本身不会缩小;只有VACUUM FULL会把整个表重写一遍,生成新的 relfilenode,把死元组彻底丢掉。
所以一个常见的运维现象是:跑了VACUUM,pg_relation_size还是那么大,用户以为没生效。实际上VACUUM的效果体现在"后续插入能不能复用空间"上,而不是文件变小。想做物理收缩,要么接受VACUUM FULL期间的重写和锁开销,要么用pg_repack这类工具在线重建。
6.4 体检:一张表膨胀到什么程度需要处理
比较直观的方法是使用pgstattuple扩展:
CREATE EXTENSION IF NOT EXISTS pgstattuple; SELECT * FROM pgstattuple('orders');它会返回dead_tuple_count、dead_tuple_percent等指标。如果死元组占比长期偏高,同时 autovacuum 又一直来不及跑,就该考虑调整 autovacuum 参数或者对这张表做一次人工维护窗口。
7. 现场实操:pageinspect 把页面拆开看
7.1 准备扩展与数据
理解物理结构最好的方式,是直接看页面内部。pageinspect扩展就是干这个的。先造一张表并插入数据:
CREATE EXTENSION IF NOT EXISTS pageinspect; CREATE TABLE orders (id int primary key, remark text); INSERT INTO orders VALUES (1, 'a'), (2, 'b');7.2 查看页头
SELECT * FROM page_header(get_raw_page('orders', 0));可以看到pd_lower、pd_upper、pd_prune_xid等值。pd_lower位置之后是行指针数组,pd_upper之前是元组数据区;两者之间是空闲空间。如果pd_upper和pd_lower很接近,说明页面快满了。
7.3 查看元组
SELECT lp, lp_off, lp_flags, t_xmin, t_xmax, t_ctid, t_infomask, t_hoff FROM heap_page_items(get_raw_page('orders', 0));输出中的lp_flags是关键:1 表示正常元组,2 表示已删除元组,3 表示 HOT 重定向。t_xmax非零时,这行就是"逻辑上已删除但物理上还在"的死元组。如果你对一个刚执行完大量 UPDATE 的表做这个查询,会看到很多lp_flags=2的项,它们就是膨胀的直接证据。
7.4 实操中能解决什么问题
实际排查时,pageinspect最大的价值是帮你验证"理论是否落地":
- 想确认 autovacuum 有没有真正清理死元组,直接看页内
lp_flags=2的比例。 - 想确认 HOT 是否生效,看元组的
t_infomask的 HOT 标志位。 - 想定位某一行数据在物理文件第几个页面,先用
SELECT ctid FROM orders WHERE ...,再用get_raw_page去对应的页查看。
有一次我在排查一个更新频繁的表,autovacuum 配置看起来很正常,但膨胀率就是下不去。后来用heap_page_items逐页检查,才发现大量旧版本元组的t_ctid都指向了其他页面,说明 HOT 根本没触发。进一步查索引定义,发现表上有十几个索引,每次更新都有索引字段在变,HOT 条件被彻底堵死。最后通过调整填充因子和删减冗余索引才解决。
这种场景,光看pg_stat_user_tables.n_dead_tup只能知道"脏了",看不到"为什么脏"。而pageinspect能让你直接看到页面内部的空间分布和元组状态,属于排障工具箱里不可替代的一层。
最后再分享一个小习惯:每次在做大版本升级或者迁移之前,我一般会抽查几张大表的 RelFileNode 信息、记录一下pg_relation_filepath和pg_tblspc的软链接指向,再结合pgstattuple跑一遍膨胀率记录。真出了问题,能快速判断是逻辑数据问题还是物理文件问题。毕竟 PostgreSQL 的物理存储结构并不复杂,复杂度大多来自没搞清目录、文件、页面和元组这一条链路。把它理顺了,很多"玄学问题"其实都在规则之内。