news 2026/10/21 23:44:32

PostgreSQL物理存储结构全解析:从数据目录到页面与元组

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PostgreSQL物理存储结构全解析:从数据目录到页面与元组

很多朋友第一次打开 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_lsn8最近一次修改该页的 WAL 记录位置
pd_checksum2数据校验和(若启用了 data checksums)
pd_flags2页面级标志位,例如是否有空闲行指针
pd_lower2行指针数组的尾部偏移量
pd_upper2元组区的起始偏移量
pd_special2Special Space 的起始偏移量
pd_pagesize_version2页面布局版本号
pd_prune_xid4页内最早需要被清理的活跃事务 ID
pd_xid_base4页内元组事务 ID 的基值(PG14+)
pd_multi_base4页内 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_xmin4创建该版本的事务 ID
t_xmax4删除/锁定该版本的事务 ID,0 表示未被删除
t_field34命令 ID(cmin/cmax)与空闲事务 ID 的组合
t_ctid6指向自身或更新后新版本的物理位置(页号,行号)
t_infomask22属性数量、HOT 相关标志
t_infomask2行级标志:有无 NULL、有无变长字段、xmin/xmax 是否已提交等
t_hoff1元组头总长度,便于解析数据起始位置

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 的物理存储结构并不复杂,复杂度大多来自没搞清目录、文件、页面和元组这一条链路。把它理顺了,很多"玄学问题"其实都在规则之内。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 23:43:48

InSAR三维形变+机器学习参数反演+Massflow:滑坡模拟预测全流程技术解析

这个项目前后我折腾了大半年&#xff0c;从InSAR时序解算到参数反演再到Massflow模拟全流程跑通&#xff0c;中间踩了不少坑。起初只是想把形变监测结果用起来&#xff0c;后来发现单看形变场和真正预测滑坡风险之间隔着一条“参数鸿沟”——你得把毫米级的位移观测翻译成力学模…

作者头像 李华
网站建设 2026/10/21 23:43:12

三字母项目命名实战:rea的工程化设计与落地规范

项目标题: "rea"这个标题乍一看非常简短&#xff0c;甚至有点模糊——只有三个字母&#xff0c;没有上下文、没有修饰、没有标点。但正因如此&#xff0c;它反而像一块试金石&#xff1a;在当下信息过载的环境中&#xff0c;一个极简符号能否被快速识别、准确理解、稳…

作者头像 李华
网站建设 2026/10/21 23:42:26

遗传算法与粒子群优化极限学习机预测模型对比实现解析

做预测模型的朋友应该都遇到过这种情况&#xff1a;ELM&#xff08;极限学习机&#xff09;用起来是真快&#xff0c;训练一个模型几乎是一眨眼的功夫&#xff0c;但预测结果却像开盲盒&#xff0c;同一份数据&#xff0c;换一次随机种子&#xff0c;精度就变一个样。这问题说白…

作者头像 李华
网站建设 2026/10/21 23:41:39

网络路由入门:从路由表到OSPF动态路由实战

1. 路由到底在干什么&#xff1a;从一次跨城寄快递说起很多人第一次接触“网络路由”这个词&#xff0c;脑子里浮现的是机房、机柜、一堆闪着绿灯的黑盒子。其实你每天都在用路由&#xff0c;只是没意识到。你点一份外卖&#xff0c;骑手从商家到你家&#xff0c;中间要经过好几…

作者头像 李华
网站建设 2026/10/21 23:39:26

知识工作插件体系:打造高效采集-加工-输出-记忆闭环

我从一个很日常的困惑开始讲这件事&#xff1a;我的电脑里同时装着笔记软件、PDF阅读器、稍后读工具、AI助手客户端、日程管理软件&#xff0c;浏览器里挂着十多个扩展。每天早上我的工作方式是这样的——先打开邮箱看有没有新资料&#xff0c;然后切到笔记软件把昨天随手存的一…

作者头像 李华
网站建设 2026/10/21 23:39:24

2026年DDoS攻防:智能化攻击与体系化防护实战解析

2026年&#xff0c;DDoS攻防这个话题正在以肉眼可见的速度发生变化。如果你还停留在“找台高防IP、买点带宽清洗、被打了就切换”的思路上&#xff0c;那接下来这两年一定会被现实反复教育。攻击端的智能化和防护端的体系化&#xff0c;正在把这场对抗从“拼带宽”推入“拼决策…

作者头像 李华