PostgreSQL 教程: 优化索引扫描

二月 12, 2025

摘要:在本教程中,您将学习如何在 PostgreSQL 中优化索引扫描。

目录

介绍

人们在查看查询计划时,经常会出现的一个问题是:检查到所有的扫描都使用了索引,因此会认为查询可能已经尽可能的快了。

很多人都知道,一个 Seq Scan 下面 Rows Removed by Filter 的数字大,表明可以用一个索引进行优化。但是,对于带有大量过滤行数的其他扫描,同样可以用一个索引来加快速度,可以采用更合适的索引来加快速度!虽然我们经常会看到索引过滤数显示为 “Rows Removed by Filter”,但遗憾的是,有些情况目前 EXPLAIN 还没有报告出来。

在本教程中,我们将介绍几个示例、一些需要注意的指标,以及如何加快速度。

请注意,还有其他的场景,可以进一步优化索引的使用。一个这样的例子是,当 Rows Removed by Index Recheck 的数字很大时。

初始设置

让我们创建一个简单的示例表,来进行演示:

CREATE TABLE example (
   id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
   a smallint NOT NULL,
   b smallint NOT NULL);

INSERT INTO example (a, b)
   SELECT random (1, 1_000), random (1, 1_000)
   FROM generate_series(1, 1_000_000);

VACUUM ANALYZE example;

SELECT * FROM example LIMIT 5;

 id |  a  |  b
----+-----+-----
  1 | 803 | 627
  2 | 978 | 702
  3 |  15 | 506
  4 | 966 | 335
  5 | 247 | 172

因此,我们总共有了 100 万行,有一个整型主键列,还有额外的两列 a 和 b,其中包含了 1 到 1000 之间的随机整数。

注意 Rows Removed by Filter

让我们看一下几种索引的方式,用于优化一个同时筛选 a 和 b 的简单查询:

CREATE INDEX example_a_idx ON example (a);

EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS)
SELECT id FROM example WHERE a = 42 AND b = 42;

                                                         QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on public.example  (cost=11.86..2542.34 rows=1 width=8) (actual time=4.024..4.489 rows=1 loops=1)
   Output: id
   Recheck Cond: (example.a = 42)
   Filter: (example.b = 42)
   Rows Removed by Filter: 1016
   Heap Blocks: exact=925
   Buffers: shared hit=928
   ->  Bitmap Index Scan on example_a_idx  (cost=0.00..11.86 rows=991 width=0) (actual time=0.407..0.407 rows=1017 loops=1)
         Index Cond: (example.a = 42)
         Buffers: shared hit=3
 Planning Time: 0.143 ms
 Execution Time: 4.538 ms

使用在 a 列上的一个索引,Postgres 选择了执行 Bitmap Index Scan,以查找 a=42 的行,然后对包含这些行的页面进行 Bitmap Heap Scan,筛选出 b 等于 42 的行。

虽然这总共只花了大约 5 毫秒。我们可以看到一个过滤条件删除了 1016 行,但最终只返回了 1 行 — 并不算很高效!

另一个线索(依据 Buffers 行)是 Postgres 总共必须读取 928 个块(约 7MB),其中除了 3 个之外,其余所有块都来自 Bitmap Heap Scan 阶段。

选择普通索引扫描

普通的索引扫描会比这种位图扫描更好吗?

让我们来尝试关闭位图扫描,然后再次运行查询:

SET enable_bitmapscan = off;

EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS) 
SELECT id FROM example WHERE a = 42 AND b = 42;

                                                           QUERY PLAN                                                            
---------------------------------------------------------------------------------------------------------------------------------
 Index Scan using example_a_idx on public.example  (cost=0.42..3655.99 rows=1 width=8) (actual time=1.438..1.682 rows=1 loops=1)
   Output: id
   Index Cond: (example.a = 42)
   Filter: (example.b = 42)
   Rows Removed by Filter: 1016
   Buffers: shared hit=928
 Settings: enable_bitmapscan = 'off'
 Planning Time: 0.144 ms
 Execution Time: 1.706 ms

这样更快了,大约 2 毫秒,但我们仍然可以看到这 1016 行被一个过滤条件删除了。Buffers 行还显示出,我们仍然读取了 928 个块。所以,这两点都是一个很好的迹象,表明我们还可以优化得更好。

建立多个索引

如果我们在 b 列上也有一个索引呢?

RESET enable_bitmapscan;

CREATE INDEX example_b_idx ON example (b);

EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS) 
SELECT id FROM example WHERE a = 42 AND b = 42;

                                                            QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on public.example  (cost=24.00..28.01 rows=1 width=8) (actual time=0.674..0.676 rows=1 loops=1)
   Output: id
   Recheck Cond: ((example.a = 42) AND (example.b = 42))
   Heap Blocks: exact=1
   Buffers: shared hit=8
   ->  BitmapAnd  (cost=24.00..24.00 rows=1 width=0) (actual time=0.652..0.653 rows=0 loops=1)
         Buffers: shared hit=7
         ->  Bitmap Index Scan on example_a_idx  (cost=0.00..11.86 rows=991 width=0) (actual time=0.259..0.259 rows=1017 loops=1)
               Index Cond: (example.a = 42)
               Buffers: shared hit=3
         ->  Bitmap Index Scan on example_b_idx  (cost=0.00..11.89 rows=995 width=0) (actual time=0.208..0.208 rows=991 loops=1)
               Index Cond: (example.b = 42)
               Buffers: shared hit=4
 Planning Time: 0.143 ms
 Execution Time: 0.706 ms

我们现在减少到了不到 1 毫秒,但至关重要的是,Buffers 行的数字要小多了(8 个块,即 64KB)!这一次,Postgres 在对每个结果中的匹配行执行交集运算之前,先单独地扫描了每个索引,以查找同时匹配两个条件的行。

采用多列索引

虽然没有再出现明确的 “Rows Removed by Filter” 字段,但这些位图索引扫描还是读取了一些最终未返回的索引条目。让我们来看看是否还可以优化得更好,通过尝试在 (a, b) 上创建一个复合索引,它将会在单个索引结构中,将我们需要的条目彼此相邻地排序:

CREATE INDEX example_a_b_idx ON example (a, b);

EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS) 
SELECT id FROM example WHERE a = 42 AND b = 42;

                                                           QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------------
 Index Scan using example_a_b_idx on public.example  (cost=0.42..8.45 rows=1 width=8) (actual time=0.032..0.033 rows=1 loops=1)
   Output: id
   Index Cond: ((example.a = 42) AND (example.b = 42))
   Buffers: shared hit=4
 Planning Time: 0.160 ms
 Execution Time: 0.058 ms

选择只用索引的扫描

我们减少到了总共 4 次缓冲页命中数,而且速度要快得多!最后一次尝试,让我们尝试在 (a, b, id) 上创建一个复合索引,以尝试获得只用索引的扫描:

CREATE INDEX example_a_b_id_idx ON example (a, b, id);

EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS)
SELECT id FROM example WHERE a = 42 AND b = 42;

                                                               QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------------
 Index Only Scan using example_a_b_id_idx on public.example  (cost=0.42..4.44 rows=1 width=8) (actual time=0.029..0.031 rows=1 loops=1)
   Output: id
   Index Cond: ((example.a = 42) AND (example.b = 42))
   Heap Fetches: 0
   Buffers: shared hit=4
 Planning Time: 0.115 ms
 Execution Time: 0.046 ms

尽管避免了对堆表的访问,我们仍然总共读取了 4 个块(可能是因为索引稍微大了),因此查询并没有快多少。

从第一个查询计划(已经非常快了,并且使用一个索引)来看,我们仍然设法在执行时间和读取的块数方面,实现了几个数量级的提升,主要是因为,通过尝试准确地获取到我们需要的数据,而无需读入和过滤行。

因此,回顾一下,无论扫描类型如何,如果 Postgres 必须读取的数据超出了其需要的数据,而必须将其过滤掉,则可能会有更有效的方法。请注意 Rows Removed by Filter。

注意循环次数

重要的一点是,Rows Removed by Filter 字段是每次循环的平均值,四舍五入到最接近的整数。在上面的示例中,我们总是看到 “loops=1”,所以这无关紧要,但在其他查询中,尤其是那些涉及 Nested Loops 的查询,这可能是一个巨大的影响因素。

想象一下,你看到 “actual […] rows=1 loops=10000” 和 “Rows Removed by Filter: 5”。这意味着返回了大约 10000 行,但为了执行该操作,会另外读取和筛选掉 50000 行。5 比 1 已经不是一个很好的效率比,但是当乘以 10000 时,可能节省出来的时间会很有潜力!

因此,如果你看到 Rows Removed by Filter 的行数很小,但 loops 的次数很大,那么可能仍然有很大的提升空间(同样可以使用更好的索引来实现)。

调整索引列顺序

由于 PostgreSQL 不支持 Index Skip Scan,因此 B 树索引中的列顺序对于性能极为重要。

筛选单个列时的列顺序

一个常见的示例是,在 (a, b) 上的索引不能高效地用于只筛选 b 列的查询。为了演示,让我们删除只在 b 列上创建的另一个索引,并关闭顺序扫描:

DROP INDEX example_b_idx;

SET enable_seqscan = off;

EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS)
SELECT id FROM example WHERE b = 42;

                                                            QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on public.example  (cost=17132.67..19669.35 rows=995 width=8) (actual time=38.616..39.629 rows=991 loops=1)
   Output: id
   Recheck Cond: (example.b = 42)
   Heap Blocks: exact=909
   Buffers: shared hit=3308
   ->  Bitmap Index Scan on example_a_b_idx  (cost=0.00..17132.42 rows=995 width=0) (actual time=38.443..38.443 rows=991 loops=1)
         Index Cond: (example.b = 42)
         Buffers: shared hit=2399
 Settings: enable_seqscan = 'off'
 Planning Time: 0.304 ms
 Execution Time: 39.736 ms

这样确实使用到了我们的 example_a_b_idx 索引,但花费了近 40 毫秒,总共读取了 3308 个块(约 26MB)。

让我们尝试添加一个顺序为 (b, a) 的索引,看看它在用于同一个查询时的效率有多高:

RESET enable_seqscan;

CREATE INDEX example_b_a_idx ON example (b, a);

EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS) 
SELECT id FROM example WHERE b = 42;

                                                         QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on public.example  (cost=20.14..2556.81 rows=995 width=8) (actual time=0.649..3.212 rows=991 loops=1)
   Output: id
   Recheck Cond: (example.b = 42)
   Heap Blocks: exact=909
   Buffers: shared hit=915
   ->  Bitmap Index Scan on example_b_a_idx  (cost=0.00..19.89 rows=995 width=0) (actual time=0.393..0.394 rows=991 loops=1)
         Index Cond: (example.b = 42)
         Buffers: shared hit=6
 Planning Time: 0.232 ms
 Execution Time: 3.406 ms

这花了不到 4 毫秒的时间,总共读取了 915 个块(约 7MB) — 好多了!

遗憾的是,Postgres 在上面的效率较低的计划中没有报告出任何的 “Rows Removed by Filter”,尽管这是在位图索引扫描期间实际发生了的事情。我们目前也无法从 Index Cond 行中判断出它是否被高效地使用了,但执行时间、成本和缓冲页数都提供了很好的线索。

筛选多个列时的列顺序

列顺序对于要筛选一个索引中所有条件的查询也非常重要。

一个简洁的示例是,如果其中一个筛选条件是一个范围条件的时候。为了演示,让我们尝试一个查询,该查询应该会顺利地使用到我们在 (b, a) 上建立的新索引:

EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS)
SELECT id FROM example WHERE b = 42 AND a < 100;

                                                        QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on public.example  (cost=5.44..362.73 rows=99 width=8) (actual time=0.128..0.502 rows=116 loops=1)
   Output: id
   Recheck Cond: ((example.b = 42) AND (example.a < 100))
   Heap Blocks: exact=115
   Buffers: shared hit=118
   ->  Bitmap Index Scan on example_b_a_idx  (cost=0.00..5.42 rows=99 width=0) (actual time=0.073..0.074 rows=116 loops=1)
         Index Cond: ((example.b = 42) AND (example.a < 100))
         Buffers: shared hit=3
 Settings: enable_seqscan = 'off'
 Planning Time: 0.304 ms
 Execution Time: 0.577 ms

尽管这返回了 116 行,但它的运行时间不到 1 毫秒,读取了 118 个块(约 1MB)。

让我们删除在 (b, a) 上建立的索引,并关闭顺序扫描,看看在 (a, b) 上的索引,在相同查询上表现的效率如何:

DROP INDEX example_b_a_idx;

EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS)
SELECT id FROM example WHERE b = 42 AND a < 100;

                                                          QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on public.example  (cost=1955.74..2313.03 rows=99 width=8) (actual time=4.721..5.123 rows=116 loops=1)
   Output: id
   Recheck Cond: ((example.a < 100) AND (example.b = 42))
   Heap Blocks: exact=115
   Buffers: shared hit=355
   ->  Bitmap Index Scan on example_a_b_idx  (cost=0.00..1955.71 rows=99 width=0) (actual time=4.623..4.624 rows=116 loops=1)
         Index Cond: ((example.a < 100) AND (example.b = 42))
         Buffers: shared hit=240
 Planning Time: 0.305 ms
 Execution Time: 5.200 ms

这花了 5 毫秒多一点,并读取了 355 个块(约 3MB),来运行相同的查询。

根据数据的分布情况和其他因素(如 LIMIT 子句),这种差异可能会变得更加极端,因此密切关注索引中的列顺序非常重要!

总结

虽然我们没有对给定的查询,讨论到索引扫描可能表现低效的每种场景,但我们已经涵盖了最常见的情况。

现在,您至少对查询计划中需要注意的事项有了更多的想法,从而能够诊断下面这些类型的低效情况:

  1. Rows Removed by Filter 的行数大,即使是在索引扫描类型上
  2. Loops 的次数乘以 Rows Removed by Filter 的行数,结果值大
  3. Buffers 行的数字大,尤其是与返回的行数相比
  4. 索引中的列顺序,以及它是否非常适合对应的查询

最重要的一点是,如果您只是看到了 “Index Scan” 一词的时候,这并不意味着扫描的速度已经尽可能快了!

了解更多

PostgreSQL 优化