前两回我们聊了内存(桌布)和进程(老父亲),今天咱们聊点更扎实的:磁盘上的数据长啥样?
当你往表里插了一行数据,它在磁盘文件里是怎么排队的?它是不是就像在 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_lower 和 pd_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 时,它的逻辑非常风骚:
- 检查空间 :
pd_upper - pd_lower够不够大?不够就报错。 - 分配行指针 :在
pd_lower处增加一个新的ItemId。 - 分配数据区 :在
pd_upper处减去size,腾出空间。 - 复制数据 :把你的 Tuple
memcpy到这一块新开辟的区域。 - 更新指针 :把
ItemId指向这个新区域。
这就是为什么 Page 结构能像个弹簧一样,随用随取,随删随缩。
总结:
Postgres 的 Page 结构告诉我们一个道理:空间管理就是一场关于"两头挤"的艺术。
- 页头管大局。
- 指针管门面。
- 数据管实在。
- 中间留白管未来。
当你理解了 Page 结构,你就能明白为什么 VACUUM 那么重要,为什么数据更新会变慢(因为 Page 满了要分裂),以及为什么数据库文件总是 8KB、16KB 地增长。
下回预告 :
数据已经躺在 Page 里了,但如果两个进程同时要改这行数据怎么办?或者我改了一半突然断电了怎么办?
下一篇,我们将进入 PG 内核最烧脑的部分------MVCC(多版本并发控制)。带你看看那些数据行是怎么"分身"的。