在 PostgreSQL 18 中,你会在 EXPLAIN ANALYZE 的输出结果中看到 “Index Searches”(索引搜索次数)相关行。如果你和我一样好奇这些行到底是什么意思,那这篇文章就是为你准备的。
标准情况
标准情况是 “Index Searches: 1”,表示对索引进行一次遍历。如果所需数据都集中在索引的同一区域,这种情况可能非常高效。但如果所需条目并非集中存储,那么扫描过程中会包含大量不满足查询条件的条目,效率就会很低。后面会详细说明这一点!
当索引搜索次数大于 1 时是什么情况
在 Postgres 17 中,有一项不错的优化,允许“让 btree 索引更高效地查找一组值,例如 IN 子句提供的值”。这项优化基于 Postgres 9.2 中“让 btree 原生处理 ScalarArrayOpExpr 子句”以及更早时针对(仅限)位图索引扫描的优化成果。
文档中提供了一个示例,位图索引扫描的结果显示索引被搜索了 4 次,IN 列表中的每个值对应一次搜索。以下是 PostgreSQL 18 中的输出结果,能看到 Index Searches 字段:
EXPLAIN ANALYZE
SELECT * FROM tenk1 WHERE thousand IN (1, 500, 700, 999);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 (''::integer[]))Heap Blocks: exact=39Buffers: 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 (''::integer[]))Index Searches: 4Buffers: shared hit=8Planning Time: 0.029 msExecution Time: 0.034 ms
在这种情况下,一次索引搜索(针对同一个索引)需要扫描更多的缓冲区,因为它还需要扫描包含 1 到 999 之间未列出的其他 995 个值的页面。
在此之前,我们无法从 EXPLAIN ANALYZE 的输出结果中确定是否使用了该优化。我们只能通过一些线索推测,比如执行时间缩短、缓冲区使用减少,但无法获得明确的索引搜索次数。不过,你可以在几个系统视图中查看相关数据,例如 pg_stat_user_indexes 表中的 idx_scan 列会统计这些单独的索引搜索次数。
在 PostgreSQL 18 中,除了在 EXPLAIN 输出中添加索引搜索次数字段外,还新增了对 btree 树索引 “跳跃扫描”(skip scans)的支持。
文档中同样提供了一个清晰的示例,仅索引扫描的结果显示索引被搜索了 3 次,范围条件中的每个值对应一次搜索:
EXPLAIN ANALYZE
SELECT four, unique1 FROM tenk1 WHERE four BETWEEN 1 AND 3
AND unique1 = 42;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: 0Index Searches: 3Buffers: shared hit=7Planning Time: 0.029 msExecution Time: 0.012 ms
请注意,尽管 1、2 和 3 是列 “four” 的连续值,但它们对应的 unique1=42 的记录在按 (“four", "unique1") 这个顺序建立的索引中,(极大概率)不会互相靠近。因此,使用 3 次单独的索引下降(descent)来获取它们,会比一次性扫描整个索引高效得多。多次下降的开销远远低于“扫描大量 unique1 <> 42 的无用元组”所带来的低效。当然,如果下降次数变得非常多,这种优势就会减弱。因此,当第一列中的值相对较少,且 WHERE 条件非常严格时,这种优化效果最为显著。
我特别喜欢这类优化 —— 它们无需我们做任何改动,就能利用现有索引加速已有的查询!
增加索引搜索是好是坏?
一般来说,最高效的扫描是对最优索引进行一次遍历,这样能最大限度地减少缓冲区读取次数。
但是,为每个查询都建立最优索引的做法并不理想,因为每增加一个索引都会带来一定的代价。这些代价包括(但不限于)写入放大、丢失热点更新(针对之前未建立索引的列)以及共享缓冲区空间竞争加剧。
因此,如果您正在优化一个重要的查询,并且愿意为其创建和维护索引,那么 “索引搜索次数> 1” 可能意味着存在更优的解决方案。
一个简单的例子
以下是我认为最简单的演示方法:
CREATE TABLE example (integer_field bigint NOT NULL,boolean_field bool NOT NULL);INSERT INTO example (integer_field, boolean_field)SELECT random () * 10_000,random () < 0.5FROM generate_series(1, 100_000);CREATE INDEX bool_int_idxON example (boolean_field, integer_field);VACUUM ANALYZE example;
因此,我们创建了一个包含两列的表,插入了 10 万行数据。其中一列的基数很低(布尔值均匀分布),另一列的基数高得多(0 到 10k 之间的随机整数)。
我们在两列上都添加了索引,布尔列排在前(列的顺序很重要)。最后,我们运行了 VACUUM ANALYZE 命令来更新可见性映射并收集统计信息。
如果我们现在运行一个仅根据索引中的第二列进行筛选的查询,我们预计在 Postgres 18 中使用跳跃扫描会得到一个效率更高的查询计划。
如果先在 Postgres 17 上运行,我们会得到以下查询计划:
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS)
SELECT boolean_field FROM example WHERE integer_field = 5432;QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------------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_fieldIndex Cond: (example.integer_field = 5432)Heap Fetches: 0Buffers: shared hit=168Planning Time: 0.197 msExecution Time: 1.976 ms
虽然我们执行的是仅索引扫描,但请注意,它读取了 168 个缓冲区,却只返回了 18 行。它实际上是在扫描我们的整个索引(168 * 8KB = 1344KB)。
SELECT pg_size_pretty(pg_indexes_size('example'));pg_size_pretty
----------------1344 kB
如果我们在 Postgres 18 上运行同样的操作,会得到以下查询计划:
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS)
SELECT boolean_field FROM example WHERE integer_field = 5432;QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------------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_fieldIndex Cond: (example.integer_field = 5432)Heap Fetches: 0Index Searches: 4Buffers: shared hit=9Planning Time: 0.240 msExecution Time: 0.323 ms
有三点需要注意:
- 缓冲区使用量大幅减少,从 168 个降至 9 个
- 执行时间更短(得益于更少的缓冲区读取)
- 索引搜索次数:4
这是一项很棒的优化,能更高效地利用索引!
等等,为什么会有四次索引搜索?可能你和我一样,原本以为只有两次,TRUE 和 FALSE 各一次。我一开始也百思不得其解,最后在性能邮件列表里提问。感谢 Peter Geoghegan 的解答。原来,在一般情况下,边界条件和 NULL 值(当然!)总是需要考虑的,所以当无法排除这些情况时,就会产生一到两次额外的索引搜索。
由于我知道这些优化非常灵活,我想知道是否可以通过显式筛选“仅”包含 TRUE 或 FALSE 的值来进行两次索引搜索:
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS)
SELECT boolean_field FROM example WHERE integer_field = 5432
AND boolean_field IN (true, false);QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------------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_fieldIndex Cond: ((example.boolean_field = ANY (''::boolean[])) AND (example.integer_field = 5432))Heap Fetches: 0Index Searches: 2Buffers: shared hit=5Planning Time: 0.265 msExecution Time: 0.115 ms
搞定!现在我们只进行了两次索引搜索,这正是我们所期望的。这使得缓冲区读取次数更少(仅 5 次),执行速度也更快。这得益于 Postgres 17 的优化工作。但是修改查询并非总是可行,如果我们认为原始查询对我们的工作负载至关重要,我们很乐意为其添加一个最优索引。这样能做得更好吗?
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;QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------------------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_fieldIndex Cond: (example.integer_field = 5432)Heap Fetches: 0Index Searches: 1Buffers: shared hit=3Planning Time: 0.179 msExecution Time: 0.078 ms
由于我们新索引的列顺序颠倒了,相关的元组现在位于同一位置,这意味着扫描可以高效地执行一次索引下降(索引搜索:1),从而产生最少的缓冲区读取(3),并因此实现最快的执行速度。
以下是我尝试用可视化方式展现列的顺序如何影响条目的共存位置:

最后,这里是通过 pgMustard 保存和可视化的最后四个查询计划。
我们是否已开始使用索引搜索来获取提示信息?
到目前为止,我们还没有直接将索引搜索次数用于 pgMustard 的优化建议中。但在某些有帮助的场景下,会在 “操作详情” 中显示该字段。
当索引扫描效率特别低时,如果缓冲区读取次数远多于返回的行数,您仍然会看到“读取效率”提示;如果 Postgres 报告说很大比例的行正在被过滤,您仍然会看到“索引效率”提示。
一旦我们了解到实际应用中这类问题的常见程度,以及优化潜力的大小,可能会添加更具体的相关建议!
一些实用建议
首先,如果你在优化重要查询时发现 “索引搜索次数> 1”,可能存在更适合该查询的索引定义。
我的主要建议是,仍然要关注所有常规事项,例如筛选行、缓冲区和计时。
如果您认为某些不太重要(或优化程度较低)的查询可能会受益于这些改进,请考虑升级到(或至少测试)Postgres 18。
如果你愿意,现在或许可以减少索引的数量。可以先扩大搜索范围,查找冗余/重叠的索引,包括那些列相同但顺序不同的索引。这样或许可以删除一两个索引,而对读取延迟的影响在可接受的范围内。
作者:Michael Christofides
原文链接:
https://www.pgmustard.com/blog/what-do-index-searches-in-explain-mean