教程·阅读约 4 分钟·
PostgreSQL B-Tree 索引深度解析:从原理到 pageinspect 实操

PostgreSQL B-Tree 索引深度解析:从原理到 pageinspect 实操

从 B-Tree 数据结构讲起,用 pageinspect 插件直接查看 PostgreSQL 索引的内部结构,理解索引如何工作、如何调优。适合想真正搞懂索引原理的开发者。

原文来源:Medium — Understanding B-Tree Indexes in PostgreSQL: A Comprehensive Guide – Part 1 — 深入解析 PostgreSQL B-Tree 索引的工作原理和内部实现。

大部分开发者都知道「加索引能加速查询」,但索引到底是怎么工作的?为什么 B-Tree 索引能加速几乎所有类型的查询?为什么有时候加了索引反而更慢?

这篇文章不讲空话,直接打开 PostgreSQL 索引的「黑箱」,用 pageinspect 插件一层层看进去。

PostgreSQL 有哪几种索引?

先来个全景。PostgreSQL 提供了 6 种索引算法,各有适用场景:

索引类型适用场景特点
B-tree通用场景:等值查询、范围查询、排序默认索引类型,支持 > < = BETWEEN IN LIKE(非前缀)
Hash等值查询(=无法排序或范围查询
GiST全文搜索、几何数据、模糊搜索可扩展,支持自定义数据类型
SP-GiST分区搜索(网络地址、四叉树)内存效率高于 GiST
GIN倒排索引(数组、JSONB、全文检索)适合组合值类型的搜索
BRIN超大表、天然有序数据(时间戳等)极省空间,但需要数据物理上接近有序

如果不指定类型,PostgreSQL 默认创建的就是 B-tree。它是最通用的选择,没有之一。

—— 广告 ——

B-Tree 的逻辑结构

B-Tree 的全称是 Balanced Tree(平衡树)。它的核心设计是:

从根节点到任意叶子节点,路径长度完全相同。

听起来简单,但这意味着无论你要找的数据在树的哪个分支,查找时间都是稳定的 O(log N)。对于数据库这个「高频读写」场景来说,这一点极其重要。

B-Tree 的关键性质

  • 每个节点可以有多个子节点(不像二叉树只能有两个),子节点数量范围是 2 到 M(M 为树的阶)
  • 节点内按排序顺序组织:小值在左、大值在右
  • 所有叶子节点在同一层,没有空子树
  • 插入/删除后自动平衡,无需手动维护

这意味着即使表里有 1 亿行数据,B-Tree 的高度通常也只有 3-4 层。每次查找只需要 3-4 次节点访问,相比全表扫描的 1 亿次,差距是数量级的。

与全表扫描的复杂度对比

操作全表扫描B-Tree 索引
查询(最坏情况)O(N)O(log N)
范围查询O(N)O(log N + K)
排序O(N log N)O(N)(索引已有序)

PostgreSQL 的 B-Tree 实现基于 Lehman & Yao 算法(1981),这个算法被证明在并发环境下也能保持一致性。

PostgreSQL 的数据存储方式

在深入索引之前,得先搞清楚 PostgreSQL 是怎么存数据的。

表与文件

每个 PostgreSQL 表对应一个物理文件,文件名就是表的 OID(对象标识符)。对于小于 1GB 的表,只有一个文件;更大的表会切割成 1GB 的段。

用这个查询可以找到表对应的物理文件:

code
SELECT pg_relation_filepath('你的表名');
-- 输出类似: base/16384/16389

页面结构

PostgreSQL 把每个文件分成固定大小的页面(默认 8KB)。每个页面从两头向中间增长:

  • 从上往下:页面头 + Item 指针数组(指向每个行的偏移量)
  • 从下往上:实际的行数据(Tuple)
  • 特殊区域:页面最底部,索引结构专用(普通表不用的)

行的物理位置由 ctid 标识,格式是 (block_number, offset)。比如 (0, 1) 表示第 0 个块的第 1 个行。

这个 ctid 是 B-Tree 索引和表数据之间的桥梁:B-Tree 的叶子节点不存实际数据,只存键值和对应的 ctid。找到 ctid 后,PostgreSQL 直接跳到对应页面取数据。

B-Tree 索引的内部结构

创建一个 B-Tree 索引非常简单:

code
CREATE INDEX idx_client_id ON public.client (id);

PostgreSQL 会在后台创建一个独立的索引文件,包含一棵完整的 B-Tree。

搜索过程演示

假设表中有 id 从 1 到 100 的数据,索引搜索 id=37 的过程:

  1. 根节点:存储了各个分支的边界值,比如 13 和 41。37 > 13,进入右分支
  2. 中间节点:存储了 31 和 41 的边界信息。37 在 31 和 41 之间,进入对应的子节点
  3. 叶子节点:找到 id=37 及其对应的 ctid(比如 (3, 5)——第 3 页的第 5 行)
  4. 取数据:直接跳到第 3 页偏移 5,取出完整行

如果没索引,PostgreSQL 需要从头到尾遍历所有页面,逐一检查每一行。

用 pageinspect 实操查看索引内部

光说不练假把式。PostgreSQL 提供了 pageinspect 插件,可以直接查看索引页面的内部数据。

准备环境

code
-- 创建测试表
CREATE TABLE IF NOT EXISTS public.client (
    id int,
    name varchar
);
 
-- 插入一条数据
INSERT INTO public.client VALUES (1, 'Row #1');
 
-- 创建索引
CREATE INDEX idx_client ON public.client (id);
 
-- 启用 pageinspect
CREATE EXTENSION IF NOT EXISTS pageinspect;

查看索引元信息

code
SELECT * FROM bt_metap('idx_client');

输出显示根页面是 1,且根页面同时也是叶子节点(只有一个数据时,根 = 叶)。

查看页面统计

code
SELECT * FROM bt_page_stats('idx_client', 1);

关键字段:

  • type = L(Leaf,叶子节点)
  • live_items = 1(当前 1 条存活数据)
  • dead_items = 0(没有过期数据)

查看页面内的具体条目

code
SELECT * FROM bt_page_items('idx_client', 1);

输出会显示每个索引条目指向的 ctid,以及键值内容。对于我们的 client 表,可以看到 id=1 的条目指向了具体的数据页面位置。

插入更多数据观察树的变化

code
INSERT INTO public.client VALUES 
  (2, 'Row #2'),
  (3, 'Row #3'),
  (5, 'Row #5'),
  (7, 'Row #7');

重新查看 bt_metap,当数据量增长到跨页面时,PostgreSQL 会自动分裂页面、提升树的高度。你会看到 fastroot 指向一个新的根页面,树从单层变成多层。

索引维护的最佳实践

清理膨胀的索引

索引在频繁的更新和删除操作后会产生碎片。Periodically 重建索引可以回收空间:

code
REINDEX INDEX idx_client;
-- 或者重建表的所有索引
REINDEX TABLE public.client;

对于 PostgreSQL 12+,推荐用 REINDEX CONCURRENTLY(不会锁表):

code
REINDEX INDEX CONCURRENTLY idx_client;

监控索引使用情况

code
SELECT
    schemaname,
    tablename,
    indexname,
    idx_scan,          -- 索引被扫描的次数
    idx_tup_read,      -- 通过索引读取的行数
    idx_tup_fetch      -- 实际取回的行数
FROM pg_stat_user_indexes
ORDER BY idx_scan DESC;

如果某个索引的 idx_scan 接近 0,说明它几乎没被使用,可以考虑删掉。

复合索引的列顺序

复合索引 (a, b)(b, a) 不是一回事。规则是:等值条件放前面,范围条件放后面

code
-- 适用于 WHERE a = 1 AND b > 10
CREATE INDEX idx_a_b ON t (a, b);
 
-- 适用于 WHERE b = 10 AND a > 1  
CREATE INDEX idx_b_a ON t (b, a);

部分索引

如果你只关心某个条件下的查询,可以用部分索引节省大量空间:

code
-- 只索引活跃用户,不索引已删除的
CREATE INDEX idx_active_users ON users (email) WHERE status = 'active';

这个索引只有 status = 'active' 的数据,体积小、维护成本低。

常见的索引误区

误区 1:索引越多越好

错。每个索引在 INSERTUPDATEDELETE 时都要同步维护。写密集的表上过多的索引会导致写入大幅降低。一个表 5 个索引通常就够了,超过 10 个要慎重。

误区 2:加索引一定能加速查询

如果查询要返回表中大部分数据(比如超过 5-10%),PostgreSQL 优化器会直接选择全表扫描,跳过索引。因为顺序读比随机读快得多。用小表、或者选择性差的列,索引反而拖后腿。

误区 3:LIKE '%xxx' 可以用 B-Tree 索引

B-Tree 只能加速 LIKE 'xxx%'(左前缀匹配)。LIKE '%xxx'LIKE '%xxx%' 无法利用 B-Tree 索引。这种情况需要 pg_trgm 扩展或者换搜索引擎。

误区 4:索引能自动维护

索引不会自动回收空间。频繁更新的表,索引会越来越膨胀。建议设置定期维护窗口(比如每周末低峰期)执行 REINDEX

总结

B-Tree 索引的原理可以用一句话概括:用有序的树结构,把全表扫描的 O(N) 降低到 O(log N)。看似简单,但这个平衡树的设计经过了 40 年的工程验证,至今仍是数据库索引的主心骨。

理解索引的原理不是为了应付面试,而是为了在生产中做出更好的决策:

  • 加索引前先确认选择性够不够高
  • 复合索引的列顺序要对
  • 定期监控和重建索引
  • 不要为了「万全」堆满索引

下一篇可以聊聊 GIN 和 GiST——当你需要全文搜索或者 JSONB 查询时,为什么 B-Tree 就不够用了。

分享到
微博Twitter

© 2026 四月

原文链接:https://www.aprilzz.com/tutorials/postgres-btree-index