资讯动态

PostgreSQL物理存储结构拆解:数据页、文件布局与MVCC实战

发布时间:2026/10/19 13:25:45 来源:尧图企业网站定制
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查OIDSELECT 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 页面头部与四个区域所有表数据和索引数据在磁盘上最终都组织为固定大小的页pagePG默认是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的事务ID0表示未被删除/锁定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_xmin101t_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);接下来先确认它的relfilenodeSELECT 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把id1的name改成tom2再执行一次heap_page_items查询。你会发现页面里多了一个tuple旧tuple的t_xmax不再是0t_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看页面的复用情况。这种“手动实操一遍”的经验比读十篇文章都管用。等哪天真有业务告诉你“磁盘快满了但数据量没怎么涨”你至少能拍胸脯说我心里清楚问题最可能出在哪一层。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑