把 B-tree 拆开看
上一节我们从行为上确认了「复合索引的前缀是硬约束」。这一节把索引文件本身打开,看清这个约束从哪来。
索引也是由 8KB 页组成的
Section titled “索引也是由 8KB 页组成的”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 索引页数;索引的第 0 页是元页(meta page),它不存数据,只记录这棵树的入口:
SELECT magic, version, root AS 根页页号, level AS 树高, fastroot
FROM bt_metap('idx_o_user');level = 1 意味着这是一棵两层的树:一个根页 + 若干叶子页。
根页存的是路标
Section titled “根页存的是路标”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')));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;注意根页里的 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/><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
这就是「前缀是硬约束」的物理原因。 索引项在页内、页与页之间,都是按 (第一列, 第二列, ...) 的字节序排列的。给定第一列的值可以在这个有序结构里二分定位;只给定第二列,则匹配的项散布在整棵树的所有叶子页里——上图那条加粗路径根本走不出来,除了全扫没有别的办法。
叶子页与去重
Section titled “叶子页与去重”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);SELECT itemoffset AS 槽位, ctid AS 指向堆表的位置, itemlen AS 项长度, dead
FROM bt_page_items('idx_o_user', 1)
LIMIT 6;这里有个反常的现象:叶子页的平均项大小是 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;
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)";两个观察:
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 列不需要有排序操作符,可以放 point、json 这种没法排序的类型。
部分索引只有完整索引的一半。 status = 'pending' 只占全表 25%,索引却是 50% 大小——因为索引有固定开销(元页、内部页、页内空隙)。行数越多,这个比例越接近真实的选择比例。
页分裂与索引膨胀
Section titled “页分裂与索引膨胀”索引页满了之后插入新键,会发生页分裂:申请一个新页,把原页的项分一半过去,再往父页插入一个新的路标。
页分裂的代价:
- 一次插入变成多次页写入 + 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_user 是 level = 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列不需要有排序操作符,可以放point、json这类根本没法排序、进不了索引键的类型。
常见错误:以为 INCLUDE 的列也能拿来做查询条件或提供排序,于是把该进键的列丢进了 INCLUDE。它不进内部页,不参与树的定位,作用只有「覆盖」——让查询取得到这个值、不必回表。要让某列参与定位或排序,就必须把它放进索引键里。
为什么随机 UUID 做主键会让索引膨胀?有什么替代方案?
因为每次插入都落在随机位置,到处触发页分裂,而分裂后的两个页都只有一半满。
- 顺序主键(
bigserial、UUIDv7、雪花 ID)总是往索引最右边追加,只有最右那个页在分裂,其余页保持满载; - 随机主键(UUIDv4)每个页都在半满状态,索引体积可能是顺序键的两倍,而且写放大严重——一次插入变成多次页写入加 WAL 记录,分裂还可能向上传播。
替代方案是 UUIDv7:前缀是时间戳,保留了顺序性,PG 18 内置了 uuidv7()。不需要全局唯一的话,bigserial 更省。
常见错误:以为「等 VACUUM 跑一跑,膨胀的索引就收回去了」。VACUUM 能清理索引里的死项,但不会合并半空的页——腾出的空间留在原页里,只有落回这一段键值范围的新键才用得上。而随机键的病根恰恰是新键不会重复落回某一段,这些空洞就一直空着。要真正整理只能重建:REINDEX INDEX CONCURRENTLY。