第 01 章
存储与数据模型
这是全篇主线的第一站:先看 PostgreSQL 物理上怎么存一行,后面的 MVCC、VACUUM、索引、规划器全挂在这一层之上。本章建立的物理地基只有四块——堆表、Page、tuple、TOAST——但「写从不原地改、一行可以有多个版本」这条主线的所有底层载体,都在这四块里。
把一张 PostgreSQL 表想成磁盘上一个文件,文件被切成等长的块,块里塞着一行行数据——这个朴素画面对了一半,也错了一半。对的是「文件 + 等长块」;错的是「一行行数据」——PG 里一行从来不是一条固定记录,而是一串可以不断增长的版本。本章把这个画面校准到位:理解了一行在物理上长什么样、放在哪、为什么能有多份,第 2 章的 MVCC 就只是「给这些版本加上可见性判定」而已。
本章你将建立的 schema
- 堆表(heap)是无序的行集合——表在磁盘上是个堆文件,行的物理顺序与插入顺序、与任何键都无关。
- 一行 = 一个 tuple,带隐藏系统列——除了用户定义的列,每个 tuple 还携带
xmin / xmax / cmin / cmax / ctid这几个看不见的系统列。 - Page 是 8KB 的读写与缓存单位——磁盘 I/O 和
shared_buffers缓存都以 8KB 的 Page(block)为最小颗粒,不存在「只读半个 Page」。 - 大字段被 TOAST 旁置——一行装不进 Page 时,大列被压缩、切片,搬进旁边的 TOAST 表,主行只留一个指针。
§1关系模型在 PG 的落地:逻辑表 vs 物理堆
SQL 看到的是有结构的关系,磁盘上躺着的是一个无序的堆文件。
关系模型是给人和查询用的抽象:表(relation)是行的集合,集合本身无序。把这个无序性诚实地落到物理层——表就是个堆(heap),新行往哪空就塞哪——换来的是插入极快、且不被任何排序约束绑死。代价是「读一行得知道它在第几块第几槽」,这个代价由索引和后面的隐藏列来补。理解「逻辑有序 ≠ 物理有序」,是看懂 PG 与 MySQL 分道扬镳的第一刀(详见 第 6 章聚簇索引对比)。
在 SQL 层,一张表是一个 relation:有列名、有类型、有约束,SELECT 出来的结果按你写的 ORDER BY 排,不写就「无序」。这个无序不是偶然——它是关系模型的定义。PG 把这条定义直接搬到磁盘:一张普通表对应一个或多个 堆文件(heap file),文件里的行没有任何强制顺序,新插入的行被放进第一个有足够空闲空间的位置。表里早插入的行,物理上完全可以排在晚插入的行之后。
这跟「表名 → 磁盘文件」的映射也不是一对一。每张表在 pg_class 里有一个 relfilenode,对应数据目录下一个文件;表超过 1GB 会自动切成 relfilenode、relfilenode.1、relfilenode.2 等多个 1GB 段文件。逻辑上是一张表,物理上是一串段。
底层机制(比文档深一层)。 「堆无序」不是 PG 偷懒,而是一个把成本后移的设计抉择。InnoDB 选择让主键决定行的物理位置(聚簇索引),于是按主键范围扫描时数据天然连续、I/O 友好;代价是每次插入都要维护这棵主键 B-tree 的物理有序,且二级索引里存的是主键值、回表要再走一次主键树。PG 的堆把这个抉择倒过来:插入只管找空位、不维护任何全局顺序,所以写入路径短;但任何「按某列顺序取数据」的需求,都得靠独立的索引结构去映射「键 → 物理位置」。这个物理位置的名字,就是下一节要讲的 Page 与 ctid。换句话说,PG 把「有序」从存储层剥离成了一个可选的、由索引提供的能力——这正是为什么 PG 可以对同一张表建任意多个平权的索引,没有哪个索引是「主」的。
堆表像一座「随手上架」的仓库:新到的箱子放进最近的空位,不按品类、不按编号排。找货靠的是一本「索引册」(货号 → 货架坐标)。类比边界:仓库里一个货号通常只对应一个箱子;而 PG 的堆里,同一行(同一逻辑货号)会因 UPDATE 留下多个版本箱子,旧箱子要等清仓(VACUUM)才腾走——这个「一货多箱」是关系仓库类比覆盖不到的,正是 MVCC 的核心,下面三节逐步揭开。
§2Page:8KB 的读写与缓存单位
磁盘 I/O 和缓存的最小颗粒是 8KB 的 Page,行栖身其中、由槽位指针定位。
数据库不能逐字节读写磁盘——太碎、太慢、也无法做缓存管理。固定大小的 Page 给了一个统一的搬运单位:从磁盘读、进 shared_buffers 缓存、写 WAL、刷盘,全都以 Page 为单位对齐。8KB 是吞吐与浪费之间的工程折中(太大则小行查询白读、太小则元数据开销占比高),且是编译期定死的常量。一旦接受「最小单位是 8KB 整页」,很多行为就顺理成章:读一行其实读了它所在的整页、缓存命中率按页统计、一行装不下一页就得想别的办法(TOAST)。
一个 Page(在文档里也叫 block)内部分四段,理解这个布局是后面一切的基础:
- page header(页头,24 字节):放校验和、空闲空间的起止偏移、本页相关的 WAL 位置等元信息,固定在页首。
- line pointer 数组(行指针,又叫 ItemId,每个 4 字节):紧接页头,从前往后增长。每个 line pointer 是一个小结构,记录「这一槽对应的 tuple 在页内的偏移量和长度」。
- tuple 数据:实际的行内容,从页尾往前堆放。
- 中间的 free space:line pointer 数组的尾端与 tuple 数据的头端之间,是空闲区。两端相向生长,吃掉中间的空闲。
- special space(特殊空间):页尾保留区,堆表里通常为空;索引页用它存索引专属数据(如 B-tree 的左右兄弟指针)。
定位一行的物理坐标叫 ctid,是一个二元组 (block number, item offset):前者是这一行在哪个 Page(从 0 开始编号),后者是这一行在该 Page 的 line pointer 数组里的第几槽(从 1 开始)。ctid 不指向 tuple 的字节位置,而是指向那一槽的 line pointer,再由 line pointer 转去真正的 tuple 数据。这层间接是后面 HOT 优化的关键,下面就讲。
建表时还能设 fillfactor(默认 100)。把它调低,比如 fillfactor=90,PG 在初次填充 Page 时只用到 90%,刻意留出 10% 空闲——这块空闲是给同页更新预备的落脚点。
底层机制(比文档深一层)。 两件事必须想透。
其一,为什么一行不能跨 Page。 Page 是 I/O 与缓存的原子单位,PG 的整套读写、加锁、WAL 记录都假设「一个 tuple 完整地待在一个 Page 里」。允许一行横跨两页,意味着读一行要锁两页、崩溃恢复要协调两页的一致性——复杂度爆炸。于是 PG 给「行装不下一页」设了另一条出路:超长的列被搬到旁边去(TOAST,见 §4),让主行无论如何都能塞进一个 Page。这条「行不跨页」的硬约束,正是 TOAST 存在的根本理由——把它记牢,§4 会直接接上。
其二,line pointer 这层间接为什么值钱。 ctid 指向 line pointer 而非 tuple 字节,意味着 tuple 可以在页内挪动(比如 VACUUM 整理碎片、把存活 tuple 往页尾压实腾出连续空闲),只要更新 line pointer 里的偏移量,对外暴露的 ctid 槽号纹丝不动。更关键的是:当一行被 UPDATE 且新版本恰好能塞进同一个 Page、且更新没有改到任何被索引的列时,PG 可以让旧版本的 line pointer 转而「重定向」到新版本的槽——这就是 HOT(Heap-Only Tuple) 更新。它的红利是:索引里的指针仍指向旧 line pointer 槽,由这层间接顺着重定向链找到新版本,于是这次更新完全不用动任何索引。HOT 是 PG 缓解「更新即新增版本」这一开销的核心手段,本章先在物理层埋好它的地基——line pointer 的间接性——具体机制留到 第 2 章 §HOT 展开。
把表的 fillfactor 从默认 100 调成 90,对「更新密集」的表是好是坏?对「只插入、几乎不更新」的表呢?
展开答案
更新密集表:通常更好。 预留的 10% 空闲让新版本更容易落在同一个 Page,从而触发 HOT 更新——免去维护索引的开销、也减少跨页带来的随机 I/O。代价是初始装载多占约 10% 磁盘。
只插入表:通常更差(白白浪费空间)。 没有更新就没有 HOT 红利,预留的空闲永远用不上,纯属让表变大、缓存里能装的行变少。这类表保持 fillfactor=100 更合适。
§3Tuple:一行的物理形态,且可以有多个
一个 tuple = 头部 + null 位图 + 用户数据;同一行在物理上可以同时存在多份。
「行」是逻辑概念,tuple 是它的物理化身。把一行编码成「定长头部 + 紧凑数据」,PG 才能在 Page 里高效定位、读取、判断可见性。而「同一行可以有多个 tuple 版本同时存在」这一点,是整份教程的主线起点——没有它,就没有 MVCC、没有死元组、没有 VACUUM。本章在物理层把这个事实立住:你现在就该把「一行」想成「一串版本」,而不是「一条记录」。
一个堆 tuple 的物理结构,从前到后是三段:
- HeapTupleHeader(堆元组头,固定 23 字节,对齐后通常 24 字节):放下面 §4 要讲的隐藏系统列(
xmin / xmax等)、本 tuple 的字段数、以及若干标志位。 - null bitmap(null 位图,可选):仅当本行存在
NULL值时才出现,每列 1 bit 标记是否为NULL。这样NULL列不必在数据区占空间。 - 用户数据:按列定义顺序紧凑排列的实际字段值(按类型做对齐填充)。
关键认知,本章最重要的一句:同一逻辑行,在物理上可以同时存在多个 tuple 版本。 一次 UPDATE 不会覆盖原 tuple,而是在堆里写入一个新 tuple 作为新版本,旧 tuple 原地保留、只被打上「失效」标记。于是一个 Page 里完全可以并排躺着同一行的 v1 和 v2。
UPDATE 没有覆盖 v1,而是新增 v2、并给 v1 盖上 xmax=205(哪个事务让它失效);待事务 205 提交且再无快照能看到 v1,v1 才成死元组,等 VACUUM 回收。底层机制(比文档深一层)。 为什么是「新增版本」而不是「原地改」?因为 PG 用 tuple 的可见性来实现多版本并发控制:一个更早启动、仍在运行的事务还需要读到 v1(它启动时 v2 还不存在),而一个新事务该读到 v2。两个版本必须同时物理存在,可见性判定(第 2 章)才能各取所需。原地覆盖会立刻销毁旧版本,读就不得不阻塞写、写也得阻塞读——PG 用「多版本共存」换来了读不阻塞写、写不阻塞读。代价直接写在物理层:每次 UPDATE/DELETE 都在堆里留下迟早要清理的旧版本,表会因此「虚胖」,这就是膨胀(bloat)的来源,也是 VACUUM 必须存在的理由。这条「为省并发开销、把清理工作后移给 VACUUM」的因果链,是后面三章反复用到的主轴。
§4隐藏系统列:MVCC 的物理载体
每个 tuple 都带 xmin / xmax / cmin / cmax / ctid 这几个看不见的系统列,它们就是 MVCC 的物理抓手。
「一行有多个版本」只是事实,还需要一套元数据来回答两个问题:这个版本是谁造的、谁废的(决定它对哪些事务可见),以及它的下一个版本在哪(把版本串成链)。隐藏系统列就是这套元数据,被直接编码进每个 tuple 的头部。它们不在 CREATE TABLE 里出现、SELECT * 也不返回,但每一行都背着它们——这是「逻辑列」之外,PG 额外付出的物理成本,也是 MVCC 得以运转的全部凭据。
四个(组)核心隐藏列:
xmin:插入(或最新一次产生)这个 tuple 版本的事务 ID(xid)。「这一版是谁写的。」xmax:删除或锁定这个 tuple 的事务 ID;若该版本仍有效、未被删除,则为0。「这一版被谁废了,没废就是 0。」cmin/cmax:事务内部的命令序号(command id),用于同一事务里多条语句之间的可见性判定。二者在物理上共用同一个字段,靠标志位区分当前表示的是 cmin 还是 cmax——绝大多数场景无须关心。ctid:本 tuple 的物理位置(block, offset)(即 §2 的那个二元组)。当一行被更新,旧版本的ctid还会兼任「指向新版本」的作用,把版本串成链。
这些列平时藏着,但能显式查出来——这是观察 MVCC 最直接的窗口:
底层机制(比文档深一层)。 这几列不是「附加功能」,而是 MVCC 的全部物理凭据,分两路工作。
第一路是可见性:判断一个事务能否看见某个 tuple,本质上是拿这个 tuple 的 xmin/xmax 去和「当前事务的快照」比对——大意是「xmin 对应的事务已提交、且 xmax 要么是 0、要么对应的事务在该快照里尚未提交」时,这一版对当前事务可见。完整规则是第 2 章的主线,本章只需记住:可见性判定读的就是这两列,没有别的魔法。
第二路是版本链:UPDATE 产生新版本后,旧版本头部记录的 ctid 会指向新版本的位置,一行的历史版本由此串成一条链;HOT 更新进一步让这条链留在同一个 Page 内(即 HOT 链)。索引扫描命中旧版本后,正是顺着这条 ctid 链找到当前有效版本。
把这两路合起来:「写产生新版本」靠 xmin/ctid 记录身份与去向,「旧版本何时可清」靠 xmax + 快照判定。 本章主线的物理载体,到这里就齐了——它们全在那 24 字节的 tuple 头里。
到此,主线的物理地基已经铺完:堆提供无序存放、Page 提供 8KB 的搬运单位、多版本 tuple 让一行能并存多份、隐藏列给每份打上「谁造谁废、下一版在哪」的标记。第 2 章只做一件事——给这些标记配上「快照」这把尺子,回答「此刻该看见哪一版」。
§5TOAST:大字段的旁置存储
一行超过约 2KB 时,大字段被压缩并切片存进旁置的 TOAST 表,主行只留一个指针。
§2 立下了硬约束「一行不能跨 Page」,可现实里一个 text 或 jsonb 列轻易就比 8KB 大。TOAST(The Oversized-Attribute Storage Technique,超长属性存储技术)是这个约束的出路:把过大的列拆出去单独存,让主行无论字段多大都能塞进一个 Page。它对查询透明——你照常 SELECT 那个大列,PG 在背后自动把切片拼回来。理解 TOAST,才能解释「为什么改一个大 jsonb 行里的小字段,开销比直觉低」这类现象。
触发逻辑:当一个 tuple 在压缩前超过 TOAST_TUPLE_THRESHOLD(约 2KB,即 8KB Page 的四分之一)时,PG 会挑出其中可 TOAST 的大列,按列的存储策略处理,直到整行缩到阈值以下。被移出的数据进入这张表专属的、自动创建的 TOAST 辅助表(在 pg_toast schema 下),大值在那里被切成约 2KB 的分片、逐片成行存储;主行对应列只留一个指针,记录「去哪张 TOAST 表、取哪个值、原长多少」。
每个可变长列有一个存储策略,用 ALTER TABLE ... ALTER COLUMN ... SET STORAGE 调整:
| 策略 | 压缩 | 旁置(移出主表) | 典型适用 |
|---|---|---|---|
| PLAIN | 否 | 否 | 定长类型(如 int),不参与 TOAST |
| EXTENDED | 是 | 是 | 大多数可变长类型的默认(text/jsonb/bytea) |
| EXTERNAL | 否 | 是 | 只切片不压缩;利于对大值做子串/随机访问 |
| MAIN | 是 | 尽量不 | 优先压缩留在主表,仅在仍超限时才旁置 |
底层机制(比文档深一层)。 两个非显而易见的点。
其一,阈值为什么卡在约 2KB(Page 的 1/4)。 PG 的设计目标是一个 Page 至少能放下 4 个 tuple——若允许单行占满接近整页,一个 Page 就只剩一两行,line pointer 数组、缓存、扫描的效率都崩坏。把单行的「就地」上限压到 8KB 的四分之一,保证了堆 Page 的行密度不至于太低。所以 2KB 不是拍脑袋,而是「每页至少 4 行」反推出来的。
其二,也是最实用的一点:TOAST 让「大行的小更新」比直觉便宜。 一个含巨大 jsonb 列的行,其 jsonb 已被旁置到 TOAST 表。当 UPDATE 只改主行里的某个小列(不碰那个 jsonb)时,PG 在堆里写一个新版本主行——但新版本的 TOAST 指针可以原样指向同一批未改动的 TOAST 分片,那几 KB 的 jsonb 数据完全不被重写。于是更新的实际写入量约等于「主行那几十字节」,而非「整行含 jsonb 的全部体积」。反过来,一旦 UPDATE 改的就是那个大列,TOAST 分片要重新压缩、切片、写一整套新行——开销陡增。结论:对「大列基本不变、只频繁改元数据字段」的宽行表,更新成本远低于按整行体积的估算;这也是把易变字段与大块 jsonb 分列存放(甚至分表)的依据。
一张表只有 id int 和 doc jsonb 两列,doc 普遍 50KB(已 TOAST)。场景 A:每秒给某行的 doc 追加一个键。场景 B:每秒新增一个 last_seen timestamptz 列并更新它(不动 doc)。哪个产生的实际写入字节多得多?
展开答案
场景 A 多得多。 改 doc 意味着这个 50KB 的值要重新压缩、重新切成约 2KB 的分片、在 TOAST 表里写一整套新分片行,外加主表新版本——每次更新写入量是几十 KB 量级。
场景 B 很便宜。 不动 doc,新主行版本的 TOAST 指针原样复用既有分片,50KB 的 doc 一个字节都不重写;实际写入只是主行那几十字节加 WAL。两者写入量差着三个数量级。
(注:无论哪种,旧版本主行都成死元组、等 VACUUM——膨胀照样发生,只是字节量级不同。)
§6动手观察:用隐藏列看一次 UPDATE
把前面四节拧成一个可观察的实验:建表、插一行、看它的隐藏列,再更新它、对照前后变化。下面的输出未在本机执行(xid 取值因实例而异),用于演示该看哪些字段、它们如何变化。
CREATE TABLE accounts (id int PRIMARY KEY, balance numeric);
INSERT INTO accounts (id, balance) VALUES (1, 100.00);
-- 第一次观察
SELECT xmin, xmax, ctid, * FROM accounts WHERE id = 1;
-- xmin | xmax | ctid | id | balance
-- ------+------+-------+----+---------
-- 100 | 0 | (0,1) | 1 | 100.00 <- 未在本机执行
UPDATE accounts SET balance = 80.00 WHERE id = 1;
-- 更新后再观察(同一行,同一 id)
SELECT xmin, xmax, ctid, * FROM accounts WHERE id = 1;
-- xmin | xmax | ctid | id | balance
-- ------+------+-------+----+---------
-- 205 | 0 | (0,2) | 1 | 80.00 <- 未在本机执行
逐行解读,把每个观察值映射回概念:
- xmin 100 → 205
INSERT时这一版的xmin=100(事务 100 创建)。UPDATE后看到的是新版本,它由事务 205 创建,故xmin=205。xmin 变了,因为这是另一个 tuple 版本,不是同一个被改写。 - ctid (0,1) → (0,2)新版本落在同一个 Page(block 0),但占了新的一槽(offset 2)。ctid 变了——物理位置变了,因为新版本是新写入的 tuple。
- xmax 都是 0两次查询看到的「当前有效版本」其
xmax都是 0(没有事务在删它)。但被取代的旧版本(v1)此刻的xmax已被改成205——它不再是「当前版本」,WHERE id=1这条查询的快照已看不到它,所以查询结果里看不到那个被盖戳的旧版本。 - balance 100 → 80用户数据当然变了;要点是它不是原地改的,而是新版本携带了新值,旧版本仍带着
100.00静静躺着,等 VACUUM。
UPDATE 一行后,它的 ctid 会变吗?它的 xmin 会变吗?为什么?
展开答案
两个都「变」——但要点在「为什么」。UPDATE 没有修改原 tuple,而是新写了一个版本。你 UPDATE 后查到的是这个新版本:它在堆里占了新位置(ctid 不同),由执行 UPDATE 的事务创建(xmin 等于该事务的 xid)。旧版本的 ctid 和 xmin 其实没动——它还在原地,只是被盖上了 xmax 失效戳、且对当前快照不可见。所以准确说法是:你「看到的那一行」换成了新版本,于是 ctid/xmin 看起来变了;底层是新增而非修改。
例外补充:若这次更新触发了 HOT(同页、未改索引列),新版本仍在同一 Page、ctid 的 block 号不变(offset 变),且索引无需更新——细节见 第 2 章 §HOT。
自测
- 一张普通 PG 表在磁盘上,行是按主键顺序物理排列的吗?为什么?
ctid的两个分量分别是什么?它指向的是 tuple 的字节地址,还是别的东西?- 一个 tuple 的
xmax = 0代表什么?xmax是一个非零的 xid 又代表什么?
查看参考答案
1. 不是。普通表是堆(heap),行无序——新行放进第一个有足够空闲的位置,物理顺序与主键、与插入顺序都不保证一致。「按某列有序取数据」靠的是独立的索引,不是堆本身。(与 InnoDB 的聚簇索引正相反,见第 6 章。)
2. ctid = (block number, item offset):哪个 8KB Page、该 Page 的 line pointer 数组里第几槽。它指向的是那一槽的 line pointer,再由 line pointer 转去真正的 tuple 数据——这层间接让 tuple 能在页内移动而 ctid 槽号不变(HOT 的基础)。
3. xmax = 0 表示这个 tuple 版本当前有效、未被任何事务删除或标记失效。xmax 是非零 xid,表示「这个版本被该事务删除或更新取代了」——该 xid 提交且再无快照能看到此版本后,它就成死元组、等 VACUUM 回收。
用 pageinspect 亲眼看一个 Page 里的死元组
装上 pageinspect 扩展,对前面那张 accounts 表,在一次 UPDATE 之后、VACUUM 之前,证明:这个 Page 里物理上同时存在两个 tuple 版本,且旧版本带着非零的 xmax。然后跑一次 VACUUM,再看 Page,说明发生了什么变化。
提示
路径:CREATE EXTENSION pageinspect; → SELECT lp, t_xmin, t_xmax, t_ctid FROM heap_page_items(get_raw_page('accounts', 0));。UPDATE 后你会看到 lp(line pointer 槽号)有两行:旧槽的 t_xmax 非零(被更新它的事务盖了戳),新槽的 t_xmax = 0。VACUUM accounts; 之后再查,旧槽要么消失、要么其 lp 被标记为可复用(lp_flags 变化)——死元组被回收,槽位让给后续插入。把「t_xmax 非零 = 已盖失效戳」「VACUUM 后槽位复用」与本章 §3/§4 对上,这条链就闭合了。