引用说明
原文作者 Michael Christofides
原文地址:https://www.pgmustard.com/blog/what-do-index-searches-in-explain-mean
你是否在升级到 PostgreSQL 18 后,发现 EXPLAIN ANALYZE 的输出中多了一个神秘的 Index Searches 字段?这个新增的统计项究竟揭示了什么秘密?是优化生效的信号,还是索引设计存在潜在问题的提示?本文将深入剖析这一新字段,结合具体示例,带你读懂其背后的执行逻辑与性能含义。
一、背景与概念:Index Searches 是什么?
从 PostgreSQL 18 开始,EXPLAIN ANALYZE 的输出中新增了 Index Searches 字段。简单来说,它记录了查询执行时,从索引的根节点向下进行的“下降(descent)”次数。每一次下降,都对应着引擎对索引树的一次定位操作。
最常见的情况:Index Searches: 1
当 Index Searches 值为 1 时,意味着优化器判断只需对索引进行一次下降,就能定位到所有满足条件的条目。这是最理想的情况吗?未必。如果所需的数据在索引中恰好紧密排列,那么一次下降确实高效。但如果符合条件的行分散在索引各处,那么即使只做一次下降,也可能需要在索引页中跳过大量无关数据,导致实际读取的缓冲区(Buffers)很多,效率低下。
Index Searches > 1 是怎么来的?
多个索引下降通常意味着查询涉及多个离散的值,而新的执行计划认为,为每个值单独做一次精准定位,比做一次“大扫荡”式的扫描更划算。
这源于 PostgreSQL 17 对 btree 索引的一项重要优化:对于 IN 子句这类多值查找,允许对同一索引进行多次独立的下降,每次精确定位一个值。这避免了在一次扫描中处理大量不满足条件的中间条目,显著提升了性能。PostgreSQL 18 更进一步,将这一机制扩展并固化为 skip scan(跳跃扫描) 功能,同时将执行情况通过 Index Searches 字段透明化。
二、官方示例解析:何时会触发多次下降?
让我们通过两个来自 PostgreSQL 官方文档的示例,直观感受 Index Searches 的作用。
示例 1:IN 子句触发的多值查找
考虑一个包含 IN 子句的查询:
EXPLAIN ANALYZE
SELECT * FROM tenk1 WHERE thousand IN (1, 500, 700, 999);
在 PostgreSQL 18 下,其执行计划可能如下:
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------
Bitmap Heap Scan on tenk1 (cost=9.45..73.44 rows=40 width=244) (actual time=0.012..0.028 rows=40.00 loops=1)
Recheck Cond: (thousand = ANY (‘{1,500,700,999}’::integer[]))
Heap Blocks: exact=39
Buffers: shared hit=47
-> Bitmap Index Scan on tenk1_thous_tenthous (cost=0.00..9.44 rows=40 width=0) (actual time=0.009..0.009 rows=40.00 loops=1)
Index Cond: (thousand = ANY (‘{1,500,700,999}’::integer[]))
Index Searches: 4 ← 4 个值,4 次索引下降
Buffers: shared hit=8
Planning Time: 0.029 ms
Execution Time: 0.034 ms
说明:IN (1, 500, 700, 999) 包含了 4 个离散值。因此执行器对索引进行了 4 次独立的下降,每次定位一个值。如果不这么做,而是进行一次大范围的扫描(从1扫到999),虽然 Index Searches 显示为 1,但需要读取的缓冲区会多得多。这个字段让原本“黑盒”的优化过程变得可见。
补充:你还可以通过系统视图 pg_stat_user_indexes 的 idx_scan 列,观察到这些索引下降次数的累积统计。
示例 2:范围条件与 Skip Scan
这个例子展示了跳跃扫描在复合索引上的应用:
EXPLAIN ANALYZE
SELECT four, unique1 FROM tenk1
WHERE four BETWEEN 1 AND 3 AND unique1 = 42;
假设存在一个索引 (four, unique1),执行计划可能为:
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------
Index Only Scan using tenk1_four_unique1_idx on tenk1 (cost=0.29..6.90 rows=1 width=8) (actual time=0.006..0.007 rows=1.00 loops=1)
Index Cond: ((four >= 1) AND (four <= 3) AND (unique1 = 42))
Heap Fetches: 0
Index Searches: 3 ← four 的值 1、2、3,各下降一次
Buffers: shared hit=7
Planning Time: 0.029 ms
Execution Time: 0.012 ms
说明:条件 four BETWEEN 1 AND 3 实际对应了三个值:1,2,3。由于 unique1=42 的行很可能分散在这三个 four 值对应的不同索引区域中,PostgreSQL 18 的 skip scan 机制会选择为每个 four 值执行一次独立的下降(共3次),直接定位 (four, unique1) = (1, 42), (2, 42), (3, 42) 可能的位置。这比从 four=1 扫到 four=3 并过滤掉所有 unique1≠42 的行要高效得多。
三、动手实验:从实践看 Index Searches 的影响
理论需要数据验证。下面我们通过一个完整的例子,演示不同索引设计和查询写法如何影响 Index Searches 及最终性能。
建表与准备数据
首先创建一个测试表并插入数据:
CREATE TABLE example (
integer_field bigint NOT NULL,
boolean_field bool NOT NULL
);
INSERT INTO example (integer_field, boolean_field)
SELECT
random() * 10000,
random() < 0.5
FROM generate_series(1, 100000);
-- 创建一个“非理想”的索引:将低基数的布尔列放在前面
CREATE INDEX bool_int_idx ON example (boolean_field, integer_field);
VACUUM ANALYZE example;
表结构说明:
- 10万行数据。
boolean_field 基数很低,只有 true/false 两个值,各约5万行。
integer_field 基数高,是0-10000的随机整数。
- 索引
bool_int_idx 将低基数列放在首位,这通常不利于仅按高基数列过滤的查询。
场景 1:PostgreSQL 17 下的“笨”扫描
在 PostgreSQL 17(不支持 skip scan)上运行一个仅按高基数列过滤的查询:
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS)
SELECT boolean_field FROM example WHERE integer_field = 5432;
输出如下:
Index Only Scan using bool_int_idx on public.example
(cost=0.29..1422.39 rows=10 width=1) (actual time=0.579..1.931 rows=18 loops=1)
Output: boolean_field
Index Cond: (example.integer_field = 5432)
Heap Fetches: 0
Buffers: shared hit=168 ← 读了整个索引(168 × 8KB = 1344KB)
Planning Time: 0.197 ms
Execution Time: 1.976 ms
结果只返回了18行,却因为索引设计问题,被迫扫描了整个索引(读取168个缓冲区,约1.3MB),效率极低。此时还没有 Index Searches 字段。
场景 2:PostgreSQL 18 的 Skip Scan 优化
同样的查询,在 PostgreSQL 18 中运行:
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS)
SELECT boolean_field FROM example WHERE integer_field = 5432;
输出发生了显著变化:
Index Only Scan using bool_int_idx on public.example
(cost=0.29..13.04 rows=10 width=1) (actual time=0.230..0.274 rows=5.00 loops=1)
Output: boolean_field
Index Cond: (example.integer_field = 5432)
Heap Fetches: 0
Index Searches: 4 ← 新出现的字段
Buffers: shared hit=9 ← 从 168 降至 9
Planning Time: 0.240 ms
Execution Time: 0.323 ms ← 执行时间大幅缩短
性能提升立竿见影:
- 缓冲区读取:从 168 个骤降至 9 个。
- 执行时间:从 1.976ms 减少到 0.323ms。
- 新增字段:出现了
Index Searches: 4。
为什么是4次? 这涉及到边界条件和 NULL 值的处理。在执行 skip scan 时,为了确保逻辑的完备性,引擎可能需要为 true, false 以及可能的边界情况(如 -infinity, +infinity)分别执行下降。根据 PostgreSQL 核心贡献者 Peter Geoghegan 的解释,这会导致次数可能比预期的值(2个)多1到2次。
场景 3:显式枚举,引导优化器
如果我们明确告诉数据库 boolean_field 只可能取 true 或 false,可以帮它做出更优决策:
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS)
SELECT boolean_field FROM example
WHERE integer_field = 5432
AND boolean_field IN (true, false);
输出:
Index Only Scan using bool_int_idx on public.example
(cost=0.29..8.79 rows=10 width=1) (actual time=0.060..0.077 rows=12.00 loops=1)
Output: boolean_field
Index Cond: ((example.boolean_field = ANY (‘{t,f}’::boolean[])) AND (example.integer_field = 5432))
Heap Fetches: 0
Index Searches: 2 ← 精确枚举后降为 2 次
Buffers: shared hit=5 ← 进一步减少
Planning Time: 0.265 ms
Execution Time: 0.115 ms
通过 IN (true, false) 显式枚举所有可能值,PostgreSQL 知道无需考虑其他边界,直接对这两个值各做一次下降即可。Index Searches 减少到 2,缓冲区和执行时间进一步优化。这个优化在 PostgreSQL 17 中就已引入。
场景 4:终极方案:设计最优索引
如果你的应用场景允许,为这个关键查询创建一个专用的、列顺序最优的索引,永远是性能提升的最佳途径。在 数据库 性能调优中,合理的索引设计是基础。
CREATE INDEX int_bool_idx ON example (integer_field, boolean_field);
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS)
SELECT boolean_field FROM example WHERE integer_field = 5432;
输出:
Index Only Scan using int_bool_idx on public.example
(cost=0.29..4.47 rows=10 width=1) (actual time=0.042..0.047 rows=12.00 loops=1)
Output: boolean_field
Index Cond: (example.integer_field = 5432)
Heap Fetches: 0
Index Searches: 1 ← 单次下降,最优
Buffers: shared hit=3 ← 只需 3 个缓冲区
Planning Time: 0.179 ms
Execution Time: 0.078 ms ← 最快
将高基数的 integer_field 放在索引首位后,所有 integer_field = 5432 的行在索引中物理上紧密相邻。查询只需一次索引下降,读取最少的缓冲区(3个),获得了最快的执行速度。
四个场景性能对比总结:
| 场景 |
PostgreSQL 版本 |
Index Searches |
Buffers |
执行时间 |
| bool_int_idx,无显式条件 |
17 |
—(无此字段) |
168 |
1.976ms |
| bool_int_idx,无显式条件 |
18(skip scan) |
4 |
9 |
0.323ms |
| bool_int_idx,显式枚举 IN(true,false) |
18 |
2 |
5 |
0.115ms |
| int_bool_idx(列顺序调换) |
18 |
1 |
3 |
0.078ms |
四、Index Searches 的实践意义与建议
看到 Index Searches 大于 1,我们该如何解读?
它本身不是绝对的“好”或“坏”指标。
- 对于追求极致性能的关键查询:如果
Index Searches > 1,它提示你当前的索引可能不是最优设计。数据没有在索引中“共处一室”(colocated),迫使数据库执行多次查找。此时,考虑调整索引列顺序,让查询条件列前置,是实现 Index Searches: 1 和最佳性能的终极手段。
- 对于日常查询或刚升级到 PG18:
Index Searches > 1 通常是 好事!它表明 PostgreSQL 正在利用 skip scan 或多值下降优化,智能地避免全索引扫描,用多次精准定位换取总体 I/O 的下降。这正是数据库自我优化的体现。
在实际的 索引优化 工作中,我们应综合看待这个新字段:
- 优化重要查询时:将
Index Searches 作为一个新的观察维度。结合 Buffers(缓冲区读取量)和 Rows Removed by Filter(过滤掉的行数)等传统指标,更全面地评估索引效率。
- 评估 PG18 升级收益:如果你的业务中有大量类似“场景1”的查询(用非首列条件过滤),可以在测试环境中验证 PG18 的 skip scan 能带来多大的性能提升。
- 审视索引策略:在排查和清理冗余索引时,可以将“列相同但顺序不同”的索引也纳入评估范围。思考在 PG18 skip scan 的加持下,是否有可能合并或删除某些索引,同时保证读性能不受影响。
Index Searches 字段的引入,是 PostgreSQL 朝着执行计划更透明、诊断信息更丰富方向迈出的又一步。它为我们深入理解查询如何与索引交互打开了一扇新的窗口。希望本文能帮助你更好地利用这个工具,优化你的数据库应用。如果你想与更多开发者交流数据库使用心得,欢迎来到 云栈社区 分享你的见解。
测试说明:本文所有示例均来自原文,实际结果会因 PostgreSQL 版本(Index Searches 字段仅在 PostgreSQL 18 及以上的 EXPLAIN ANALYZE 输出中出现,skip scan 功能也需要 PG18)、数据分布、统计信息更新情况、VACUUM 执行情况及硬件环境等因素而有所不同。建议在测试环境中使用 EXPLAIN (ANALYZE, BUFFERS, VERBOSE) 实际验证,不要将示例数值直接用于生产环境的性能判断。