Chapter 03
索引:给优化器一条更便宜的路径
上一章确立了优化器按代价从候选计划集里选最便宜的计划,索引就是"往候选集里塞一个更便宜的取数选项"。这一章讲清有哪些索引、各让什么查询变便宜,以及为什么你建的索引有时不被选。
本章你将建立的心智模型
- B-tree 擅长什么、不擅长什么——它为什么天然支持范围扫描和有序输出
- 多列索引的列序原则(等值在前、范围在后),以及 PG 18 的 skip scan 怎么松动了"最左前缀"铁律
- 覆盖索引与 Index Only Scan:免回表的前提是可见性映射 all-visible,这条机制连到第 04 章
- 部分索引(只索引一部分行)与表达式索引(让函数包裹的列也能走索引)
- 索引类型全景:B-tree / GIN / GiST / BRIN / Hash 各自服务什么数据与查询
- 索引为什么不被用 + 索引的写入代价——索引是"读变快 vs 写变慢"的权衡
3.1B-tree:默认索引,擅长什么
B-tree 是 PostgreSQL 的默认索引,服务一切"可比较"的查询:等值、范围、排序、前缀匹配。
文档会说 B-tree 支持 = 和范围。再深一层看它的物理结构:B-tree 是一棵平衡的多层树,从根节点逐层下探到叶子,任意一个键的查找路径长度相同(都是树高,通常 3~4 层)。关键在叶子层——所有键按值有序排列,且相邻叶子节点用双向链表相连。这一个结构同时解释了它的两项核心能力:有序叶子让范围扫描(找到起点后沿链表顺序读)和 ORDER BY 免排序(直接按叶子顺序输出)得以成立。"B-tree 天然有序"不是一句口号,是它的叶子层物理布局决定的。
B-tree 能服务的查询条件:
- 等值与范围:
=、<、>、<=、>=、BETWEEN、IN (...)——这些都是"在有序键里定位一个点或一段区间"。 - 前缀匹配:
LIKE 'abc%'(以及~ '^abc')能走 B-tree,因为前缀确定了叶子层里一段连续区间的起点。 ORDER BY免排序:查询的排序列与索引列(及方向)一致时,优化器直接读有序叶子,跳过显式 Sort 节点。
B-tree 用不上的场景:
- 后缀/中缀匹配:
LIKE '%abc'、LIKE '%abc%'——通配符在前,无法确定有序键里的起点,只能逐行扫。这类查询要靠 §3.5 的 GIN(配pg_trgm)。 - 被函数包裹的列:
WHERE lower(email) = ?对裸email的 B-tree 无效——索引存的是原值,不是lower()后的值。解法是 §3.4 的表达式索引。
一个走 B-tree 的计划,标志是 Index Cond 这一行——它表示条件被下推进了索引扫描,而不是取出行后再 Filter:
Index Scan using idx_orders_created on orders
(cost=0.42..28.6 rows=120 width=84)
(actual time=0.02..0.31 rows=118 loops=1)
Index Cond: ((created_at >= '2026-05-01') AND (created_at < '2026-06-01'))
这是一个范围查询走 B-tree 的典型:Index Cond 里两个边界把扫描限定在叶子层的一段连续区间内,只读了这段区间对应的索引项,再回表取 118 行。对比第 01 章那个 Rows Removed by Filter: 999991 的顺序扫描,差别一目了然——条件进了 Index Cond,就没有海量行被扫出来再扔掉。
ORDER BY 免排序的物理来源。找到区间起点后,顺着链表读即可,无需再回根节点。3.2多列索引与列序(含 skip scan 现状)
多列索引 (a, b, c) 的列序决定它能服务哪些查询;列序排错,索引就服务不了你以为它能服务的查询。
多列 B-tree 的叶子按"a 为主、b 次之、c 再次"的字典序排列。这个排序方式直接推出经典的最左前缀规则:索引只能服务"从最左列开始、连续使用"的条件前缀。
以 (a, b) 为例:
WHERE a = ?—— 能用(用到最左列)。WHERE a = ? AND b = ?—— 能用(前缀完整)。WHERE b = ?—— 经典规则下用不上:缺了最左列a,无法在按a排序的叶子里定位。
列序原则:等值列在前,范围列在后
当查询里既有等值又有范围,把等值列放在前面、范围列放在后面。原因还是叶子的字典序:等值条件把扫描收窄到"a 固定"的一段,在这一段里 b 仍是有序的,范围条件 b > ? 就能继续在这段内定位区间。反过来,(b, a) 且 b 是范围时,b > ? 跨越多个 b 值,每段里的 a 各自有序但整体无序,等值条件 a = ? 就无法靠索引收窄。
所以查询 WHERE a = ? AND b > ? 的最优索引是 (a, b),不是 (b, a)。这条原则在 §3.6 的挑战题里会再用到。
CURRENCY · PG 18 引入 B-tree skip scan(2025-09)
PostgreSQL 18(GA 于 2025-09-25)给 B-tree 加入了 skip scan。它改变了上面"缺最左列就用不上"的结论:对索引 (a, b),查询 WHERE b = ?(缺前导列 a)现在也有机会用上这个索引——优化器会遍历 a 的每一个 distinct 值,在每个值下用 b = ? 做一次子搜索,把多次子搜索拼起来。
skip scan 的代价正比于前导列 a 的 distinct 值个数:a 只有几个或几十个不同值(低基数)时,跳着搜很划算;a 高基数(成千上万个值)时,要跳的次数太多,优化器会放弃 skip scan、退回顺序扫描。所以"前导列必须出现在 WHERE"不再是绝对铁律,但松动仅限低基数前导列。PG 18 之前,这条规则没有例外。
| 查询条件 | 用到 (a, b)? PG 18 之前 | 用到 (a, b)? PG 18 之后 |
|---|---|---|
WHERE a = ? | 是(最左前缀) | 是 |
WHERE a = ? AND b = ? | 是(前缀完整) | 是 |
WHERE b = ?(a 低基数) | 否 | 是(skip scan) |
WHERE b = ?(a 高基数) | 否 | 否(skip 代价太高,退化) |
3.3覆盖索引与 Index Only Scan
查询要的列全在索引里时,优化器可以只读索引、不回表(Index Only Scan)——前提是数据页被标记为 all-visible。
普通 Index Scan 分两步:走索引拿到行的物理位置(TID),再回表(到堆表对应页)取出整行。回表是随机读,正是第 02 章里"命中行一多就该用顺序扫描"的代价来源。如果查询需要的列恰好都在索引里,这一步回表就能省掉——这就是 Index Only Scan。
用 INCLUDE 把额外列搭进叶子
让索引覆盖更多列,有两种写法。一是直接把列加进索引键 (a, b),但这会让 b 参与排序和定位,有时并不需要。二是用 INCLUDE 子句:INCLUDE 的列只存进叶子节点(供取数、避免回表),不参与排序与查找。当某列只是要被 SELECT 出来、从不作为查询条件时,放进 INCLUDE 比放进索引键更省。
-- 查询:SELECT amount FROM orders WHERE customer_id = ?
-- customer_id 用于查找,amount 只是要取出来
CREATE INDEX idx_orders_cust_amt
ON orders (customer_id) INCLUDE (amount);
建好后,上面那条查询就能走 Index Only Scan——customer_id 定位、amount 直接从叶子取,完全不碰堆表:
Index Only Scan using idx_orders_cust_amt on orders
(cost=0.42..8.4 rows=9 width=8)
(actual time=0.02..0.03 rows=9 loops=1)
Index Cond: (customer_id = 42)
Heap Fetches: 0
关键看 Heap Fetches: 0:0 次回表,整条查询只读了索引。这是 Index Only Scan 跑在理想状态的标志。
Index Only Scan 有一个隐藏前提:索引项指向的堆页必须在可见性映射(visibility map,VM)里被标记为 all-visible。原因是索引项本身不存行的事务可见性信息(那存在堆表行头里);只有当某页"对所有事务都可见"时,优化器才敢断定"不回表也不会读到不该看见的行"。而这个 all-visible 标记由 VACUUM 维护。如果一张表写入频繁、VACUUM 跟不上,大量页的 VM 标记是脏的,Index Only Scan 就不得不逐个回表去查可见性——表现就是 Heap Fetches 变大,Index Only Scan 退化到接近普通 Index Scan。这条"VACUUM 不及时 → index-only 退化"的机制,在第 04 章 §4.3展开。
3.4部分索引与表达式索引
部分索引只索引"满足某条件的行",表达式索引索引"某个表达式的结果"——两者都是把索引精确对准查询。
部分索引:只索引你真正会查的行
部分索引在定义里带 WHERE,只为满足条件的行建索引项。它的好处是更小、写入更省:不满足条件的行根本不进索引,既省空间,这些行的增删改也不必维护索引项。
这正好接上第 02 章挑战题的悬念:status 有 shipped 60% / paid 25% / pending 12% / cancelled 3%,高频值走 Seq Scan、只有低频的 cancelled 适合走索引。但建一个完整的 status 单列索引,会为全部行(包括占 97% 的非 cancelled 行)建项,浪费且拖慢写入。更优解是只为 cancelled 行建部分索引:
-- 只索引低频值 cancelled 的行;索引体积≈全表的 3%
CREATE INDEX idx_orders_cancelled
ON orders (created_at)
WHERE status = 'cancelled';
-- 这条查询会走上面的部分索引
SELECT * FROM orders
WHERE status = 'cancelled' AND created_at >= '2026-05-01';
优化器识别出查询条件 status = 'cancelled' 与索引的 WHERE 谓词匹配,就会用这个只含 3% 行的小索引,既快又几乎不增加写入负担。这是"部分索引专门服务低频值查询"的标准用法。
表达式索引:让函数包裹的列也能走索引
§3.1 提过,WHERE lower(email) = ? 用不上 email 的普通 B-tree——索引存的是原值,不是 lower() 后的值。表达式索引(也叫函数索引)直接索引表达式的结果:
-- 索引 lower(email) 的结果,而不是 email 本身
CREATE INDEX idx_users_lower_email
ON users (lower(email));
-- 现在这条大小写不敏感的查找能走索引
SELECT * FROM users WHERE lower(email) = 'alice@example.com';
注意查询里的表达式必须与索引定义里的完全一致(都是 lower(email)),优化器才会匹配。表达式索引的代价:每次 INSERT/UPDATE 都要计算一次该表达式来维护索引项,所以表达式应当是确定性的、不太重的函数。
3.5索引类型全景
B-tree 不是唯一的索引类型;数据形态和查询算子不同,最合适的索引结构也不同。
前四节都在讲 B-tree,因为它覆盖了绝大多数 OLTP 查询。但 jsonb 的包含查询、全文检索、几何最近邻、超大时序表——这些场景 B-tree 要么用不上,要么远不是最优。PostgreSQL 内置五种索引类型:
| 类型 | 适用数据 / 查询 | 典型例子 |
|---|---|---|
| B-tree | 可比较的标量:等值、范围、排序、前缀 | WHERE id = ? · ORDER BY created_at |
| GIN | 倒排索引:一个值含多个可检索项(jsonb、数组、全文) | jsonb @> · 数组包含 · tsvector 全文检索 |
| GiST | 几何 / 范围类型 / 最近邻(KNN) | 地理位置 && 相交 · 范围重叠 · ORDER BY point <-> ? |
| BRIN | 超大表且物理顺序与列值相关(如追加写的时序表) | 按时间分区的日志表上 created_at >= ? |
| Hash | 仅等值(PG 10+ 已 WAL 安全 / 崩溃安全) | WHERE token = ?(少用,B-tree 通常够) |
几点要点:
- GIN 是"倒排":对一行里的多个元素分别建索引项(如 jsonb 的每个 key、数组的每个元素、文档的每个词)。配
pg_trgm扩展时,GIN 还能服务 §3.1 里 B-tree 做不到的LIKE '%abc%'中缀匹配。 - BRIN 不存每一行,只存"每一段连续块的值范围摘要"。所以它极小(一张几百 GB 的表,BRIN 往往只有几 MB)。代价是它只在"物理顺序与列值强相关"时有效——按时间追加写入的时序表是最佳场景,行的物理位置天然按
created_at递增。 - Hash 在 PG 10 起已经写 WAL、崩溃安全,不再是过去那个"不推荐"的状态。但它只支持等值、不支持范围,而 B-tree 同样能做等值,所以实践中仍少用。
3.6索引为什么不被用 + 索引的代价
建了索引不等于会被用;而且每个索引都对写入收税——索引是"读变快 vs 写变慢/占空间"的权衡。
索引为什么不被用
"明明建了索引,优化器却没走"是最常见的困惑。逐条对应根因:
- 统计信息过期,估错了选择率:优化器以为命中很多行,觉得索引不划算。根因和修法见第 02 章 §2.2(
ANALYZE/ 提高统计精度)。这是头号原因。 - 列被函数包裹:
WHERE lower(email) = ?用不上email的普通索引。修法:建 §3.4 的表达式索引。 - 隐式类型不匹配:列是
bigint,查询写WHERE id = '42'(字符串),或列是text却用数字比较——隐式转换会让索引失效。修法:让参数类型与列类型一致。 - 命中行太多,Seq 更便宜:这不是 bug,是正确的代价权衡。命中全表很大比例时,顺序扫描比海量随机回表更快。机制见第 02 章 §2.3。
- 表太小:只有几百行的表,整页顺序读一遍比走索引还快,优化器直接选 Seq Scan。
LIKE '%x'前导通配:通配符在前,B-tree 无法定位起点(§3.1)。修法:GIN +pg_trgm。
索引的代价:不是越多越好
索引让读变快,但不是免费的。三笔代价:
- 拖慢写入:每个索引都要在
INSERT/UPDATE/DELETE时同步维护索引项。一张表上有 8 个索引,一次INSERT就要写 1 个堆元组 + 8 个索引项。索引越多,写入越慢。 - 破坏 HOT 优化:PostgreSQL 的 HOT(Heap-Only Tuple)优化能让"不改任何被索引列"的
UPDATE不去更新索引,代价很低。但只要改了被某个索引覆盖的列,这次 UPDATE 就无法走 HOT,必须更新所有相关索引项。给一个频繁更新的列建索引,等于让每次更新都变贵。HOT 的机制见第 04 章 §4.4。 - 索引自身会膨胀:和表一样,索引在大量更新/删除后也会积累死项、变得臃肿,需要
REINDEX重建才能恢复紧凑。膨胀与重建见第 04 章。
把读和写放在一起看:索引把读从全表扫描变成几次定位,但给每一次写都加了维护成本,还占磁盘、还会膨胀。所以"给所有想得到的列都建上索引"是反模式——它会让一张写多读少的表慢得不可理喻。正确做法:用第 01 章的 pg_stat_statements 找出真正高频、真正慢的查询,只为它们建必要的索引,然后用 EXPLAIN 复测确认索引被用上了。建索引前先问一句:这张表的读写比是多少?
§本章 self-check
先合上教程,把答案写下来再对照。这一章的题大多没有"背一下就行"的答案,需要你把机制走一遍。
- B-tree 为什么天然支持
ORDER BY免排序和范围扫描?用它的叶子层结构解释,而不是只说"它有序"。 - 索引
(a, b),查询WHERE b = ?。在 PG 18 之前会用上它吗?PG 18 之后呢?后者成立的前提条件是什么? - 一条 Index Only Scan 的
Heap Fetches突然从 0 涨到很大,查询变慢。最大嫌疑的根因是什么?该去看哪一章? - (设计题)一张
events表写极多、读极少,只偶尔按user_id查最近事件。有人提议给user_id、type、created_at、payload全部各建一个索引。指出这个方案的问题,给出更克制的设计,并说明你会怎么验证它有效。
答案(先做完再展开)
- B-tree 的叶子层按键有序,且相邻叶子用双向链表相连。排序列与索引一致时,直接顺着有序叶子输出即可,无需显式 Sort;范围查询找到区间起点后,沿链表顺序读到终点即可。两项能力都来自叶子层的物理布局,不是抽象的"有序"。
- PG 18 之前:不会,缺最左列
a,违反最左前缀。PG 18 之后:有机会,靠 skip scan。前提条件是 前导列a是低基数(distinct 值少);a高基数时跳搜代价太高,优化器仍会退回顺序扫描。 - 根因:索引指向的堆页在可见性映射(VM)里不再是 all-visible——通常是表写入频繁而 VACUUM 跟不上,VM 标记变脏,Index Only Scan 不得不逐行回表查可见性。去看第 04 章 §4.3(VACUUM 与可见性映射)。
- 问题:这是写多读少的表,4 个索引会让每次写入都维护 4 个索引项,且给
created_at这类频繁变化的列建索引会破坏 HOT 优化(连第 04 章),写入急剧变慢;payload几乎不会作为查询条件,给它建索引纯属浪费。更克制的设计:只为唯一真实查询"按user_id查最近事件"建一个(user_id, created_at)索引(等值列在前、排序/范围列在后),其余不建。验证:用pg_stat_statements确认确实只有这一类查询是热点,再对该查询跑EXPLAIN (ANALYZE, BUFFERS)确认走了该索引、Buffers大幅下降,同时观察写入吞吐没有明显退化。
一条查询,设计一个最优索引(列序 + 覆盖)
给定查询:SELECT status, amount FROM orders WHERE customer_id = ? AND created_at > ? ORDER BY created_at;。它有一个等值条件、一个范围条件、一个排序、且只 SELECT 两列。请设计一个索引,使这条查询既能用索引定位、又能免排序、还能免回表。写出 CREATE INDEX 语句并说明每个决策。
提示(卡住再展开)
三步拼起来:① 列序——等值列 customer_id 在前,范围/排序列 created_at 在后(§3.2 的"等值在前、范围在后"),这样既能定位又能让 created_at 在 customer_id 固定的那一段里保持有序,从而免排序。② 覆盖——查询还要取 status 和 amount,但它们不作为条件,放进 INCLUDE(§3.3)就能免回表、走 Index Only Scan。③ 合起来:CREATE INDEX ON orders (customer_id, created_at) INCLUDE (status, amount);。验证时看 EXPLAIN 是否出现 Index Only Scan、没有 Sort 节点、且 Heap Fetches 接近 0(后者还取决于 VACUUM,见第 04 章)。