跳转到内容

把 B-tree 拆开看

准备中…

上一节我们从行为上确认了「复合索引的前缀是硬约束」。这一节把索引文件本身打开,看清这个约束从哪来。

CREATE EXTENSION IF NOT EXISTS pageinspect;
CREATE INDEX IF NOT EXISTS idx_o_user ON orders(user_id);
ANALYZE orders;
SELECT pg_size_pretty(pg_relation_size('orders'))    AS 表大小,
     pg_size_pretty(pg_relation_size('idx_o_user')) AS 索引大小,
     pg_relation_size('idx_o_user') / 8192          AS 索引页数;
⌘/Ctrl + Enter

索引的第 0 页是元页(meta page),它不存数据,只记录这棵树的入口:

SELECT magic, version, root AS 根页页号, level AS 树高, fastroot
FROM bt_metap('idx_o_user');
⌘/Ctrl + Enter

level = 1 意味着这是一棵两层的树:一个根页 + 若干叶子页。

SELECT blkno, type AS 页类型, live_items AS 项数,
     avg_item_size AS 平均项大小, free_size AS 剩余空间, btpo_level AS 层级
FROM bt_page_stats('idx_o_user', (SELECT root FROM bt_metap('idx_o_user')));
⌘/Ctrl + Enter
SELECT itemoffset AS 槽位, ctid AS 指向的页, itemlen, data AS 键值的原始字节
FROM bt_page_items('idx_o_user', (SELECT root FROM bt_metap('idx_o_user')))
LIMIT 6;
⌘/Ctrl + Enter

注意根页里的 ctid——它的格式是 (页号, 偏移),但这里指向的是索引内部的页号,不是堆表的位置。第一项的 data 是空的:最左边那个分支不需要下界。

data 是键值的小端字节序表示。58 00 00 00 就是 0x58 = 88,af 00 00 00 是 0xaf = 175。也就是说这个根页在说:

< 88 → 去 1 号页
88 ~ 174 → 去 2 号页
175 ~ 261 → 去 4 号页
...

整棵树长这样,查 user_id = 100 的路径是加粗那条:

flowchart TD
  meta["元页 · blk 0<br/>root=3 · level=1"]
  root["根页 · blk 3<br/>&lt;88 → 页1 | 88~174 → 页2 | 175~261 → 页4 | …"]
  l1["叶子页 1<br/>user_id 1..87"]
  l2["叶子页 2<br/>user_id 88..174"]
  l4["叶子页 4<br/>user_id 175..261"]
  heap[("堆表<br/>按 ctid 回表取行")]

  meta --> root
  root --> l1
  root ==>|"100 落在这一段"| l2
  root --> l4
  l2 ==>|"ctid (16,1)"| heap

  style meta fill:#8a8f9c,color:#fff,stroke:none
  style root fill:#2a6796,color:#fff,stroke:none
  style l1 fill:#4d9bd4,color:#fff,stroke:none
  style l2 fill:#d8933a,color:#fff,stroke:none
  style l4 fill:#4d9bd4,color:#fff,stroke:none
  style heap fill:#3b8f6d,color:#fff,stroke:none

这就是「前缀是硬约束」的物理原因。 索引项在页内、页与页之间,都是按 (第一列, 第二列, ...) 的字节序排列的。给定第一列的值可以在这个有序结构里二分定位;只给定第二列,则匹配的项散布在整棵树的所有叶子页里——上图那条加粗路径根本走不出来,除了全扫没有别的办法。

SELECT blkno, type AS 页类型, live_items AS 项数,
     avg_item_size AS 平均项大小, free_size AS 剩余空间, btpo_level AS 层级
FROM bt_page_stats('idx_o_user', 1);
⌘/Ctrl + Enter
SELECT itemoffset AS 槽位, ctid AS 指向堆表的位置, itemlen AS 项长度, dead
FROM bt_page_items('idx_o_user', 1)
LIMIT 6;
⌘/Ctrl + Enter

这里有个反常的现象:叶子页的平均项大小是 79 字节,而根页只有 15 字节。一个 int 键 + 一个 ctid 应该只要十几个字节才对。

原因是 PG 13 引入的 B-tree 去重(deduplication)。user_id 有大量重复值——每个用户平均 10 笔订单。去重之前,同一个 user_id = 42 要存 10 个独立索引项,每项都重复存一遍键值:

去重前: [42 → (16,1)] [42 → (16,8)] [42 → (17,3)] ... 每项 16 字节
去重后: [42 → (16,1), (16,8), (17,3), ...] 一项 80 字节

去重后把同一个键的所有 TID 打包成一个 posting list,键值只存一次。项数变少了、单项变大了,总空间显著下降

复合索引、INCLUDE 与部分索引的体积

Section titled “复合索引、INCLUDE 与部分索引的体积”
CREATE INDEX IF NOT EXISTS idx_o_ui ON orders(user_id) INCLUDE (amount);
CREATE INDEX IF NOT EXISTS idx_o_ua ON orders(user_id, amount);
CREATE INDEX IF NOT EXISTS idx_o_all_created ON orders(created_at);
CREATE INDEX IF NOT EXISTS idx_o_pending ON orders(created_at) WHERE status = 'pending';
ANALYZE orders;
⌘/Ctrl + Enter
SELECT pg_size_pretty(pg_relation_size('idx_o_user'))        AS "单列 (user_id)",
     pg_size_pretty(pg_relation_size('idx_o_ua'))          AS "复合 (user_id, amount)",
     pg_size_pretty(pg_relation_size('idx_o_ui'))          AS "INCLUDE (amount)",
     pg_size_pretty(pg_relation_size('idx_o_all_created')) AS "完整 (created_at)",
     pg_size_pretty(pg_relation_size('idx_o_pending'))     AS "部分 (仅 pending)";
⌘/Ctrl + Enter

两个观察:

INCLUDE 和复合索引一样大。 很多人以为 INCLUDE 更省——它只是把额外的列放在叶子页而不进入内部页,省的是内部页的空间,而内部页只占整棵树的很小一部分。

INCLUDE 的真正价值不是体积,而是语义

-- 这个能保证 (user_id) 唯一,同时让 amount 参与覆盖
CREATE UNIQUE INDEX ON t (user_id) INCLUDE (amount);
-- 这个保证的是 (user_id, amount) 唯一 —— 完全不同的约束
CREATE UNIQUE INDEX ON t (user_id, amount);

而且 INCLUDE不需要有排序操作符,可以放 pointjson 这种没法排序的类型。

部分索引只有完整索引的一半。 status = 'pending' 只占全表 25%,索引却是 50% 大小——因为索引有固定开销(元页、内部页、页内空隙)。行数越多,这个比例越接近真实的选择比例。

索引页满了之后插入新键,会发生页分裂:申请一个新页,把原页的项分一半过去,再往父页插入一个新的路标。

页分裂的代价:

  • 一次插入变成多次页写入 + WAL 记录;
  • 分裂后两个页都只有一半满,索引整体的空间利用率下降
  • 如果父页也满了,分裂会向上传播,极端情况下树会长高一层。

fillfactor 就是为此而设——B-tree 索引默认 90,给后续插入留 10% 空隙。

索引膨胀了怎么办?VACUUM 能清理索引里的死项,但不会合并半空的页。要真正整理只能重建:

REINDEX INDEX CONCURRENTLY idx_name; -- PG 12+,不阻塞读写

先自己回答,再点开对照。

一棵能索引上千万行的 B-tree 大概有几层?这对「索引查找的实际成本」意味着什么?

3 层。 一个 8KB 页面能放几百个索引项,扇出极大:1 层几百行,2 层几万行,3 层上千万行,4 层数十亿行。几乎所有生产环境的 B-tree 都不超过 4 层。

意味着定位一行大约就是 3~4 次页面访问,而且上层页几乎必然常驻缓存。本节演示的 idx_o_userlevel = 1,也就是一个根页加一批叶子页的两层树。

常见错误:把「索引查找是 O(log n)」直接套用成「数据量翻倍,查找成本明显上升」。这里 log 的底数是几百,不是 2。行数从一千万涨到一亿,树高只从 3 变成 4——多一次页面访问而已。所以「表太大了所以索引查询慢」几乎总是伪命题:真实成本压在命中行数和回表上,不在树高上。这也是上一节那条选择率阈值真正的分量所在。

从索引的物理结构解释:为什么 (a, b) 索引加速不了 WHERE b = ?

因为索引项在页内、在页与页之间,都是按 (第一列, 第二列, ...) 的字节序排列的

本节 dump 出来的根页就是证据:它存的是「88 以下 → 1 号页,88174 → 2 号页,175261 → 4 号页」这样一串按第一列切分的路标。查 user_id = 100 时,从根页比一次就知道该往 2 号页下降。换成只给第二列的条件,这一步无从下手——匹配的项散布在所有叶子页里,那条从根往下的路径根本走不出来。

常见错误:以为「b 毕竟也在索引里,叶子页之间还有链表,B-tree 至少能顺着扫过去帮我找」。顺着扫确实可行,但那等于把整个索引读一遍,已经不叫索引查找了;而且叶子页内部是先按 a 再按 b 排的,b 在全局根本无序,扫的过程中一个都不能提前跳过。B-tree 提供的能力是定位,只给第二列时定位能力为零,剩下的只有遍历。

B-tree 去重解决了什么问题?它对唯一索引有效吗?

解决重复键值被反复存储的问题。orders.user_id 上每个用户平均 10 笔订单,去重之前 user_id = 42 要占 10 个独立索引项,每项都完整重复存一遍键值。PG 13 引入的去重把同一个键的所有 TID 打包成一个 posting list,键值只存一次。

对唯一索引无效——没有重复值可打包。也不会有开销,它只是不发生。

常见错误一:看到本节的实测数字——叶子页平均项大小 79 字节,根页只有 15 字节——得出「叶子页更臃肿」的结论。方向正好反了:79 字节是一整个 posting list(一个键值加十来个 TID)打包后的大小,摊到每个 TID 上远小于去重前每项 16 字节。开启去重之后,avg_item_size 不再是判断索引胖瘦的指标,要看的是 pg_relation_size

常见错误二:由此认为「PG 13 之后低基数列就可以放心建索引了」。去重解决的是索引体积,不解决选择率。上一节里 status 上的索引就好端端在那,优化器照样走 Seq Scan——索引小了不代表它会被用。

INCLUDE 相比把列直接加进索引键,真正的优势是什么?

语义,不是体积。

体积上两者实测一样大:INCLUDE 只是把额外的列放在叶子页而不进入内部页,而内部页只占整棵树很小一部分,省下的那点可以忽略。

真正的两个优势:

  • 唯一约束的范围不同CREATE UNIQUE INDEX ON t (user_id) INCLUDE (amount) 约束的是 user_id 唯一;写成 (user_id, amount) 约束的是这个组合唯一——完全不同的两件事。
  • INCLUDE 列不需要有排序操作符,可以放 pointjson 这类根本没法排序、进不了索引键的类型。

常见错误:以为 INCLUDE 的列也能拿来做查询条件或提供排序,于是把该进键的列丢进了 INCLUDE。它不进内部页,不参与树的定位,作用只有「覆盖」——让查询取得到这个值、不必回表。要让某列参与定位或排序,就必须把它放进索引键里。

为什么随机 UUID 做主键会让索引膨胀?有什么替代方案?

因为每次插入都落在随机位置,到处触发页分裂,而分裂后的两个页都只有一半满

  • 顺序主键(bigserial、UUIDv7、雪花 ID)总是往索引最右边追加,只有最右那个页在分裂,其余页保持满载;
  • 随机主键(UUIDv4)每个页都在半满状态,索引体积可能是顺序键的两倍,而且写放大严重——一次插入变成多次页写入加 WAL 记录,分裂还可能向上传播。

替代方案是 UUIDv7:前缀是时间戳,保留了顺序性,PG 18 内置了 uuidv7()。不需要全局唯一的话,bigserial 更省。

常见错误:以为「等 VACUUM 跑一跑,膨胀的索引就收回去了」。VACUUM 能清理索引里的死项,但不会合并半空的页——腾出的空间留在原页里,只有落回这一段键值范围的新键才用得上。而随机键的病根恰恰是新键不会重复落回某一段,这些空洞就一直空着。要真正整理只能重建:REINDEX INDEX CONCURRENTLY