Postgres 内核:从入门到“入土” (三) —— Page 结构:数据是如何在磁盘上“躺平”的

前两回我们聊了内存(桌布)和进程(老父亲),今天咱们聊点更扎实的:磁盘上的数据长啥样?

当你往表里插了一行数据,它在磁盘文件里是怎么排队的?它是不是就像在 Excel 里那样整齐划一?不,Postgres 的数据页(Page)更像是一个极其抠门的收纳盒,为了多塞一个字节,它能玩出各种花样。

1. 8KB 的宿命

在 Postgres 里,数据不是随便乱丢的。它被切成了一个个固定大小的块,默认是 8KB。这个大小是编译时定的,通常我们不去动它。
Page 8KB Layout
PageHeaderData 24B
ItemId 1
ItemId 2
ItemId N
--- Free Space ---
Tuple N
Tuple 2
Tuple 1
Special Space - 索引专用

为什么要 8KB?因为这通常是文件系统块大小(Block Size)的倍数,读起来顺手,CPU 处理起来也舒服。

2. 收纳盒的构造:Page 布局

如果你把一个 8KB 的 Page 横切开,你会看到一个非常有趣的现象:它是两头往中间挤的。

  • 页头 (Page Header):最顶层,存着这个 Page 的基本信息(比如这个 Page 满了吗?WAL 日志到哪了?)。
  • 行指针 (Item Pointers / Line Pointers):紧贴着页头往下排。它是数据的"索引",记录了每一行数据在这个 Page 里的偏移量。
  • 空闲空间 (Free Space):中间那块白地儿。这是最关键的。
  • 行数据 (Items / Tuples):从 Page 的最底部开始往上长!

这就像是一个挤地铁的场景:页头是司机,行指针是门口排队的人,而真正的乘客(行数据)是从车厢最后头开始往前坐的。中间空出来的那块地儿,就是留给还没上车的乘客的。

3. 深入源码:那个叫 pd_lowerpd_upper 的指针

打开 src/include/storage/bufpage.h,你会看到这个精妙的结构:

c 复制代码
typedef struct PageHeaderData
{
    /* 各种管理信息... */
    LocationIndex pd_lower;     /* 空闲空间的起始偏移量(行指针的尽头) */
    LocationIndex pd_upper;     /* 空闲空间的结束偏移量(行数据的开头) */
    LocationIndex pd_special;   /* 如果是索引页,这里存特殊信息 */
    /* ... */
} PageHeaderData;
  • pd_lower 就像是一个水位线,随着行指针越来越多,它往下走。
  • pd_upper 就像是底部的天花板,随着数据行越来越多,它往上爬。
  • pd_lower 遇见 pd_upper,这个 Page 就彻底"爆仓"了,得开新 Page 去了。

4. 行指针:别看它小,它很重要

每条数据行在 Page 里都有一个 4 字节的行指针(ItemIdData)。

为什么要搞这层脱了裤子放屁的映射?直接指到偏移量不行吗?
不行! 因为有了行指针,数据库内部在清理 Page(比如把中间删掉的空位挤一挤)时,数据可以在 Page 内部随便搬家,而外部引用只需要记住行指针的序号(Offset Number)就行,不需要跟着变。这叫间接寻址,是数据库高性能的基石。

5. 对齐(Alignment):程序员的强迫症

在 Page 里存数据,PG 有个强迫症:对齐

如果你的数据是 4 字节的,它可能非要占 8 字节的空间。为什么?因为 CPU 读取内存时,对齐的数据能快得飞起。

这就导致了一个著名的现象:表字段定义的顺序不同,占用的磁盘空间竟然不一样!

(提示:把大的字段排在前面,小的排在后面,通常能省出不少 Page 空间。这也是资深 DBA 的面试金句。)

6. 特殊空间:给索引留的小后门

如果是索引页(比如 B-Tree),Page 的最末尾还会有一块 pd_special 空间。那里存的是索引特有的元数据,比如"我的邻居是谁?"(左右指针),方便 B-Tree 在 Page 之间快速横跳。

7. 源码深潜:行指针的位操作魔法

7.1 只有 4 字节的 ItemIdData

你可能会好奇,每个行指针只有 4 字节,它怎么存得下偏移量、长度和状态?

答案是:位域(Bit Fields)

src/include/storage/itemid.h 里:

c 复制代码
typedef struct ItemIdData
{
    unsigned    lp_off:15,      /* 偏移量 (0-32767,刚好能指完 8KB) */
                lp_flags:2,     /* 状态位:UNUSED, NORMAL, REDIRECT, DEAD */
                lp_len:15;      /* 数据长度 (最大也是 32KB) */
} ItemIdData;

这就是为什么单行数据(Tuple)不能超过 8KB 的原因之一(虽然 TOAST 技术解决了这个问题,但那又是另一个故事了)。

这里的 lp_flags 非常关键:

  • LP_REDIRECT (1):这就不仅仅是个指针了,这是个"传送门",通常用于 HOT Update(仅堆内元组更新),告诉你去同一个页面的另一个行指针找真正的版本。
  • LP_DEAD (3):这行已经死透了,Vacuum 下次来的时候可以直接清理。
7.2 PageAddItem:往缝隙里塞数据

当你调用 PageAddItem 时,它的逻辑非常风骚:

  1. 检查空间pd_upper - pd_lower 够不够大?不够就报错。
  2. 分配行指针 :在 pd_lower 处增加一个新的 ItemId
  3. 分配数据区 :在 pd_upper 处减去 size,腾出空间。
  4. 复制数据 :把你的 Tuple memcpy 到这一块新开辟的区域。
  5. 更新指针 :把 ItemId 指向这个新区域。

这就是为什么 Page 结构能像个弹簧一样,随用随取,随删随缩。

总结:

Postgres 的 Page 结构告诉我们一个道理:空间管理就是一场关于"两头挤"的艺术。

  • 页头管大局。
  • 指针管门面。
  • 数据管实在。
  • 中间留白管未来。

当你理解了 Page 结构,你就能明白为什么 VACUUM 那么重要,为什么数据更新会变慢(因为 Page 满了要分裂),以及为什么数据库文件总是 8KB、16KB 地增长。


下回预告

数据已经躺在 Page 里了,但如果两个进程同时要改这行数据怎么办?或者我改了一半突然断电了怎么办?

下一篇,我们将进入 PG 内核最烧脑的部分------MVCC(多版本并发控制)。带你看看那些数据行是怎么"分身"的。

相关推荐
不愿透露姓名的大鹏2 小时前
MySQL Binlog配置优化全攻略
运维·服务器·数据库·mysql·adb
汀、人工智能2 小时前
[特殊字符] 第42课:对称二叉树
数据结构·算法·数据库架构·图论·bfs·对称二叉树
柒.梧.2 小时前
MySQL核心考点:存储引擎区别+视图详解
数据库·mysql·面试
电商API&Tina2 小时前
跨境电商如何接入1688官方寻源通接口?附接入流程
java·数据库·python·sql·oracle·json·php
明月_清风3 小时前
🚀 Flyway 存量数据库迁移:50张表一键导出清洗实战(附完整脚本)
数据库·后端
深邃-3 小时前
【C语言】-数据在内存中的存储(1)
c语言·开发语言·数据结构·c++·算法
孤影过客3 小时前
Linux下的PostgreSQL集群演进指南
linux·运维·postgresql
羊小蜜.3 小时前
Mysql 08: 数据表基本操作——从创建到约束
数据库·mysql·数据表
程序员小郭833 小时前
MySQL分库分表策略全解析(实战版)
数据库·mysql·架构