1. 整体架构思路:为什么必须先搞清楚物理存储结构
PostgreSQL作为老牌开源关系型数据库,在各类业务系统中被大量使用。很多人聊到PG的优化,开口就是调参数、改配置、上索引,但其实大家都忽略了一个最基础的根基——数据在磁盘上到底是怎么放的。我见过不少同行排查慢查询时,把执行计划翻来覆去地看,最后发现问题的根源是表文件碎成了几百块,又或者是对一个大表的膨胀率完全没有概念。物理存储结构这层东西,表面上和“SQL慢不慢”没有直接关系,实际上它决定了你对整个数据库的所有判断:为什么清空了表文件大小不变,为什么vacuum之后磁盘空间没释放,为什么同样一条SQL在两个环境里的表现差距那么大——这些问题的答案全部藏在存储结构里。
这篇内容我来彻底拆解一下PG的物理存储结构。先说清楚一个最核心的事实:PostgreSQL本质上是一个“进程模型+多缓冲池+文件存储”的组合,表、索引、序列在磁盘上都对应实实在在的操作系统文件。你不需要成为一个文件系统专家,但你必须知道一个表的数据是放在哪个文件里、文件内部的页面是什么组织方式、为什么一个1GB的表会被拆成多个文件段,以及WAL日志在整个物理存储中扮演什么角色。
整篇的阅读重点放在三层:
- 第一层是宏观视角,数据库、表空间、文件之间的映射关系;
- 第二层是微观视角,一个8KB数据页的内部布局和tuple的组织方式;
- 第三层是工程视角,结合pageinspect等工具,用SQL去直接看页面的真实内容,再把常见问题的排查逻辑串起来。
不管你是刚开始接触PG的DBA,还是写过不少业务SQL但没深究过内部机制的开发者,我建议都仔细过一遍。后端开发如果对存储结构有概念,写SQL的时候会本能地考虑数据分布和行大小,设计表的时候也知道需不需要担心单文件上限。做运维的就更不用说了,bloat排查、膨胀治理、备份恢复验证,全部和这层知识强相关。
2. 目录与文件布局:数据落盘的第一现场
2.1 数据目录里的核心成员
PG安装完成后,用initdb初始化出来的那个数据目录(一般叫PGDATA),就是整个集群的“家”。我直接列出其中最关键的几个成员:
- PG_VERSION:纯文本文件,记录主版本号,例如“16”。
- base/:所有数据库的实际数据文件都放在这个目录下,每个数据库一个子目录,目录名就是数据库的OID。
- global/:存放集群级共享对象,比如pg_database、pg_authid这些系统表,还有控制文件pg_control。
- pg_wal/:WAL日志文件目录。PG 10之前叫pg_xlog,旧版本的资料里经常看到这个名字,指的是同一个东西。
- pg_tblspc/:表空间符号链接目录。外部表空间在这里以符号链接的方式登记。
- postgresql.conf、pg_hba.conf:配置文件,不在数据文件存储结构讨论范围内,但真实环境里它们就住在这里。
有一个细节值得注意:base目录里每个数据库子目录的名称,是数据库的OID而不是数据库名字。你想在文件系统层面直接找到某个库的数据文件,光看名字是找不到的,必须先用SQL查OID:
SELECT oid, datname FROM pg_database;查到OID之后,假设它是16384,那么对应目录就是$PGDATA/base/16384/。这个信息在排查问题时很有用,比如你想直接看某个索引文件在磁盘上占多大,或者在文件系统层确认某个大对象文件是否存在,都需要走OID这条线。
2.2 一张表的物理文件现在是“全家桶”
在PG里,一张表对应的物理文件不止一个。我拿一张实际的表来做说明,假设它叫t_user,通过pg_class可以查到它的relfilenode(文件节点编号):
SELECT relname, relfilenode, reltablespace FROM pg_class WHERE relname = 't_user';假设查出来的relfilenode是 42103,那么表的堆文件就是:
$PGDATA/base/16384/42103这个文件存放表的主数据,也就是heap本体。除了它,同一个目录下你还可能看到另外两个伴生文件:
- 42103_fsm:空闲空间映射文件,记录每个页面上有多少剩余空间。
- 42103_vm:可见性映射文件,标记哪些页面上的所有tuple对所有事务都可见。
_fsm和_vm是“辅助文件”,它们的存在和vacuum机制强相关。后面我会细讲,这里先记住:一张表在磁盘上的“全家桶”至少包含三个文件,别只知道那个没后缀的主文件。
不过有个坑要提醒一下:系统视图pg_class里的relfilenode不一定等于你看到的文件名。数据文件在名字上可能被附加了后缀,比如42103.1、42103.2。这涉及segment分段的机制,我专门在下一节说明。
2.3 segment分割与1GB边界
PG的单个文件被设计为最大1GB。当表的大小超过1GB时,系统会自动创建新的段文件,命名规则就是在relfilenode后面加小数点编号。42103这个表如果数据量涨到2.3GB,你会看到:
42103 42103.1 42103.2 42103_fsm 42103_vm每个段文件内部依然是连续的8KB页面,段与段之间在逻辑上是连续的,文件系统层面却是分离的。为什么要这样设计?一个非常务实的理由是兼容性:历史上不少文件系统对超大单文件的支持有限,也为了便于单文件备份和管理。PG没有选择像某些数据库那样把一个大表的所有数据塞进一个超大文件里,而是用这种“分段”的思路做了妥协。
实际运维中,es和vm文件的分身也存在——比如42103_fsm可能对应一个42103_fsm.1。更复杂的场景是当表跨表空间或使用分区表时,物理文件位置会分散在多个表空间目录里。所以不要惯性思维觉得“一个表就一个文件”,遇到某些异常情况,比如文件缺失或大小不一致,最好先在文件系统层把这个表的完整文件列表拉出来看。
另外,表空间本身是PG的一个逻辑概念,它对应文件系统上的一个目录。默认表空间是pg_default,映射到base目录对应数据库子目录。你可以通过CREATE TABLESPACE把一个库的某些表放到另一块磁盘上,物理上就是把relfilenode文件放到那个目录下。做冷备份时要注意别漏掉非默认表空间的符号链接。
3. 数据页内部结构:8KB方块里的信息布局
3.1 页面头部与四个区域
所有表数据和索引数据在磁盘上最终都组织为固定大小的页(page),PG默认是8KB。这个8KB不是随便定的,它和数据库块大小、文件系统块大小、WAL对齐都有关系,兼顾了随机读的粒度与缓冲池的管理效率。除非你在编译时用--with-blocksize改过,否则生产环境清一色8KB。
一个数据页在逻辑上被划分为四个区域:
- Page Header:页头,固定大小。里面保存着页的元信息:页的LSN(最近一次修改对应的WAL位置)、校验和、页内空闲空间的起始偏移量、最后一条记录的位置等。
- Line Pointer数组:也叫ItemIdData数组。这个数组是“目录表”,每个表项指向页内一个实际的tuple。
- Free Space:空闲空间,位于line pointer数组和实际数据之间,是动态变化的。
- Tuple实际数据区:从页尾向前增长,每一行数据以HeapTupleHeaderData开头,后跟用户字段数据。
这个结构的核心设计思想是:指针数组从页头往下长,数据从页尾往上长,中间是空闲区。插入一行数据时,系统先在空闲区尾部为它分配空间,再从指针数组里拿一个新的槽位指向它。由此可见,页面的空间利用率由两个方向共同决定。长期insert/update/delete之后,页内容会碎裂,这就是膨胀的微观来源。
3.2 Tuple头里那些一眼带过的字段
每个tuple都不是光秃秃的数据,它前面带头部信息,其中几个字段直接关系到可见性判断和MVCC机制的实现。看这张表:
| 字段 | 含义 | 作用 |
|---|---|---|
| t_xmin | 插入该tuple的事务ID | 标记该行的诞生事务 |
| t_xmax | 删除/锁定该tuple的事务ID | 0表示未被删除/锁定 |
| t_ctid | 该tuple或新版本的物理位置 | 指向自身,或更新后指向新版本 |
| t_infomask | 标志位集合 | 表示事务提交状态、HEAP_UPDATED等 |
| t_data | 实际数据起始位置 | NULL位图与字段数据 |
理解t_xmin和t_xmax是理解MVCC的钥匙。在PG中,update不是“原地修改”,而是插入一个全新版本的tuple,旧版本通过t_ctid链接到新版本,同时旧版本被标记为失效。t_xmax记录了“那个把我变成旧版本的事务ID”。
我拿一个简单的update来做说明,假设事务ID为101的事务更新了某行:
- 原tuple的t_xmax从0变成101,表示101号事务让它失效了;
- 新插入的tuple的t_xmin=101,t_ctid指向自己;
- 对于事务ID大于101的后续事务,它们在判断可见性时,会通过t_xmax得知这个旧版本在当前快照下不可见。
这也是为什么PG中一个单纯的UPDATE都可能产生死元组。如果不做vacuum,旧版本会一直躺在页面里。数据页空闲空间耗完后只有两个选择:要么扩展新的数据页,要么等vacuum回收。
3.3 空闲空间映射表与可见性映射表
回到前面提到的_fsm和_vm文件。FSM里面记录的是每个数据页的空闲空间水位。当插入一行数据时,PG不会傻傻地顺序扫描所有页找空位,而是通过FSM快速定位“有足够空间容纳新行”的页面。这个映射是高效率插入的关键。
VM记录的是页面的全部tuple是否对所有事务都可见。如果某个页面被标记为全可见,那么索引扫描时就可以跳过对该页面的可见性检查,直接返回数据。更重要意义在于:vacuum工作时,对全可见的页面可以直接跳过,大幅减少扫描工作量。
这两个辅助文件平时不显眼,可一旦它们损坏,日志里会出现类似“unexpected chunk number”或者“fsm page corrupted”的信息,简单粗暴的处理是删除对应文件的_fsm或_vm后缀让其重建。这个操作风险较低,因为FSM和VM本质上是可重建的缓存型结构,删掉后系统在后续操作中会自动重新创建。
4. 实操:用pageinspect直接翻看数据页内部
4.1 先定位目标表和文件
讲这么多理论,不如直接上手看看。环境用的是PG 16。先建一张测试表,插入几行简单数据:
CREATE TABLE t_phys ( id integer, name text ); INSERT INTO t_phys VALUES (1, 'tom'), (2, 'jerry'), (3, 'spike');接下来先确认它的relfilenode:
SELECT relfilenode FROM pg_class WHERE relname = 't_phys';假设结果是42108。同时需要确认文件被放到了哪个目录:hexadecimal OID是16384,所以物理路径是:
$PGDATA/base/16384/42108在操作系统层面可以做一次验证:
ls -l $PGDATA/base/16384/42108*正常情况下能看到42108、42108_fsm、42108_vm。这说明即使你只插了3条数据,PG也已经为这张表创建了辅助文件——FSM和VM并不是等数据量大了才出现的。
4.2 pageinspect带你看到页面全貌
pageinspect是PG自带的扩展,用于检查数据页内容,调试和教学都很实用。
CREATE EXTENSION IF NOT EXISTS pageinspect;先用get_raw_page函数拿到第一个页面的原始字节,然后再用page_header函数解析页面头部:
SELECT * FROM page_header(get_raw_page('t_phys', 0));输出会给出这个页面的关键信息:pd_lsn、pd_checksum、pd_lower、pd_upper、pd_special等。其中pd_lower表示line pointer数组结束的位置,pd_upper表示空闲空间的起始位置,pd_special是特殊空间起始位置(对普通堆表来说和pd_upper通常一样)。通过pd_upper - pd_lower就能算出当前页还有多少空闲字节。
接下来看页内tuple的实际布局:
SELECT lp, lp_off, lp_len, t_xmin, t_xmax, t_ctid, t_infomask, t_data FROM heap_page_items(get_raw_page('t_phys', 0));这一条SQL能看到的东西比想象中多:
- lp是line pointer槽位编号,从1开始;
- lp_off和lp_len是tuple实际存储的偏移和长度;
- t_xmin、t_xmax是tuple头部的关键事务信息;
- t_data会显示数据部分的原始字节。
修改这张表的数据,再查询,你就能看到MVCC在页面层面的真实效果。我插入第4行之后,执行一次update,把id=1的name改成"tom2",再执行一次heap_page_items查询。你会发现页面里多了一个tuple,旧tuple的t_xmax不再是0,t_ctid指向了新tuple的位置。这种真实的变化比任何文档里的MVCC图解都直观。
4.3 把页面大小和文件大小关联起来
确认一下现在表的实际磁盘占用:
SELECT pg_relation_size('t_phys');假设输出是8192,也就是1个页面。这是因为只有几行数据,全部塞在一个8KB页面里。你可以在查询里把所有测试数据删除,然后再看pg_relation_size,它很可能仍然是8192。这个现象就是很多DBA新手困惑的点:我明明删掉了所有数据,表的文件怎么还是这么大?
这正好呼应前面讲的收缩机制:DELETE操作并没有物理移除数据文件中的页面,它只是把tuple标记为删除。释放出来的页面空间可以通过FSM被后续插入复用,但文件本身不会自动缩短。要让文件真正变小,只有两个思路:使用VACUUM FULL或者CLUSTER重写整个表文件。考虑到生产环境的锁影响,做任何收缩操作前都必须评估业务影响。
5. 常用排查:从物理文件信息到问题诊断
5.1 表膨胀诊断与治理
膨胀指的是表实际数据量不大,但物理文件占用的空间远超实际需要。根源就是大量死元组未被及时回收。我以前接手过一个报表库,一张流水表明明只保留近三个月的记录,文件大小却有40GB。查了下统计信息:
SELECT relname, relpages, dead_tuple_count, live_tuple_count FROM pg_stat_all_tables WHERE relname = 't_big_flow';发现死元组占比极高,VACUUM配置为默认参数,触达频率太低。处理过程分三步走:先调低autovacuum_vacuum_scale_factor,再对表做主表VACUUM;如果效果仍不理想,再评估业务低峰期执行VACUUM FULL。
整个过程对你理解物理存储结构的帮助是:你能从文件大小和页面数的变化中看到收缩动作的真实效果,而不仅仅是看一个“压缩完成”的提示。
5.2 表和索引文件体积对不上
另一种常见现象是:业务库的备份文件异常大,但实际逻辑数据量很小。我遇到过多次,排查发现是有大索引长期未重建。索引本身也有膨胀问题,逻辑上同样适用heap文件那一套。对索引执行REINDEX后,磁盘占用肉眼可见地下降。
检查表和索引的实际大小有一个通用方法:
SELECT relname, pg_size_pretty(pg_relation_size(relid)) AS relation_size, pg_size_pretty(pg_total_relation_size(relid)) AS total_size FROM pg_stat_user_tables ORDER BY pg_total_relation_size(relid) DESC;pg_relation_size只统计表的堆文件,pg_total_relation_size把表、索引、FSM、VM都算进来。两者差距大,基本就说明索引占大头,需要重点检查是否有废弃索引或者严重膨胀的索引。
5.3 WAL日志文件和数据文件在存储上的协作
WAL机制有一个基本认知点需要反复强调:数据文件的写入不一定是同步的,真正保证持久性的关键是WAL。理解这一点有助于你在操作系统层面检查磁盘空间时,分辨出哪些是数据文件、哪些是WAL日志文件、哪些归档文件又占了多少空间。数据文件可以因为故障而丢失,但WAL正确备份后数据库仍可恢复,这种架构决定了归档策略的优先级。
我个人的习惯是:文件系统监控最小粒度必须区分pg_wal目录和base目录。如果只看整个PGDATA目录的占用率,你无法判断是不是归档堆积把磁盘撑满了。WAL目录增长异常时,第一件事就是查看pg_wal的当前WAL段数和归档进程状态。这种思路完全依赖于你对“数据文件在哪、日志文件在哪”的清晰认知。
5.4 文件损坏时的处理思路
最后一个我常被问到的问题是:数据文件损坏了怎么办?举一个我处理过的case,某测试环境的表出现“could not read block 0 in file base/16384/28731: read only 0 of 8192 bytes”的报错。这种情况多半是文件被外部误删或物理存储层有过一次不彻底的恢复。
处理顺序是:
- 先尝试从最近的备份恢复该文件;
- 如果没有备份,使用zero_damaged_pages可以跳过损坏页面继续启动,但这只在页面损坏极端轻微时有意义,否则会丢数据;
- 如果损坏发生在索引文件上,直接drop掉索引重建,常常是代价最低的方案。
这里必需强调:任何直接操作系统文件的“修复”都需要在完全了解后果的前提下执行。物理文件损坏在PG中不算频发,但一旦发生就是高风险事件。提前知道哪些损坏可以靠重建解决(索引),哪些必须靠备份恢复(堆表),能帮你争取不少时间。
6. 最后补充一点个人经验
从物理存储结构的角度去排查问题时,我发现自己最大的收获不是某个具体命令,而是切换了分析视角——从“SQL是什么”变成“数据落盘是什么状态”。很多曾经觉得“奇怪”的现象,放到存储结构里去看就顺理成章了。比如为什么vacuum了文件还不缩小,为什么空表也占用8KB空间,为什么一条update导致页面的LSN频繁变化,这些都是物理存储结构决定的必然结果。
建议你找一个非生产环境,建一张带几万行的表,先记录pg_relation_size的大小,然后分批删除数据,观察fsm文件的变化,再通过pageinspect看页面的复用情况。这种“手动实操一遍”的经验比读十篇文章都管用。等哪天真有业务告诉你“磁盘快满了但数据量没怎么涨”,你至少能拍胸脯说,我心里清楚问题最可能出在哪一层。