恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
PostgreSQL物理存储结构全解析:从数据目录到页面与元组
首页
资讯中心
/
PostgreSQL物理存储结构全解析:从数据目录到页面与元组
PostgreSQL物理存储结构全解析:从数据目录到页面与元组
发布时间:2026/10/21 23:44:41
很多朋友第一次打开 PostgreSQL 的数据目录都会愣一下base、global、pg_wal 一堆文件夹想找自己建的那张 orders 表却根本看不到 orders.db 这样的文件。这个困惑我解释过太多次了。实际上 PostgreSQL 把表、索引、序列、物化视图统一抽象为关系relation每个关系通过数字标识映射到数据目录下的裸文件文件内部再按 8KB 页面组织数据。这套 physical storage structure 表面上不起眼但一旦看懂了后面理解 WAL、vacuum、表膨胀、备份恢复都会顺很多。这篇就按从目录到文件、从页面到元组的顺序把 PostgreSQL 的物理存储结构完整过一遍适合正在学 PG 原理、或者被膨胀和清理问题折磨过的开发与运维同学。1. 一张表到底躺在哪个文件里relfilenode 与文件路径映射1.1 为什么数据目录里找不到表名.dbPostgreSQL 的设计哲学里对象标识OID是贯穿逻辑层和物理层的关键线索。建表时系统给表分配一个 OID同时在 pg_class 系统目录里记录一行元数据其中relfilenode字段决定了这个表在磁盘上的文件名。大多数情况下新建表的 relfilenode 与 OID 相等但执行过VACUUM FULL、CLUSTER或者TRUNCATE之后表会被重写relfilenode 会变成一个全新的值而 OID 不变。这也是为什么你在文件系统里看到的数字和pg_class.oid对不上时不用惊讶。要查一个表的真实文件路径最直接的办法是这条 SQLSELECT 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文件记录主版本号例如 17pg_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;输出中oid16384的数据库对应的就是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。FSMFree Space Map记录的是页面的空闲空间分布。PostgreSQL 插入新元组时不是随便挑一个页面硬塞它会查 FSM 找到合适空闲空间的目标页面然后尝试放进去。FSM 本身也是按页面组织的每个堆页对应一个字节表示空闲空间档位整体形成多层树状结构查询开销很小。这个文件不是建表时就存在的通常第一次VACUUM或需要空间搜索时才会创建。所以你在备份清单里看到某些表有_fsm后缀文件、某些还没有是正常现象。3.3 VM 文件加速可见性判断的布告栏VMVisibility Map用比特位标记某个页面的所有元组是否对任何活跃事务都可见。如果一页全可见VACUUM就可以跳过它INDEX ONLY SCAN也可以少做回表验证可见性的步骤。一个 bit 就能让一个 8KB 页面的可见性判断从逐行扫描变成 O(1) 查询性价比极高。3.4 临时文件不算正式资产base/数据库OID/pgsql_tmp/目录下会生成排序、哈希连接、CREATE INDEX 等操作产生的临时文件。因为它们是运行期存在进程退出或崩溃后可能残留需要人工留意清理。官方没有自动兜底机制所以在长期运行的实例上偶尔检查一下这个目录的大小是值得的。4. 8KB 页面的内部结构从页头到行指针的文档柜模型4.1 为什么偏偏是 8KBPostgreSQL 的数据库页默认大小是 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 checksumspd_flags2页面级标志位例如是否有空闲行指针pd_lower2行指针数组的尾部偏移量pd_upper2元组区的起始偏移量pd_special2Special Space 的起始偏移量pd_pagesize_version2页面布局版本号pd_prune_xid4页内最早需要被清理的活跃事务 IDpd_xid_base4页内元组事务 ID 的基值PG14pd_multi_base4页内 multixact 事务 ID 的基值PG14pd_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创建该版本的事务 IDt_xmax4删除/锁定该版本的事务 ID0 表示未被删除t_field34命令 IDcmin/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是插入这个版本的事务 IDxmax是删除或锁定的事务 IDctid是当前版本所在位置。通过这些列你可以直接观察到一行数据经历过多少次更新新版本插入后旧版本的t_ctid会指向新版本的物理位置形成一条版本链。5.3 TOAST超长行数据的外部仓库堆表元组不能超过页大小所以 PostgreSQL 对超大字段做了一套拆出去存的机制叫 TOASTThe 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 更新带来的优化HOTHeap Only Tuple是 PostgreSQL 针对索引键没变的更新做的重要优化。如果更新前后的索引字段都一样新元组又恰好能放到旧元组所在页面那么 PostgreSQL 不会为每个索引单独新增索引项而只是在新元组旁做一个 HOT 重定向。这样索引扫描可以先跳到旧元组位置再顺着链接找到新元组。HOT 更新的前提比较苛刻所有索引键都不变且页内有空间放新版本。如果经常出现 HOT 更新失败就说明索引建得多或者页面空闲空间不足需要适当调整填充因子fillfactor让每个页面刻意留出一部分空间给 HOT 更新。6.3 DELETE 留下的坑死元组与膨胀DELETE 在物理上同样不会立即移除元组它只是设置t_xmax并让该版本对之后的事务不可见。于是被删除的空间和更新留下的旧版本会一直占用数据文件直到VACUUM来回收。为什么膨胀难处理因为VACUUM默认只是标记页面里的空间为可复用文件本身不会缩小只有VACUUM FULL会把整个表重写一遍生成新的 relfilenode把死元组彻底丢掉。所以一个常见的运维现象是跑了VACUUMpg_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_flags2的项它们就是膨胀的直接证据。7.4 实操中能解决什么问题实际排查时pageinspect最大的价值是帮你验证理论是否落地想确认 autovacuum 有没有真正清理死元组直接看页内lp_flags2的比例。想确认 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 的物理存储结构并不复杂复杂度大多来自没搞清目录、文件、页面和元组这一条链路。把它理顺了很多玄学问题其实都在规则之内。