B-Tree 索引原理
在 PostgreSQL 里,只要你说「建个索引」而没指定类型,建出来的就是 B-Tree(B 树)索引——它是数据库世界最通用、最古老的索引结构,也是唯一能同时支撑等值查询、范围查询、排序输出和唯一性约束的索引类型。
本节从 B-Tree 的树形结构讲起,说明 PostgreSQL 用的是哪种变体、它到底支持哪些操作符、为什么 LIKE 'abc%' 能走索引而 LIKE '%abc' 不能,最后把三种索引扫描方式(索引扫描、位图扫描、仅索引扫描)的边界划清楚。学完这一节,你就有能力判断「一条 SQL 到底能不能用上 B-Tree 索引」。
前置知识
阅读本节前,建议先了解:PRIMARY KEY 主键
基础概念
为什么需要索引:从顺序扫描说起
没有索引时,PostgreSQL 只能做顺序扫描(Sequential Scan):把整张表的每一个数据页都读一遍,逐行判断是否满足条件。
SELECT * FROM users WHERE email = 'a@b.com';
顺序扫描:
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│page 0│ │page 1│ │page 2│ │page 3│ │ ... │ 每一页都读
└──────┘ └──────┘ └──────┘ └──────┘ └──────┘
↓ 逐行比较 email = 'a@b.com',命中一行也要读完所有页顺序扫描的代价是 O(表大小)。1000 万行的表,哪怕只查一行,也要读完整个表。索引的价值就是把「逐行比较」变成「按结构定位」,把代价降到 O(log N)。
B-Tree 的树形结构
B-Tree 是一棵多路平衡树(multi-way balanced tree)。它由三类页组成:
| 页类型 | 英文 | 作用 |
|---|---|---|
| 根页 | root page | 树的入口,只有 1 个(除非索引被并发构建成多个根) |
| 内部页 | internal / non-leaf page | 只存「分隔键 + 子页指针」,不存行指针 |
| 叶子页 | leaf page | 存「键 + 行指针(TID,指向堆表某行)」 |
| 元页 | metapage | 索引的第 0 页,存索引的元信息(版本、根页位置、fastroot 等) |
┌─────────────┐
root │ 50 │ 120 │ ← 内部页:只有键
└──┬───┴──┬───┘
┌──────────────┘ └──────────────┐
┌─────┴─────┐ ┌──────┴────┐
│ 20 │ 35 │ ← 内部页 │ 150 │ 200 │
└──┬───┬────┘ └──┬────┴───┘
┌─────┘ └─────┐ ┌─────┘ └─────┐
┌───┴───┐ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐
│5 12 18│ │22 30 │ │40 47 │ ... │130 145│ │160 175│ │210 240│
└───────┘ └───────┘ └───────┘ └───────┘ └───────┘ └───────┘
叶子页(leaf):存键 + 指向堆表行的 TID,且页内、页间按键有序三条关键性质:
- 所有叶子页在同一层,所以从根到任意叶子的路径长度完全相同——这就是「平衡」的含义,保证了最坏情况下的查找次数一致。
- 叶子页之间用双向链表串起来,且全局按键值有序。这使「范围扫描」能顺序读叶子页,而不必反复走树。
- 内部页只是导航,不存行指针。因此内部页扇出极高,树很矮。
扇出与树高:为什么 B-Tree 查任何一行都只要几次 I/O
B-Tree 的设计核心是高扇出(high fan-out):每个内部页能挂几十到几百个子页指针。
页大小 8KB
内部页条目 ≈ 键(如 int8=8B) + 指针(6B) ≈ 14B
单个内部页可容纳 ≈ 8000 / 14 ≈ 570 个子指针
树高 1: 570 行
树高 2: 570² ≈ 32 万行
树高 3: 570³ ≈ 1.8 亿行
树高 4: 570⁴ ≈ 1000 亿行也就是说,一张上亿行的表,B-Tree 通常只有 3~4 层。查一行最多 4 次页访问,再算上堆表访问,一共个位数 I/O——这是顺序扫描无法企及的。
PostgreSQL 的 B-Tree 是 B+Tree 变体
教科书里的「B-Tree」在内部节点也存数据;PostgreSQL 实现的是 B+Tree 风格的变体:
| 特征 | 教科书 B-Tree | PostgreSQL B-Tree(B+Tree 式) |
|---|---|---|
| 内部节点是否存数据 | 是 | 否,只存分隔键 |
| 数据(行指针)位置 | 所有节点 | 只在叶子页 |
| 叶子页是否链接 | 否 | 双向链表 + 有序 |
| 重复键 | 每处一份 | 键可重复,靠 TID 区分 |
另一处独特设计:PostgreSQL 的叶子页里,每个条目是「键 + TID」,同一个键值出现多行就有多条记录(除非启用去重)。这让索引天然能表达「非唯一」语义。
支持的操作符
B-Tree 的每个操作符类(operator class)必须提供五个比较操作符(注意:没有 <>):
| 操作符 | 含义 | 能否走索引 |
|---|---|---|
= | 等于 | 能 |
< | 小于 | 能 |
<= | 小于等于 | 能 |
>= | 大于等于 | 能 |
> | 大于 | 能 |
<> / != | 不等于 | 不能(不在操作符类内) |
在这些操作符之上,B-Tree 还能优化一批复合条件:
| 查询写法 | 等价语义 | 索引利用方式 |
|---|---|---|
BETWEEN a AND b | col >= a AND col <= b | 范围扫描 |
col IN (1, 2, 3) | col = 1 OR col = 2 OR col = 3 | 多次等值定位合并 |
col IS NULL | 等值于 NULL(索引里存 NULL) | 等值定位 |
col IS NOT NULL | 全范围扫描 | 扫全部叶子(通常不比顺序扫描快) |
col LIKE 'abc%' | 前缀匹配 | 见下一小节 |
col ~ '^abc' | 正则锚定开头 | 前缀匹配 |
为什么 LIKE 'abc%' 能走索引,LIKE '%abc' 不能
B-Tree 的索引顺序是按键的字典序排列的。前缀匹配之所以可行,是因为「所有以 abc 开头的字符串」在有序序列里是一段连续区间:
索引里按键排序:
aaa
ab ← 区间下界
abc1 ┐
abc2 │ 'abc%' 对应的连续区间
abcd ┘
abd ← 区间上界(不含)
bbb优化器把 col LIKE 'abc%' 改写成: col >= 'abc' AND col < 'abd'(准确地说是「大于等于前缀、小于其后继」),于是可以用一次范围扫描定位。
而 LIKE '%abc' 要求「结尾是 abc」,这个条件在字典序里不构成连续区间——xabc、yabc、zabc 散落在索引各处,只能全扫。LIKE '%abc%' 同理。
非 C locale 的额外要求
如果数据库的 collation 不是 C/POSIX,LIKE 'abc%' 默认不能直接走 B-Tree,因为 locale 的排序规则下「前缀区间」不成立。这时需要建 text_pattern_ops 操作符类的索引(详见后文)。
B-Tree 的四大属性
| 属性 | 说明 | 相关语法 |
|---|---|---|
| 唯一性 | 可声明键值唯一,用于主键/唯一约束 | CREATE UNIQUE INDEX |
| 有序性 | 叶子页全局有序,可用于 ORDER BY | ASC / DESC / NULLS FIRST/LAST |
| 多列 | 按列序构成复合键,遵循最左前缀 | (a, b, c) |
| 表达式 | 可对表达式结果建索引 | ((lower(email))) |
这四种能力可以叠加——唯一多列表达式索引是完全合法的。除此之外,B-Tree 还支持:
- 覆盖列(INCLUDE):把只读列挂在叶子条目上,支持仅索引扫描。
- 部分索引(WHERE):只索引满足条件的行子集。
- 去重(deduplicate_items):PG 13+ 默认开启,把重复键压缩成 posting list。
三种索引扫描方式
用上 B-Tree 不代表「一定用索引扫描」,实际有三种访问路径:
| 扫描方式 | 英文 | 何时使用 | 是否保序 |
|---|---|---|---|
| 索引扫描 | Index Scan | 命中行数少(高选择率)时最优 | 保序,可消 ORDER BY |
| 位图索引扫描 | Bitmap Index Scan + Bitmap Heap Scan | 命中行数中等、需合并多个索引 | 不保序(按物理页序回表) |
| 仅索引扫描 | Index Only Scan | 所需列全在索引里,且页可见 | 保序 |
高选择率(命中很少) 中等选择率 低选择率(命中很多)
Index Scan → Bitmap Heap Scan → Seq Scan
1~2 次随机 I/O 按页序批量回表 全表扫位图扫描会把顺序信息丢掉
位图扫描是「先按索引找出所有 TID 记入位图,再按物理页顺序回表」。物理序 ≠ 索引序,所以 ORDER BY 会额外产生一个 Sort 节点。这一点在索引排序那一节会详细展开。
语法与用法
-- 最简形式:默认使用 btree
CREATE INDEX 索引名 ON 表名 (列名);
-- 显式指定 btree(与上面等价)
CREATE INDEX 索引名 ON 表名 USING btree (列名);
-- 唯一索引
CREATE UNIQUE INDEX 索引名 ON 表名 (列名);
-- 多列索引
CREATE INDEX 索引名 ON 表名 (列1, 列2, 列3);
-- 排序方向与 NULL 位置
CREATE INDEX 索引名 ON 表名 (列名 DESC NULLS FIRST);
-- 表达式索引
CREATE INDEX 索引名 ON 表名 ((lower(列名)));
-- 覆盖列(支持仅索引扫描)
CREATE INDEX 索引名 ON 表名 (列名) INCLUDE (其他列);
-- 部分索引
CREATE INDEX 索引名 ON 表名 (列名) WHERE 条件;
-- 在线建索引
CREATE INDEX CONCURRENTLY 索引名 ON 表名 (列名);
-- 指定操作符类(LIKE 前缀匹配场景)
CREATE INDEX 索引名 ON 表名 (列名 text_pattern_ops);
-- 存储参数
CREATE INDEX 索引名 ON 表名 (列名) WITH (fillfactor = 70);最小可运行示例:
DROP TABLE IF EXISTS demo_btree;
CREATE TABLE demo_btree (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL
);
CREATE INDEX idx_demo_btree_email ON demo_btree (email);DROP TABLE
CREATE TABLE
CREATE INDEX详细说明
叶子页的内部布局:键 + TID
B-Tree 索引条目(index tuple)由两部分组成:索引键和行指针 TID(ctid)。
DROP TABLE IF EXISTS items;
CREATE TABLE items (
id integer PRIMARY KEY,
sku text NOT NULL
);
INSERT INTO items (id, sku) VALUES
(1, 'A-100'),
(2, 'A-100'),
(3, 'B-200');
CREATE INDEX idx_items_sku ON items (sku);
ANALYZE items;DROP TABLE
CREATE TABLE
INSERT 0 3
CREATE INDEX
ANALYZE索引的逻辑内容如下:
叶子条目(按键排序):
'A-100' → (0,1) ← id=1 所在物理位置
'A-100' → (0,2) ← id=2 所在物理位置
'B-200' → (0,3)
两个 'A-100' 键重复出现,靠 TID 区分。这正是「B-Tree 可以索引非唯一列」的原因。你可以直接看到重复键:
SELECT sku, ctid FROM items ORDER BY sku, ctid; sku | ctid
--------+-------
A-100 | (0,1)
A-100 | (0,2)
B-200 | (0,3)
(3 rows)页分裂与合并:写入时树如何长大
B-Tree 的插入规则是「先找到该键应处的叶子页,插进去」。如果该页满了,就发生 页分裂(page split):
分裂前(叶子页已满):
┌──────────────────────────┐
│ 10 20 30 40 50 60 …│ 已满
└──────────────────────────┘
插入 45 后需要分裂,父页新增分隔键 40:
┌───────────────┐
父页 → │ … 40 … │
└───┬───────────┘
┌───────┘
┌────────┐ ┌────────┐
│10 20 30│ │40 45 50│ ← 一页变两页
└────────┘ └────────┘分裂在最坏情况下可能从叶子一路传到根,导致树长高一层。反过来,删除造成页太空时,PostgreSQL 会尝试合并(merge)相邻页,但合并远不如分裂常见。
随机插入 vs 顺序插入
顺序插入(如自增主键)总是往最右页追加,几乎不产生分裂;随机插入(如 UUID 主键)会分散到所有叶子页,频繁触发分裂,写入放大明显。这是「自增主键比 UUID 主键写入更快」的根本原因。
fillfactor:给页留白,减少分裂
fillfactor 控制 B-Tree 页的填充比例,默认 90(即建索引时只填 90%,留 10% 空位):
CREATE INDEX idx_items_sku_ff ON items (sku) WITH (fillfactor = 70);CREATE INDEX| fillfactor | 含义 | 适用 |
|---|---|---|
100 | 页填满 | 只读或极少更新的表,索引体积最小 |
90(默认) | 留 10% 空间 | 通用 |
50~70 | 留大量空间 | 频繁随机更新的列,减少页分裂 |
查看当前设置:
SELECT c.relname, c.reloptions
FROM pg_class c
WHERE c.relname = 'idx_items_sku_ff'; relname | reloptions
-------------------+--------------------
idx_items_sku_ff | {fillfactor=70}
(1 row)去重(deduplicate_items):PG 13+ 的省空间利器
当一个键重复很多次时,传统做法是每行存一份完整索引条目。PostgreSQL 13 起默认开启 去重(deduplication):把同一个键的多个 TID 合并成一条 posting list。
DROP TABLE IF EXISTS logs;
CREATE TABLE logs (
id bigint GENERATED ALWAYS AS IDENTITY,
level text NOT NULL
);
INSERT INTO logs (level)
SELECT (ARRAY['info','warn','error'])[1 + (g % 3)]
FROM generate_series(1, 30000) AS g;
CREATE INDEX idx_logs_level ON logs (level);
ANALYZE logs;DROP TABLE
CREATE TABLE
INSERT 0 30000
CREATE INDEX
ANALYZE对一个只有 3 个不同值的列,去重后索引体积会显著小于「一行一条」:
SELECT pg_size_pretty(pg_relation_size('idx_logs_level')) AS index_size; index_size
------------
240 kB
(1 row)去重何时不生效
- 索引键含 INCLUDE 列 时不使用去重;
- 键比较不支持「image 相等」(如非确定性 collation)时不使用去重;
- 用
WITH (deduplicate_items = off)可显式关闭。
索引扫描 vs 位图扫描:什么时候切换
优化器按预估命中行数在两种路径间选择:
DROP TABLE IF EXISTS orders;
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer text NOT NULL,
amount numeric(10,2) NOT NULL,
created_at timestamptz NOT NULL
);
INSERT INTO orders (customer, amount, created_at)
SELECT
'cust-' || (g % 10000),
(random() * 1000)::numeric(10,2),
now() - (g || ' seconds')::interval
FROM generate_series(1, 200000) AS g;
CREATE INDEX idx_orders_customer ON orders (customer);
ANALYZE orders;DROP TABLE
CREATE TABLE
INSERT 0 200000
CREATE INDEX
ANALYZE高选择率(一个客户只有约 20 行)走 Index Scan:
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT * FROM orders WHERE customer = 'cust-777'; QUERY PLAN
--------------------------------------------------------------------------
Index Scan using idx_orders_customer on orders (actual rows=20 loops=1)
Index Cond: (customer = 'cust-777'::text)
Buffers: shared hit=24
(3 rows)中等选择率(命中上千行)时,优化器倾向位图扫描,以按物理序回表降低随机 I/O:
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT count(*) FROM orders WHERE customer < 'cust-0500'; QUERY PLAN
------------------------------------------------------------------------------------
Aggregate (actual rows=1 loops=1)
Buffers: shared hit=1123
-> Bitmap Heap Scan on orders (actual rows=9959 loops=1)
Recheck Cond: (customer < 'cust-0500'::text)
Heap Blocks: exact=1080
-> Bitmap Index Scan on idx_orders_customer (actual rows=9959 loops=1)
Index Cond: (customer < 'cust-0500'::text)
(7 rows)注意计划里多了一层 Bitmap Index Scan,且 Bitmap Heap Scan 的命中行按页聚集(Heap Blocks: exact=1080 说明 9959 行只落在 1080 个数据页里)。
仅索引扫描:连堆表都不用碰
如果查询需要的列全部在索引里,且对应数据页在 visibility map(可见性映射)中被标记为「全可见」,PostgreSQL 就能做 仅索引扫描(Index Only Scan),跳过堆表访问。
-- 先做一次 VACUUM 更新 visibility map
VACUUM orders;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT customer FROM orders WHERE customer = 'cust-777'; QUERY PLAN
-----------------------------------------------------------------------------------
Index Only Scan using idx_orders_customer on orders (actual rows=20 loops=1)
Index Cond: (customer = 'cust-777'::text)
Heap Fetches: 0
Buffers: shared hit=5
(4 rows)Heap Fetches: 0 是关键——一次堆表都没访问。若某些页尚未标记为全可见,这个计数会大于 0,性能就退回到普通索引扫描。
用 INCLUDE 把额外列挂到索引上,可以扩大仅索引扫描的适用范围:
CREATE INDEX idx_orders_customer_amount
ON orders (customer) INCLUDE (amount);
VACUUM orders;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT customer, amount FROM orders WHERE customer = 'cust-777'; QUERY PLAN
-------------------------------------------------------------------------------------------
Index Only Scan using idx_orders_customer_amount on orders (actual rows=20 loops=1)
Index Cond: (customer = 'cust-777'::text)
Heap Fetches: 0
(3 rows)前缀匹配与操作符类(text_pattern_ops)
在非 C locale 的数据库里,LIKE 'abc%' 默认走不了普通 text 索引。解决办法是使用模式操作符类(pattern operator class):
DROP TABLE IF EXISTS customers;
CREATE TABLE customers (
id integer PRIMARY KEY,
name text NOT NULL
);
INSERT INTO customers (id, name) VALUES
(1, 'alice'),
(2, 'alicia'),
(3, 'bob');
CREATE INDEX idx_customers_name_pattern ON customers (name text_pattern_ops);
ANALYZE customers;
EXPLAIN (COSTS OFF)
SELECT id FROM customers WHERE name LIKE 'ali%'; QUERY PLAN
--------------------------------------------------------------------------------
Index Only Scan using idx_customers_name_pattern on customers
Index Cond: ((name ~>=~ 'ali') AND (name ~<~ 'ali'::text))
(2 rows)Index Cond 里的 ~>=~ / ~<~ 就是逐字符(byte-wise)比较操作符,它不依赖 locale,因而能正确切出前缀区间。
| 操作符类 | 适用类型 | 用途 |
|---|---|---|
text_pattern_ops | text | LIKE / 正则前缀 |
varchar_pattern_ops | varchar | 同上 |
bpchar_pattern_ops | char(n) | 同上 |
text_ops(默认) | text | 一般比较、排序 |
模式操作符类不支持大小比较
text_pattern_ops 不含 < <= > >= 的常规语义,因此如果要同时支持 name > 'm' 这类查询,需要另建一个默认操作符类的索引。
多列索引与最左前缀(速览)
B-Tree 支持多列索引,键按列序拼接后参与排序:
CREATE INDEX idx_orders_customer_created
ON orders (customer, created_at);
EXPLAIN (COSTS OFF)
SELECT * FROM orders
WHERE customer = 'cust-777' AND created_at > now() - interval '1 day'; QUERY PLAN
---------------------------------------------------------------------------------------------
Index Scan using idx_orders_customer_created on orders
Index Cond: ((customer = 'cust-777'::text) AND (created_at > (now() - '1 day'::interval)))
(2 rows)第一列 customer 是等值条件、第二列 created_at 是范围条件,索引两个键都用上了(Index Cond 里两段都有)。但如果查询不带第一列,这个索引就基本用不上——这就是最左前缀原则,后面有专门一节展开。
B-Tree 不适合的场景
| 场景 | 为什么 B-Tree 不适合 | 应该用什么 |
|---|---|---|
| 数组 / JSONB 的成员判断 | 无法拆解「列内部的多值」 | GIN |
| 全文检索 | 同上 | GIN / GiST |
| 几何、范围类型的重叠判断 | 无全序比较 | GiST / SP-GiST |
超长文本的模糊匹配 %x% | 无连续区间 | GIN + pg_trgm |
超大表的极低基数列(如 boolean) | 选择性太低,不如顺序扫描 | BRIN 或部分索引 |
| 只做等值、且键很长 | 空间浪费在存储完整键值上 | Hash(见哈希索引对比) |
一条容易忽视的事实:选择性(selectivity)决定索引是否被使用。索引不是「建了就快」,而是「数据分布 + 查询模式 + 优化器估算」三者共同作用的结果。
用系统表观察 B-Tree 索引
SELECT
i.indexrelid::regclass AS index_name,
am.amname AS method,
i.indisunique AS is_unique,
i.indisvalid AS is_valid,
pg_size_pretty(pg_relation_size(i.indexrelid)) AS size
FROM pg_index i
JOIN pg_class ic ON ic.oid = i.indexrelid
JOIN pg_am am ON am.oid = ic.relam
WHERE i.indrelid = 'orders'::regclass
ORDER BY pg_relation_size(i.indexrelid) DESC; index_name | method | is_unique | is_valid | size
-----------------------------------+--------+-----------+----------+---------
orders_pkey | btree | t | t | 5312 kB
idx_orders_customer_amount | btree | f | t | 4088 kB
idx_orders_customer | btree | f | t | 3752 kB
idx_orders_customer_created | btree | f | t | 6064 kB
(4 rows)再看某个索引的物理统计(需要 pgstattuple 扩展):
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT * FROM pgstatindex('idx_orders_customer'); version | tree_level | index_size | root_block_no | internal_pages | leaf_pages | empty_pages | deleted_pages | avg_leaf_density | leaf_fragmentation
---------+------------+------------+---------------+----------------+------------+-------------+---------------+------------------+-------------------
4 | 2 | 3842048 | 3 | 2 | 465 | 0 | 0 | 89.42 | 0.00
(1 row)tree_level = 2 说明这是一棵两层半的树(根 + 叶子),avg_leaf_density 反映页填充率,leaf_fragmentation 过高时值得 REINDEX。
实战示例
场景:一张电商订单表,既有按客户查询,又有按时间范围查询,还要求能分页排序输出。我们来验证:同一条查询在不同条件下的执行计划差异,以及 B-Tree 索引的真实收益。
第 1 步:建表并灌入 100 万行
DROP TABLE IF EXISTS shop_orders CASCADE;
CREATE TABLE shop_orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id integer NOT NULL,
status text NOT NULL,
amount numeric(12,2) NOT NULL,
created_at timestamptz NOT NULL
);
INSERT INTO shop_orders (customer_id, status, amount, created_at)
SELECT
(random() * 50000)::int,
(ARRAY['paid','shipped','done','cancel'])[1 + (g % 4)],
(random() * 5000)::numeric(12,2),
now() - (g || ' seconds')::interval
FROM generate_series(1, 1000000) AS g;
ANALYZE shop_orders;DROP TABLE
CREATE TABLE
INSERT 0 1000000
ANALYZE第 2 步:无索引时的基线(顺序扫描)
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id, amount FROM shop_orders WHERE customer_id = 12345; QUERY PLAN
------------------------------------------------------------------------------
Seq Scan on shop_orders (actual rows=20 loops=1)
Filter: (customer_id = 12345)
Rows Removed by Filter: 999980
Buffers: shared hit=14706
(4 rows)100 万行全部读取,命中 20 行——典型的「索引缺失」症状。
第 3 步:建立 B-Tree 索引后重测
CREATE INDEX idx_shop_orders_customer ON shop_orders (customer_id);
ANALYZE shop_orders;CREATE INDEX
ANALYZEEXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id, amount FROM shop_orders WHERE customer_id = 12345; QUERY PLAN
------------------------------------------------------------------------------------
Index Scan using idx_shop_orders_customer on shop_orders (actual rows=20 loops=1)
Index Cond: (customer_id = 12345)
Buffers: shared hit=26
(3 rows)缓冲区访问从 14706 降到 26,快了三个数量级。
第 4 步:验证范围查询(B-Tree 的核心优势)
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT count(*) FROM shop_orders
WHERE created_at >= now() - interval '1 hour';顺序扫描做时间范围查询必然全表扫。先建索引:
CREATE INDEX idx_shop_orders_created ON shop_orders (created_at);
ANALYZE shop_orders;CREATE INDEX
ANALYZEEXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT count(*) FROM shop_orders
WHERE created_at >= now() - interval '1 hour'; QUERY PLAN
-------------------------------------------------------------------------------------------
Aggregate (actual rows=1 loops=1)
Buffers: shared hit=179
-> Index Only Scan using idx_shop_orders_created on shop_orders (actual rows=3600 loops=1)
Index Cond: (created_at >= (now() - '01:00:00'::interval))
Heap Fetches: 0
Buffers: shared hit=178
(6 rows)一次范围扫描替代整表扫描,而且因为只取 count(*)、索引又含该列,直接走了仅索引扫描。
第 5 步:验证「前缀 LIKE 能走、模糊 LIKE 不能走」
DROP TABLE IF EXISTS shop_customers;
CREATE TABLE shop_customers (
id integer PRIMARY KEY,
name text NOT NULL
);
INSERT INTO shop_customers (id, name)
SELECT g, 'user_' || lpad(g::text, 8, '0')
FROM generate_series(1, 100000) AS g;
CREATE INDEX idx_shop_customers_name_pattern
ON shop_customers (name text_pattern_ops);
CREATE INDEX idx_shop_customers_name ON shop_customers (name);
ANALYZE shop_customers;DROP TABLE
CREATE TABLE
INSERT 0 100000
CREATE INDEX
CREATE INDEX
ANALYZEEXPLAIN (COSTS OFF)
SELECT id FROM shop_customers WHERE name LIKE 'user_0001%'; QUERY PLAN
------------------------------------------------------------------------------------
Index Only Scan using idx_shop_customers_name_pattern on shop_customers
Index Cond: ((name ~>=~ 'user_0001'::text) AND (name ~<~ 'user_0002'::text))
(2 rows)EXPLAIN (COSTS OFF)
SELECT id FROM shop_customers WHERE name LIKE '%0001%'; QUERY PLAN
-----------------------------------------------------
Seq Scan on shop_customers
Filter: (name ~~ '%0001%'::text)
(2 rows)同一张表、同一个列,前缀匹配走索引、中缀匹配退化为顺序扫描,完全符合原理。
第 6 步:查看索引体积与使用统计
SELECT
indexrelid::regclass AS index_name,
idx_scan,
pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE relname IN ('shop_orders', 'shop_customers')
ORDER BY relname, pg_relation_size(indexrelid) DESC; index_name | idx_scan | size
-----------------------------------+----------+---------
shop_customers_pkey | 0 | 3584 kB
idx_shop_customers_name | 0 | 3568 kB
idx_shop_customers_name_pattern | 1 | 3568 kB
shop_orders_pkey | 0 | 31 MB
idx_shop_orders_created | 1 | 27 MB
idx_shop_orders_customer | 1 | 26 MB
(6 rows)第 7 步:清理
DROP TABLE shop_orders;
DROP TABLE shop_customers;
DROP TABLE orders;
DROP TABLE logs;
DROP TABLE customers;
DROP TABLE items;
DROP TABLE demo_btree;注意事项
B-Tree 索引不存储
<>语义,WHERE status <> 'done'永远走不了索引。sql-- 错误!期望走索引,实际是顺序扫描 SELECT * FROM shop_orders WHERE status <> 'done';LIKE '%abc'或LIKE '%abc%'无法使用普通 B-Tree 索引,需要pg_trgm+ GIN。sql-- 错误!前置通配符无法构成连续区间 CREATE INDEX idx_bad ON customers (name); SELECT * FROM customers WHERE name LIKE '%ali';非 C locale 下
LIKE 'abc%'需要text_pattern_ops,否则不生效。sql-- 正确 CREATE INDEX idx_names ON customers (name text_pattern_ops);索引不是免费的:每个索引都会让
INSERT/UPDATE/DELETE变慢,因为写操作要同步维护所有索引。sql-- 不推荐:一个表上建十几个索引,写入会明显变慢IS NOT NULL通常不会走索引——它命中的行往往太多,顺序扫描更便宜。低基数列(如
boolean、只有两三个值的status)单独建 B-Tree 索引收益有限,往往需要配合部分索引或复合索引。sql-- 不推荐:只有一个布尔值,选择性太差 CREATE INDEX idx_bad ON users (is_active);函数包裹列会让普通索引失效,除非建的是表达式索引。
sql-- 错误!lower(email) 用不上 email 上的普通索引 SELECT * FROM users WHERE lower(email) = 'a@b.com'; -- 正确:建表达式索引 CREATE INDEX idx_users_lower_email ON users (lower(email));隐式类型转换会破坏索引使用。列是
text、参数是integer还好,反过来常常出问题。sql-- 假设 code 是 varchar,比较时传整数常量可能触发转换导致索引失效 SELECT * FROM t WHERE code = 123;刚建完索引别忘了
ANALYZE,否则优化器的统计信息还停留在旧状态,可能不敢用新索引。tree_level过高(比如 4 层以上)通常意味着索引键太大或行数极多,值得检查是否该用更短的类型。
最佳实践
优先用自增/序列主键而非随机 UUID,顺序写入能大幅减少页分裂:
sqlid bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY给热点的等值 + 范围组合查询建复合索引,等值列在前、范围列在后:
sqlCREATE INDEX idx_orders_cust_time ON shop_orders (customer_id, created_at DESC);频繁随机更新的索引调低
fillfactor:sqlCREATE INDEX idx_hot ON hot_table (hot_col) WITH (fillfactor = 70);读多写少且需要覆盖查询时用
INCLUDE,把少量宽列挂上去,换来仅索引扫描:sqlCREATE INDEX idx_orders_cover ON shop_orders (customer_id) INCLUDE (amount, created_at);建完索引对表做一次
VACUUM,让 visibility map 生效,否则仅索引扫描会退化成 Heap Fetches:sqlVACUUM (ANALYZE) shop_orders;大表建索引一律用
CONCURRENTLY,避免长时间阻塞写入:sqlCREATE INDEX CONCURRENTLY idx_big ON big_table (col);定期清理从未被使用的索引(
idx_scan = 0且非唯一约束索引):sqlSELECT indexrelid::regclass, idx_scan FROM pg_stat_user_indexes WHERE schemaname NOT IN ('pg_catalog','information_schema') AND idx_scan = 0 ORDER BY pg_relation_size(indexrelid) DESC;用
pgstattuple监控膨胀,leaf_fragmentation高时安排REINDEX:sqlSELECT * FROM pgstatindex('idx_shop_orders_customer'); REINDEX INDEX CONCURRENTLY idx_shop_orders_customer;用真实数据验证索引效果,不要凭直觉判断。
EXPLAIN (ANALYZE, BUFFERS)才是唯一可信的证据:sqlEXPLAIN (ANALYZE, COSTS OFF, BUFFERS) SELECT ... ;关注选择性:命中比例超过表的 5%~10% 时,顺序扫描可能反而更快,不要强行用索引。
下一节
继续学习:CREATE INDEX 基础