8KB 页里到底放了什么
Postgres 的一切存储——堆表、索引、TOAST——都由固定 8KB 的页(page,也叫 block)组成。这是 I/O、缓存、WAL 的共同单位。
CREATE EXTENSION IF NOT EXISTS pageinspect;
SELECT lsn::text AS 最后修改的WAL位置,
checksum,
lower AS 行指针区末尾,
upper AS 元组区起点,
special AS 特殊区起点,
pagesize,
version
FROM page_header(get_raw_page('orders', 0));一个页的布局是两端向中间生长:
block-beta columns 5 head["页头<br/>24 字节"] lp["行指针数组 →<br/>每项 4 字节"] free["空闲空间"] tuples["← 元组数据<br/>从页尾往前填"] special["特殊区<br/>索引才用"] space:5 o0["0"] o24["24"] lower["lower"] upper["upper"] o8192["8192"] style head fill:#2a6796,color:#fff,stroke:none style lp fill:#4d9bd4,color:#fff,stroke:none style free fill:none,color:#8a8f9c,stroke:#8a8f9c,stroke-dasharray: 4 3 style tuples fill:#d8933a,color:#fff,stroke:none style special fill:#8a8f9c,color:#fff,stroke:none style o0 fill:none,stroke:none,color:#8a8f9c style o24 fill:none,stroke:none,color:#8a8f9c style lower fill:none,stroke:none,color:#8a8f9c style upper fill:none,stroke:none,color:#8a8f9c style o8192 fill:none,stroke:none,color:#8a8f9c
- 行指针数组从页头之后向后生长,每项 4 字节,记录「这一行在页内的偏移和长度」;
- 元组数据从页尾向前生长;
- 中间是空闲空间,
upper - lower就是它的大小。
SELECT lower - 24 AS 行指针占用字节,
(lower - 24) / 4 AS 行指针个数,
upper - lower AS 剩余空闲字节,
8192 - upper AS 元组数据占用字节
FROM page_header(get_raw_page('orders', 0));SELECT count(*) AS 第0页实际元组数 FROM heap_page_items(get_raw_page('orders', 0));元组头的固定开销
Section titled “元组头的固定开销”SELECT lp,
lp_len AS 元组总长度,
t_hoff AS 元组头长度,
lp_len - t_hoff AS 实际数据长度
FROM heap_page_items(get_raw_page('orders', 0))
LIMIT 3;每一行都要额外付 24 字节的元组头,加上页里 4 字节的行指针,固定开销是 28 字节/行。
元组头里装的正是阶段三讲过的东西:t_xmin、t_xmax、t_ctid、两个 infomask、以及空值位图。
列的顺序会影响表的大小
Section titled “列的顺序会影响表的大小”这是一个很少有人注意、但实测差距可观的细节。Postgres 会为每个列按其类型做内存对齐:bigint 要 8 字节对齐,int 要 4 字节,bool 只要 1 字节。对齐要求靠填充空字节满足。
DROP TABLE IF EXISTS align_bad; DROP TABLE IF EXISTS align_good; CREATE TABLE align_bad (a bool, b bigint, c bool, d bigint); CREATE TABLE align_good (b bigint, d bigint, a bool, c bool); INSERT INTO align_bad SELECT true, 1, true, 2 FROM generate_series(1, 10000); INSERT INTO align_good SELECT 1, 2, true, true FROM generate_series(1, 10000);
SELECT pg_size_pretty(pg_relation_size('align_bad')) AS 交替排列,
pg_size_pretty(pg_relation_size('align_good')) AS 宽的排前面;完全相同的数据、相同的列,只是顺序不同,差了 15%。
原因是交替排列时每个 bool 后面都要填 7 个字节才能让下一个 bigint 对齐:
交替: [bool][填7字节][bigint][bool][填7字节][bigint] = 32 字节排序: [bigint][bigint][bool][bool][填6字节] = 24 字节一行能有多大
Section titled “一行能有多大”单个元组不能跨页。所以一行的上限就是一个页面(8KB)减去页头,实际约 8160 字节。
超过怎么办?靠 TOAST 把大字段挪出去,主表里只留指针——下一节的内容。
顺带回答一个常见问题:Postgres 一张表最多多少列? 答案是 1600,但这是理论上限。实际上列多了元组头里的空值位图会变长,而且很难不撞上 8KB 的行宽限制。
先自己回答,再点开对照。
一个 8KB 页的四个区域分别是什么?行指针和元组数据的生长方向为什么相反?
四个区域:页头(24 字节)、行指针数组、空闲空间、元组数据,索引页在页尾还有一个特殊区。
生长方向相反,是因为两边的大小事先都不知道:这一页最终会放几行、每行多长,写入时才知道。行指针数组从页头之后向后长,元组数据从页尾向前长,中间那块空闲空间始终是连续的一整块——判断「还塞不塞得下」只需要一个减法:upper - lower。
如果改成固定分割(比如前 1KB 给行指针),窄表会浪费掉大半行指针区,宽表则会在行指针区先用光,两种情况都是错的。
常见错误:拿 upper - lower 当作「还能插入的最大一行」。插入一行要同时消耗两边——元组数据本身,加上行指针区新增的 4 字节。可用空间其实是 upper - lower - 4。
为什么索引指向「行指针槽位」而不是「页内偏移」?这带来了什么好处?
因为 VACUUM 整理页面时会移动元组来合并碎片。如果索引直接记页内偏移量,元组一挪,所有指向它的索引项就全错了,每次页面整理都要连带更新索引。
有了行指针这一层间接,整理页面只改行指针里记录的偏移,槽位号保持不变,索引一个字节都不用动。LP_REDIRECT 那类 HOT 链剪枝指针也是同一个机制的应用。
常见错误:以为这层间接的作用是「行搬到别的页面时索引也不用改」。不是——ctid 里包含页号,跨页移动索引照样要改(除非走 HOT,靠 t_ctid 链在页内跳转)。行指针的间接性只在页内有效,它解决的是页内碎片整理,不是跨页移动。
每行的固定开销是多少字节?这对「垂直拆表」这类优化意味着什么?
28 字节:24 字节的元组头(t_xmin、t_xmax、t_ctid、两个 infomask、空值位图)加上页里 4 字节的行指针。
这意味着垂直拆表经常适得其反:固定开销是按表按行付的,拆成 3 张表就要为同一条逻辑记录付 3 份 28 字节。一张 (id int, flag bool) 的窄表,有效数据 5 字节、开销 28 字节——85% 的空间在存元数据。省下的列宽往往还不够付新增的元组头,何况还要加上 JOIN 的代价。
只有一种情况划算:拆出去的列确实很大且很少被访问。
常见错误:算账时只记得 24 字节的元组头,忘了页里还有 4 字节的行指针。差的这 4 字节在窄表上就是 15% 以上的误差——正好足以让一个「算下来能省」的拆表方案变成净亏。
为什么调换列顺序能省 15% 空间?给出建表时的排列规则。
Postgres 按列的类型做内存对齐:bigint 要 8 字节对齐,int 要 4 字节,bool 只要 1 字节。对齐要求靠填充空字节满足,而填充的位置取决于列的物理顺序。
交替: [bool][填7字节][bigint][bool][填7字节][bigint] = 32 字节排序: [bigint][bigint][bool][bool][填6字节] = 24 字节规则:按字段宽度从大到小排列。 bigint/timestamptz/float8(8 字节)→ int/float4/date(4 字节)→ smallint(2)→ bool/char(1)→ 变长类型(text/jsonb/numeric)放最后。零成本、零风险。
常见错误:以为一个 bool 的代价就是它自己那 1 字节,「几个 bool 而已无所谓」。真正的代价不在 bool 身上,而在它后面那个被迫填 7 字节的 bigint 上——一个 1 字节的列可以造成 8 字节的净开销。
常见错误二:用 32 vs 24 算出「应该省 25%」,然后对实测的 15% 感到困惑。因为省的只是元组数据部分,那 28 字节的固定开销一分不省:(32+28) / (24+28) ≈ 1.15。这也是列顺序优化的天然上限——表越窄,固定开销占比越高,可优化的余地越小。
一行数据的大小上限是多少?超过了会怎样?
单个元组不能跨页,所以上限是一个页面(8KB)减去页头,实际约 8160 字节。
超过了不会报错——Postgres 用 TOAST 把大字段挪到独立的 TOAST 表里,主表那一行只留一个指针。
常见错误:把「一行不能超过 8160 字节」理解成「一行的所有数据加起来不能超过 8KB」,于是担心存不下大文档。受限的是主表里那个元组的大小,被外置的字段在主表里只占一个指针的位置,所以一行的逻辑内容可以远远超过 8KB。真正会撞上这个墙的是不可 TOAST 的定长列太多——几百个 bigint 挤在一起,TOAST 一个都救不了。列数上限 1600 也是同理:那是理论值,实际上先撞到的通常是 8KB 行宽。