跳转到内容

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));
⌘/Ctrl + Enter

一个页的布局是两端向中间生长

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));
⌘/Ctrl + Enter
SELECT count(*) AS 第0页实际元组数 FROM heap_page_items(get_raw_page('orders', 0));
⌘/Ctrl + Enter
SELECT lp,
     lp_len AS 元组总长度,
     t_hoff AS 元组头长度,
     lp_len - t_hoff AS 实际数据长度
FROM heap_page_items(get_raw_page('orders', 0))
LIMIT 3;
⌘/Ctrl + Enter

每一行都要额外付 24 字节的元组头,加上页里 4 字节的行指针,固定开销是 28 字节/行

元组头里装的正是阶段三讲过的东西:t_xmint_xmaxt_ctid、两个 infomask、以及空值位图。

这是一个很少有人注意、但实测差距可观的细节。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);
⌘/Ctrl + Enter
SELECT pg_size_pretty(pg_relation_size('align_bad'))  AS 交替排列,
     pg_size_pretty(pg_relation_size('align_good')) AS 宽的排前面;
⌘/Ctrl + Enter

完全相同的数据、相同的列,只是顺序不同,差了 15%。

原因是交替排列时每个 bool 后面都要填 7 个字节才能让下一个 bigint 对齐:

交替: [bool][填7字节][bigint][bool][填7字节][bigint] = 32 字节
排序: [bigint][bigint][bool][bool][填6字节] = 24 字节

单个元组不能跨页。所以一行的上限就是一个页面(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 链在页内跳转)。行指针的间接性只在页内有效,它解决的是页内碎片整理,不是跨页移动。

详见 HOT 更新与 fillfactor

每行的固定开销是多少字节?这对「垂直拆表」这类优化意味着什么?

28 字节:24 字节的元组头(t_xmint_xmaxt_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 行宽。

详见 TOAST:大字段去哪了