本章目录

第 3 章:访问方法

3.1 顺序扫描

规划器可以通过多种方式访问表中的数据,最简单的一种是顺序扫描 (Seq Scan)。

顺序扫描逐页读取表的主分支。与通常的表访问一样,普通表通过共享缓冲区缓存读取;临时表则使用当前会话的本地缓冲区。

顺序扫描逐页读取表的主分支,其中也可能包含尚未清理的死行版本。
顺序扫描逐页读取表的主分支,其中也可能包含尚未清理的死行版本。

在顺序扫描期间,PostgreSQL 可以利用操作系统的预读或异步 I/O,高效地预取相邻页面,避免发起彼此无关的随机读取。

当查询需要读取整张表或其中的大部分数据时,顺序扫描通常是一个不错的选择。也就是说,此时谓词的筛选能力较弱,大部分输入行都能通过过滤。

在查询计划中,顺序扫描由 Seq Scan 节点表示:

EXPLAIN SELECT * FROM flights;
                  QUERY PLAN
-------------------------------------------
 Seq Scan on flights
   (cost=0.00..744.71 rows=37671 width=51)
(2 rows)

括号中的几个值很重要:

  • cost 是预估成本。
  • rows 是预估的操作返回行数。
  • width 是预估的平均行宽,单位为字节。

成本以规划器内部的相对单位衡量,由两个数值表示:

  • 第一个数值是启动成本,即节点返回第一行之前必须付出的成本。顺序扫描不需要准备工作,因此启动成本为零。
  • 第二个数值是返回全部行的总成本。

PostgreSQL 的成本模型同时考虑 I/O 和 CPU 开销。在本例的简化计算中,I/O 部分等于表的页数乘以顺序读取一个页面的成本:

SELECT relpages,
  current_setting('seq_page_cost'),
  relpages * current_setting('seq_page_cost')::real AS total
FROM pg_class WHERE relname = 'flights';
 relpages | current_setting | total
----------+-----------------+-------
      368 | 1               |   368
(1 row)

CPU 部分包括处理表中各行的成本:

SELECT reltuples,
  current_setting('cpu_tuple_cost'),
  reltuples * current_setting('cpu_tuple_cost')::real AS total
FROM pg_class WHERE relname = 'flights';
 reltuples | current_setting | total
-----------+-----------------+--------
     37671 | 0.01            | 376.71
(1 row)

3.1.1 死行版本

执行顺序扫描时,PostgreSQL 必须检查每个页面中的行版本,包括已经过时、但尚未被清理的版本。1 如果表中存在大量死行版本,扫描开销可能会明显增加。

这也是及时清理可以改善某些查询性能的原因之一。2

创建 flights 的副本,并禁用自动清理,以免死行版本被自动清除:

CREATE TABLE flights_copy
WITH (autovacuum_enabled = false)
AS SELECT * FROM flights;
SELECT 37671

SELECT pg_size_pretty(
  pg_total_relation_size('flights_copy')
);
 pg_size_pretty
----------------
 3024 kB
(1 row)

删除所有行:

DELETE FROM flights_copy;
DELETE 37671

在 VACUUM 回收旧行版本之前,它们仍然保留在表文件中,因此文件大小没有变化:

SELECT pg_size_pretty(
  pg_total_relation_size('flights_copy')
);
 pg_size_pretty
----------------
 3024 kB
(1 row)

使用 analyze 选项查看查询计划。隐式启用的 buffers 选项会显示以下数据块计数:

  • hit:所需的数据块已在 PostgreSQL 的缓存中。
  • read:在缓冲区缓存中没有找到所需的数据块,需要向操作系统请求读取。
  • written:本后端进程写出了此前已经变脏的数据块。
  • dirtied:此前干净的数据块变成了脏块。
EXPLAIN (analyze, timing off, summary off)
SELECT count(*) FROM flights_copy;
                         QUERY PLAN
-------------------------------------------------------------
 Aggregate  (cost=673.89..673.90 rows=1 width=8) (actual...
   Buffers: shared hit=377
   -> Seq Scan on flights_copy  (cost=0.00..614.51 rows=...
        Buffers: shared hit=377
 Planning:
   Buffers: shared hit=3
(6 rows)

尽管表中已经没有活行,扫描仍然访问了三百多个堆页面。清理这张表,再检查它的大小:

VACUUM flights_copy;
SELECT pg_size_pretty(pg_total_relation_size('flights_copy'));
VACUUM
 pg_size_pretty
----------------
 24 kB
(1 row)

死行版本已被清除,表的大小也随之减小。再次执行查询:

EXPLAIN (analyze, timing off, summary off)
SELECT count(*) FROM flights_copy;
                         QUERY PLAN
-------------------------------------------------------------
 Aggregate  (cost=0.00..0.01 rows=1 width=8) (actual row...
   -> Seq Scan on flights_copy  (cost=0.00..0.00 rows=1 ...
 Planning:
   Buffers: shared hit=2
(4 rows)

现在表已经为空,没有需要检查的行版本,预估成本也降到了极低的水平。对于大得多的表,遍历那些充满过时行版本的页面可能会产生可观的开销。

DROP TABLE flights_copy;
DROP TABLE

3.1.2 缓冲区环与同步扫描

对大表进行顺序扫描,可能会让大量只使用一次的页面进入缓冲区缓存,并逐出原本有用的缓存页面。为避免这种情况,PostgreSQL 对足够大的堆扫描采用批量读取的缓冲区访问策略。当关系大小超过缓冲区缓存的四分之一时,只使用缓存中的一部分作为缓冲区环,在环内完成页面替换,其余缓存数据不受影响。

共享缓冲区中的缓冲区环:加入环的缓冲区、扫描读取的数据页,以及被逐出或刷到磁盘的脏缓冲区。
共享缓冲区中的缓冲区环:加入环的缓冲区、扫描读取的数据页,以及被逐出或刷到磁盘的脏缓冲区。

大表的顺序扫描还可以使用同步扫描。当多个后端进程扫描同一张大表时,新扫描可以从另一个扫描当前读取位置附近开始,先扫描到表尾,再回到表头继续。这样,多个后端进程就更有机会在相同页面仍驻留于缓冲区环时使用它们。

临时表使用本地缓冲区,因此上述缓冲区缓存访问策略和同步扫描机制不能以相同方式应用于临时表。

3.2 索引扫描

我们以 PostgreSQL 的 B 树索引为例,说明 Index Scan 的工作方式。对于标量数据,B 树是最常用的索引类型。

与 PostgreSQL 的其他索引一样,B 树是独立于表数据的辅助结构。它占用额外的磁盘空间,可以删除,也可以根据表中的数据重建。索引旨在降低只访问表中较少数据的操作成本;B 树索引还用于保证主键约束和唯一约束。

索引将被索引的值,也就是搜索键,与指向表中行版本的引用关联起来。B 树按照明确的排序规则组织键,使 PostgreSQL 能够沿树找到所需的键,再通过相应的元组标识符访问表。B 树操作符类必须为相应数据类型定义线性顺序。数字、字符串和日期都是典型的例子。相比之下,几何点没有一种适合通常 B 树语义的自然全序;PostgreSQL 为此类空间数据提供了其他索引类型。

PostgreSQL 的 B 树是平衡树,每个内部页面可以指向许多子页面。树的深度取决于键的大小和条目数量,但在实际使用中,即使索引很大,通常也只需要少数几层。

B 树结构:根页面、内部页面、叶子页面,以及叶子条目所引用的表页面。
B 树结构:根页面、内部页面、叶子页面,以及叶子条目所引用的表页面。

上图展示了一棵 B 树。索引页面由索引条目组成,每个条目包含:

  • 一个键,即被索引的键列中的值。
  • 指向另一个索引页面的向下链接;如果位于叶子层,则是一个或多个指向表中行版本的引用。

页面内的键按顺序排列。

叶子页面位于树的最底层,其条目引用表中的行版本。叶子页面之间通过双向链接连接,因此可以高效地向前或向后扫描键。

内部页面指向下一层的页面。分隔键用来判断所需的值可能位于哪棵子树中。最顶层的页面就是根页面。

B 树页面不必完全填满;保留空闲空间有利于后续插入。

当页面无法容纳一个新条目时,PostgreSQL 会分裂该页面。一半条目留在原页面中,另一半移到新分配的兄弟页面。B 树页面分裂后通常不会再合并,因此某些负载可能导致索引膨胀和碎片化。

默认情况下,升序 B 树索引将空值放在非空值之后,即 NULLS LAST。创建索引时,可以通过 NULLS FIRST 或 NULLS LAST 改变这一顺序,并按需结合 ASC 或 DESC 使用。

3.2.1 等值检索

假设我们要查找索引列值为 4 的行。

检索从树根开始。根页面中的索引条目界定了下层页面的键范围:“从 1 到 9”和“9 及以上”。我们需要前一个范围,它对应键为 1 的条目。由于键是有序的,页面内部的检索十分高效。

等值检索沿根页面、内部页面和叶子页面定位键 4,再访问其引用的表中行版本。
等值检索沿根页面、内部页面和叶子页面定位键 4,再访问其引用的表中行版本。

沿着该条目的引用,我们到达第二层页面。在这里找到“从 3 到 6”的范围 (键为 3),再进入第三层页面。这是一个叶子页面。找到键 4 后,就可以沿着它的引用访问表页面。

请注意,即使是唯一索引,也可能包含重复的键,因为 MVCC 可以为同一行生成多个版本。为了节省空间,相同的键可以在索引页面中合并为一个包含多个指针的条目。

返回堆元组之前,必须检查其可见性。

图中高亮显示了检索时需要访问的页面和条目。

以 bookings 表为例。book_ref 列是主键,因此 PostgreSQL 自动为它创建了唯一 B 树索引 bookings_pkey。

查看查找单个键值的执行计划:

EXPLAIN SELECT * FROM bookings WHERE book_ref = 'CDEFD8';
                    QUERY PLAN
---------------------------------------------
 Index Scan using bookings_pkey on bookings
   (cost=0.43..8.45 rows=1 width=21)
   Index Cond: (book_ref = 'CDEFD8'::bpchar)
(3 rows)

所选的访问方式是 Index Scan,计划中也给出了使用的索引名称。索引访问和堆访问由同一个计划节点表示。Index Cond 显示索引使用的条件。

对于这种检索,启动成本包括从树根向下定位到相应叶子页面的工作。这个预估值很小,因为规划器认为 B 树上层页面很可能已在缓存中,向下检索主要计入 CPU 开销。

总成本还包括预期的叶子页面和堆页面访问成本。

由于索引是唯一的,而且等值条件至多对应一条可见行,因此可以粗略地将本例的页面访问理解为:访问一个叶子页面,再访问一个堆页面。随机页面读取的成本由 random_page_cost 定义:

SELECT current_setting('random_page_cost');
 current_setting
-----------------
 4
(1 row)

它的默认值高于 seq_page_cost,反映了传统成本模型中非顺序访问通常具有更高成本的假设。使用 SSD,或者大部分数据已在缓存中时,常会缩小这两个参数之间的差距。

合适的取值取决于存储设备和实际负载,并不存在适用于所有 SSD 的统一规则。

Index Cond 包含能够驱动索引扫描,或能够在索引层检查的条件。

需要访问堆行才能检查的条件,会单独显示为 Filter:

EXPLAIN SELECT * FROM bookings
WHERE book_ref = 'CDEFD8' AND total_amount > 1000;
                    QUERY PLAN
---------------------------------------------
 Index Scan using bookings_pkey on bookings
   (cost=0.43..8.45 rows=1 width=21)
   Index Cond: (book_ref = 'CDEFD8'::bpchar)
   Filter: (total_amount > '1000'::numeric)
(4 rows)

3.2.2 范围检索

B 树不仅能高效地进行等值检索,也适用于有序范围条件:<、<=、>、>= 和 BETWEEN。

首先,找到条件的一个边界键。例如,对于从 4 到 9 的范围,可以从 4 或 9 开始;而对于“小于 9”的条件,则需要从 9 开始。随后像前面的例子一样,沿树向下到达叶子索引页面,取得表中的第一个值。接下来,根据条件沿叶子页面向右或向左移动,逐个检查条目,直到遇到范围之外的键。

范围检索 x BETWEEN 4 AND 9 沿叶子页面访问键 4 至 9,相应的堆页面可能被反复访问。
范围检索 x BETWEEN 4 AND 9 沿叶子页面访问键 4 至 9,相应的堆页面可能被反复访问。

图中展示了 x BETWEEN 4 AND 9 的检索过程,它等价于 x >= 4 AND x <= 9。PostgreSQL 沿树找到第一个符合条件的键,随后依次访问 5、6,直到 9。遇到 10 时,范围扫描结束。

这一过程依赖两个性质:所有页面内的键都有序,并且叶子页面构成双向链表。检索结果自然就按键的顺序排列。

注意,同一个表页面可能需要多次访问。检索值 4 时,先访问第一个表页面,接着访问最后一个页面中的另一个 4;检索值 5 时又回到第一个页面,检索值 6 时再回到最后一个页面,依此类推。

B 树提供的顺序可以省去单独的排序操作:

EXPLAIN (costs off)
SELECT * FROM bookings
WHERE book_ref > '000CTL' AND book_ref < '000FHR'
ORDER BY book_ref;
                         QUERY PLAN
-------------------------------------------------------------
 Index Scan using bookings_pkey on bookings
   Index Cond: ((book_ref > '000CTL'::bpchar) AND (book_...
(2 rows)

同一棵 B 树也可以反向扫描:

EXPLAIN (analyze, costs off, timing off, summary off)
SELECT * FROM bookings
WHERE book_ref > '000CTL' AND book_ref < '000FHR'
ORDER BY book_ref DESC;
                         QUERY PLAN
-------------------------------------------------------------
 Index Scan Backward using bookings_pkey on bookings (ac...
   Index Cond: ((book_ref > '000CTL'::bpchar) AND (book_...
   Index Searches: 1
   Buffers: shared hit=3 read=5
 Planning:
   Buffers: shared hit=13
(6 rows)

这里,扫描先定位到右侧合适的叶子位置,再沿叶子链表反向遍历。Buffers 显示页面访问情况,Index Searches 则表示发起了多少次独立的 B 树检索。

3.3 位图扫描

反复访问相同的堆页面,可能使常规索引扫描的成本变高。即使页面已在缓冲区缓存中,PostgreSQL 仍然需要定位并锁定该缓冲区 (pin),然后执行必要的元组处理。位图扫描将查找候选元组位置与访问堆页面分成两个阶段。

Bitmap Index Scan 根据索引条目构建位图,记录候选行版本在各个表页面中的位置。
Bitmap Index Scan 根据索引条目构建位图,记录候选行版本在各个表页面中的位置。

首先扫描索引 (Bitmap Index Scan),并在进程的本地内存中构建位图。位图由若干段组成,每个段对应一个表页面,段中的每一位对应页面中的一个行版本。构建位图时,将满足条件、需要读取的行版本标记出来。由于位图按段组织,当只有少数行版本被标记时,所占的空间也很少。

从概念上说,精确的位图条目记录堆页号,以及该页内的元组偏移。

Bitmap Heap Scan 根据位图访问堆页面,每个包含候选元组的页面只需访问一次。
Bitmap Heap Scan 根据位图访问堆页面,每个包含候选元组的页面只需访问一次。

位图构建完成后,Bitmap Heap Scan 开始访问堆页面。

一个堆页面中可能有多个候选元组,但页面本身只需要访问一次。当许多匹配的索引条目所引用的元组分布在较少的堆页面上时,这就是位图扫描相对于常规索引扫描的主要优势。

CREATE INDEX ON bookings(book_date);
CREATE INDEX ON bookings(total_amount);

上面创建的两个索引为规划器提供了不同的位图来源。对于返回较多行的查询,规划器选择位图扫描:

EXPLAIN SELECT *
FROM bookings WHERE total_amount < 5000;
                       QUERY PLAN
-------------------------------------------------------
 Bitmap Heap Scan on bookings
   (cost=6241.15..26259.60 rows=332996 width=21)
   Recheck Cond: (total_amount < '5000'::numeric)
   -> Bitmap Index Scan on bookings_total_amount_idx
        (cost=0.00..6157.90 rows=332996 width=0)
        Index Cond: (total_amount < '5000'::numeric)
(6 rows)

计划中有两个相关节点:

  • Bitmap Index Scan 读取索引并构建位图。
  • Bitmap Heap Scan 根据位图读取堆页面。

启动成本体现了这种方法的取舍:只有完整构建位图之后,才能使用它。位图访问放弃了常规索引扫描边读边返回结果的方式,换取更有序的堆访问。

3.3.1 合并位图

位图也可以合并多个索引条件。例如,同一个索引上的两个范围:

EXPLAIN (costs off)
SELECT * FROM bookings
WHERE total_amount < 5000
   OR total_amount > 500000;
                         QUERY PLAN
-------------------------------------------------------------
 Bitmap Heap Scan on bookings
   Recheck Cond: ((total_amount < '5000'::numeric) OR (t...
   -> BitmapOr
        -> Bitmap Index Scan on bookings_total_amount_idx
             Index Cond: (total_amount < '5000'::numeric)
        -> Bitmap Index Scan on bookings_total_amount_idx
             Index Cond: (total_amount > '500000'::numeric)
(7 rows)

PostgreSQL 构建两个位图,再通过 BitmapOr 将它们合并。

不同的索引也可以参与位图的合并:

EXPLAIN (costs off)
SELECT * FROM bookings
WHERE total_amount < 5000
   OR book_date = bookings.now() - INTERVAL '1 day';
                         QUERY PLAN
-------------------------------------------------------------
 Bitmap Heap Scan on bookings
   Recheck Cond: ((total_amount < '5000'::numeric) OR (b...
   -> BitmapOr
        -> Bitmap Index Scan on bookings_total_amount_idx
             Index Cond: (total_amount < '5000'::numeric)
        -> Bitmap Index Scan on bookings_book_date_idx
             Index Cond: (book_date = ('2026-03-01 03:00:0...
(7 rows)

3.3.2 有损位图页面

位图保存在后端进程的本地内存中,大小受 work_mem 限制;位图扫描不会将位图溢写到临时文件。

如果精确位图变得过大,PostgreSQL 可以将其中一部分转为有损表示。精确条目记录堆页面中各个元组的偏移;有损条目则只记住某个堆页面可能包含匹配行。

有损位图只记录需要访问的堆页面;图中精确段定位具体元组,有损段需要重新检查条件。
有损位图只记录需要访问的堆页面;图中精确段定位具体元组,有损段需要重新检查条件。

当 work_mem 很小、结果集又很大时,即使位图已经完全转为有损表示,也可能仍然放不下。在这种特殊情况下,PostgreSQL 可以超出 work_mem 的预算,分配足以表示所需堆页面的内存。

有损位图段节省了内存,但 Bitmap Heap Scan 处理这样的段时,必须重新检查相应表页面中每个行版本是否满足条件。

重新检查页面中每个行版本的条件会影响性能。如果合并两个位图时,其中至少一个位图的某个段是有损的,合并后对应的段也必须是有损的。因此,work_mem 的取值可能显著影响位图扫描的性能。

Bitmap Heap Scan 会显示需要重新检查的条件 (Recheck Cond)。这个条件并非总会重新检查;在这里,只有位图无法放入可用内存、精度丢失时,才需要重检。

先使用通常的内存设置:

EXPLAIN (analyze, costs off, buffers off, timing off)
SELECT * FROM bookings WHERE total_amount < 5000;
                         QUERY PLAN
-------------------------------------------------------------
 Bitmap Heap Scan on bookings (actual rows=332628.00 loo...
   Recheck Cond: (total_amount < '5000'::numeric)
   Heap Blocks: exact=15839
   -> Bitmap Index Scan on bookings_total_amount_idx (ac...
        Index Cond: (total_amount < '5000'::numeric)
        Index Searches: 1
 Planning Time: 0.029 ms
 Execution Time: 39.772 ms
(8 rows)

Heap Blocks: exact=15839 表明,所有堆页面对应的位图条目都保持了精确表示。

现在减少可用内存,再执行同一个查询:

SET work_mem = '512kB';
EXPLAIN (analyze, costs off, buffers off, timing off)
SELECT * FROM bookings WHERE total_amount < 5000;
                         QUERY PLAN
-------------------------------------------------------------
 Bitmap Heap Scan on bookings (actual rows=332628.00 loo...
   Recheck Cond: (total_amount < '5000'::numeric)
   Rows Removed by Index Recheck: 1121673
   Heap Blocks: exact=7581 lossy=8258
   -> Bitmap Index Scan on bookings_total_amount_idx (ac...
        Index Cond: (total_amount < '5000'::numeric)
        Index Searches: 1
 Planning Time: 0.053 ms
 Execution Time: 84.084 ms
(9 rows)

现在,一些堆页面由有损位图条目表示。Rows Removed by Index Recheck 显示重新检查条件时,有多少候选行因为不满足条件而被过滤掉。

恢复原来的设置:

RESET work_mem;

3.3.3 聚簇

当堆中行的物理顺序与索引顺序具有较高相关性时,常规索引扫描会更具优势。

此时,相邻的索引条目往往指向相同或相邻的堆页面,因此扫描所需的随机堆访问会大幅减少。

如果物理相关性很高,位图按页面组织访问的优势就可能大幅减弱,规划器也可能更倾向于选择常规索引扫描。

聚簇后的堆页面顺序与索引顺序相关,相邻索引条目倾向于引用相同或相邻的表页面。
聚簇后的堆页面顺序与索引顺序相关,相邻索引条目倾向于引用相同或相邻的表页面。

CLUSTER 命令可以按照某个索引的顺序重写表。最初,bookings 中的行并未按该索引的顺序存放,下面这个没有 ORDER BY 的查询展示了这一点:

SELECT * FROM bookings LIMIT 10;
 book_ref |           book_date            | total_amount
----------+--------------------------------+--------------
 2EW1SQ   | 2025-09-01 03:00:12.557744+03  |      8125.00
 3ZY3O5   | 2025-09-03 03:25:35.278453+03  |      7500.00
 756UAS   | 2025-09-10 19:24:02.978559+03  |      6325.00
 GC7I6S   | 2025-09-03 03:26:37.085293+03  |     18000.00
 RBUUIQ   | 2025-09-01 03:00:33.126206+03  |     13000.00
 GB7E53   | 2025-09-10 18:58:23.948989+03  |     11000.00
 6TAS39   | 2025-09-01 03:00:06.265219+03  |      5000.00
 UEX925   | 2025-09-03 03:27:39.815145+03  |     19550.00
 GGY5EU   | 2025-09-01 03:00:07.565193+03  |      5750.00
 MS148V   | 2025-09-03 03:29:12.70515+03   |      6750.00
(10 rows)

根据 total_amount 上的索引重新排列这些行。

我们在事务中执行这段代码,以便在最后回滚聚簇操作,不影响后续示例:

BEGIN;
CLUSTER bookings USING bookings_total_amount_idx;
CLUSTER

现在,普通的顺序读取恰好会按照新的物理顺序遇到这些行:

SELECT * FROM bookings LIMIT 10;
 book_ref |           book_date            | total_amount
----------+--------------------------------+--------------
 7ZHP9E   | 2025-09-10 19:44:37.68484+03   |      1750.00
 PZO1NI   | 2025-11-12 03:45:47.817622+03  |      1750.00
 6BC1L3   | 2025-09-03 05:32:45.971723+03  |      1750.00
 YQQ1XT   | 2025-09-24 03:35:58.864739+03  |      1750.00
 EQXEG8   | 2025-11-05 03:52:11.774956+03  |      1750.00
 BS0GMN   | 2025-09-17 03:36:35.340508+03  |      1750.00
 9HLHGP   | 2026-02-18 04:09:04.979717+03  |      1750.00
 SFR4P7   | 2025-10-15 03:37:58.510102+03  |      1750.00
 XDD0AF   | 2025-11-26 04:06:02.505752+03  |      1750.00
 BKVSNB   | 2025-10-02 07:44:39.551269+03  |      1750.00
(10 rows)

ANALYZE bookings;
EXPLAIN (costs off)
SELECT * FROM bookings WHERE total_amount < 5000;
                         QUERY PLAN
----------------------------------------------------------
 Index Scan using bookings_total_amount_idx on bookings
   Index Cond: (total_amount < '5000'::numeric)
(2 rows)

ROLLBACK;

上面的 ANALYZE 更新了统计信息,使规划器能够了解新的物理相关性。聚簇之前,这个谓词使用位图扫描;相关性提高后,常规索引扫描的成本更低。最后的 ROLLBACK 将表恢复,以便继续后面的示例。

实际使用时,有两点需要注意:

  • CLUSTER 会重写表,需要获取 ACCESS EXCLUSIVE 锁,这一点与其他全表重写操作相似。
  • PostgreSQL 不会自动维持聚簇顺序。后续插入和更新可能逐渐降低这种相关性。

3.4 覆盖索引

3.4.1 仅索引扫描

如果查询所需的值都能从某个索引中取得,那么这个索引就可以成为该查询的覆盖索引。

原则上,PostgreSQL 此时可以只访问索引就完成查询,而不访问堆。但 MVCC 的可见性检查使情况变得复杂。3 B 树叶子条目能够告诉 PostgreSQL 它引用了哪个表元组,却没有足够的可见性信息来证明该元组对当前快照可见。

对于已知全部可见的页面,可见性映射解决了这个问题。如果映射表明某个表页面中的所有元组都对所有事务可见,仅索引扫描就不必再沿叶子索引条目的引用访问这个堆页面。

仅索引扫描检查可见性映射:全部可见页面可以跳过堆访问,其他页面仍需读取堆元组检查可见性。
仅索引扫描检查可见性映射:全部可见页面可以跳过堆访问,其他页面仍需读取堆元组检查可见性。

因此,清理对仅索引扫描很重要。更新和删除会在必要时清除页面的全部可见状态,而 VACUUM 检查页面后,可以重新设置全部可见位。

规划器无法确切知道执行时会发生多少次堆元组访问。估算仅索引扫描的成本时,它使用关系的统计信息,其中包括全部可见页面比例的估算值。如果预计需要访问太多堆页面,规划器可能会认为其他访问方式更便宜。

对于每个匹配的索引条目,执行器都会检查可见性映射。如果相应堆页面全部可见,就无需访问堆;否则,必须读取堆元组来检查可见性。即使需要部分堆访问,计划节点仍然叫作 Index Only Scan。

EXPLAIN (analyze, buffers off, costs off, timing off)
SELECT total_amount
FROM bookings
WHERE total_amount > 200000;
                         QUERY PLAN
-------------------------------------------------------------
 Index Only Scan using bookings_total_amount_idx on book...
   Index Cond: (total_amount > '200000'::numeric)
   Heap Fetches: 0
   Index Searches: 1
 Planning Time: 0.122 ms
 Execution Time: 0.148 ms
(6 rows)

Heap Fetches 表示为了检查可见性而读取堆元组的次数。这里仅凭可见性映射就能完成判断,因此不需要访问堆。

现在更新一行:

UPDATE bookings
SET total_amount = total_amount
WHERE book_ref = '00EV9A';
UPDATE 1

更新会清除受影响堆页面的全部可见状态。当匹配的索引条目指向这个页面时,执行器就必须访问堆:

EXPLAIN (analyze, buffers off, costs off, timing off)
SELECT total_amount
FROM bookings
WHERE total_amount > 200000;
                         QUERY PLAN
-------------------------------------------------------------
 Index Only Scan using bookings_total_amount_idx on book...
   Index Cond: (total_amount > '200000'::numeric)
   Heap Fetches: 3
   Index Searches: 1
 Planning Time: 0.033 ms
 Execution Time: 0.136 ms
(6 rows)

执行器并没有扫描被修改页面中的每个元组。这里的堆访问,是由需要检查可见性的索引条目引起的,因为已经不能再认定该页面全部可见。

3.4.2 包含 INCLUDE 子句的索引

覆盖索引可以大幅降低查询成本,但将所有所需列都作为索引键并不总是合适。例如:

  • 向唯一索引添加键列,会改变唯一性约束所针对的值组合。
  • 新增列的数据类型可能不受该索引支持。

PostgreSQL 允许通过 INCLUDE 子句,将非键列包含在索引中。

这些列的值不参与搜索树的键比较,而是作为附加数据保存在叶子条目中,以便仅索引扫描直接返回这些值,无需访问表。

在 PostgreSQL 18 中,B 树、GiST 和 SP-GiST 索引支持 INCLUDE。

包含 INCLUDE 子句的索引与覆盖索引不是同一个概念。索引可以在不使用 INCLUDE 的情况下覆盖某个查询;反过来,包含额外列的索引也未必能够覆盖另一个查询。

主键索引 tickets_pkey 包含 ticket_no,却不包含 book_ref,因此不能覆盖下面这个查询:

EXPLAIN (analyze, costs off, summary off)
SELECT ticket_no, book_ref
FROM tickets
WHERE ticket_no > '0005435990286';
                         QUERY PLAN
-------------------------------------------------------------
 Index Scan using tickets_pkey on tickets (actual time=0...
   Index Cond: (ticket_no > '0005435990286'::text)
   Index Searches: 1
   Buffers: shared hit=1496831 read=23275 written=17
 Planning:
   Buffers: shared hit=46 read=6
(6 rows)

Buffers 行报告了页面访问情况。这里大量的缓冲区命中,反映了为了获取索引中不存在的值而访问表所产生的开销。创建一个将 book_ref 作为附加数据保存的唯一索引:

CREATE UNIQUE INDEX ON tickets (ticket_no)
INCLUDE (book_ref);
CREATE INDEX

EXPLAIN (analyze, costs off, summary off)
SELECT ticket_no, book_ref
FROM tickets
WHERE ticket_no > '0005435990286';
                         QUERY PLAN
-------------------------------------------------------------
 Index Only Scan using tickets_ticket_no_book_ref_idx on...
   Index Cond: (ticket_no > '0005435990286'::text)
   Heap Fetches: 0
   Index Searches: 1
   Buffers: shared hit=1 read=8033
 Planning:
   Buffers: shared hit=18 read=1 dirtied=1
(7 rows)

现在,规划器选择 Index Only Scan,并使用新建的索引。由于相关堆页面全部可见,Heap Fetches 为零,页面访问量也小得多。

通过 INCLUDE 添加的列不参与索引访问方法的键处理,因此这种索引可以包含 B 树不支持作为键的数据类型,例如几何类型或 xml。

3.5 并行访问

PostgreSQL 可以并行执行查询计划中的某些部分。领导者进程启动并行工作进程,由它们执行计划中支持并行协作的部分,再通过 Gather 或 Gather Merge 节点收集输出。

领导者进程通过 Gather 收集工作进程的结果,空闲时也可参与并行部分;Gather 之上的计划串行执行。
领导者进程通过 Gather 收集工作进程的结果,空闲时也可参与并行部分;Gather 之上的计划串行执行。

领导者进程也可以参与并行计划的执行,尤其是在接收工作进程返回的元组尚未占满其处理能力时。

启动工作进程、协调进程以及传递元组都会产生开销,因此并行执行并不适合所有查询。4 此外,只有部分计划节点和操作支持并行协作;计划中其余部分由领导者进程串行执行。

3.5.1 工作进程数量限制

有几个配置参数用于限制并行执行。

工作进程数量的三层限制:max_worker_processes、max_parallel_workers 和 max_parallel_workers_per_gather;图中分别为 8、8、2。
工作进程数量的三层限制:max_worker_processes、max_parallel_workers 和 max_parallel_workers_per_gather;图中分别为 8、8、2。

PostgreSQL 的工作进程不仅用于并行查询。逻辑复制和扩展也可以使用后台工作进程。应用代码同样可以使用工作进程,参见 DEV2 课程中的 Background Processes 一课。

max_worker_processes 限制可同时运行的后台工作进程总数。

max_parallel_workers 限制整个服务器可用于并行查询执行的工作进程数量。

max_parallel_workers_per_gather 限制服务于一个领导者进程的并行工作进程数量。

合适的参数值取决于 CPU 能力、I/O 带宽、内存、负载以及并发会话数量。

提高上限并不会让每个查询都变快;它只是改变了规划器和执行器可以使用的并行资源数量。

3.5.2 并行顺序扫描

Parallel Seq Scan 允许多个进程协作扫描同一张表。

并行顺序扫描由多个进程分工读取堆页面,并通过 Partial Aggregate、Gather 和 Finalize Aggregate 完成计数。
并行顺序扫描由多个进程分工读取堆页面,并通过 Partial Aggregate、Gather 和 Finalize Aggregate 完成计数。

这个名称并不矛盾。堆数据块仍然像普通 Seq Scan 一样按顺序访问,区别在于多个参与进程同时读取各自负责的数据块,并通过共享的并行扫描状态协调彼此的工作。

收益并不只是多个进程可以同时读取存储。每个工作进程还会在分配给自己的页面上计算谓词,并执行其他逐行 CPU 处理。只有节省的执行时间超过工作进程启动、同步和元组传输的开销,并行才有收益。聚合就是一个很好的例子:工作进程可以扫描并局部聚合大量行,而向上层传递的只是少量局部结果。

对于小表,串行执行 count(*) 的成本更低:

EXPLAIN SELECT count(*) FROM seats;
                         QUERY PLAN
-------------------------------------------------------------
 Aggregate  (cost=31.76..31.77 rows=1 width=8)
   -> Seq Scan on seats  (cost=0.00..27.41 rows=1741 wid...
(2 rows)

Aggregate 必须读完全部输入才能返回最终计数,因此其启动成本接近总成本。

在大得多的 bookings 表上执行同类查询:

EXPLAIN SELECT count(*) FROM bookings;
                         QUERY PLAN
-------------------------------------------------------------
 Finalize Aggregate  (cost=29807.83..29807.84 rows=1 wid...
   -> Gather  (cost=29807.61..29807.82 rows=2 width=8)
        Workers Planned: 2
        -> Partial Aggregate  (cost=28807.61..28807.62 ro...
             -> Parallel Seq Scan on bookings  (cost=0.00....
(5 rows)

Gather 下方是计划的并行部分,由规划的工作进程执行,领导者进程也可能参与。Gather 自身及其上方的节点则由领导者进程执行。

每个工作进程计算局部计数,领导者进程收集这些局部结果,最后由 Finalize Aggregate 生成最终值。

并非每个查询都能使用并行执行。例如,许多修改数据或锁定行的操作不能并行执行,函数和其他操作的并行安全规则也可能使计划只能串行执行。这些限制会随 PostgreSQL 版本演进,具体版本的行为应查阅对应文档。5

3.5.3 并行索引扫描与并行仅索引扫描

B 树索引访问也可以参与并行计划。

并行 B 树扫描分两个阶段进行。首先,领导者进程从树根向下定位到一个叶子页面。然后,工作进程沿叶子链表并行读取索引页面。读取某个索引页面的进程,也负责读取所需的表页面。

并行索引扫描中,不同进程读取不同的叶子页面,并访问相应的堆页面;同一个堆页面可能被多个进程访问。
并行索引扫描中,不同进程读取不同的叶子页面,并访问相应的堆页面;同一个堆页面可能被多个进程访问。

多个进程可能需要读取同一个表页面。图中就展示了这种情况:最后一个表页面中的行,由不同进程读取的多个索引页面所引用。当然,这个表页面在缓冲区缓存中只保存一份。并行仅索引扫描与此类似,只是当索引条目所引用的堆页面全部可见时,可以跳过堆访问。

并行仅索引扫描同时利用进程分工与可见性映射,对全部可见的堆页面避免回表访问。
并行仅索引扫描同时利用进程分工与可见性映射,对全部可见的堆页面避免回表访问。

例如,统计最近一个月的预订数:

EXPLAIN (costs off)
SELECT count(*)
FROM bookings
WHERE book_date > '2026-02-01 03:00:00+03';
                         QUERY PLAN
-------------------------------------------------------------
 Finalize Aggregate
   -> Gather
        Workers Planned: 2
        -> Partial Aggregate
             -> Parallel Index Only Scan using bookings_bo...
                  Index Cond: (book_date > '2026-02-01 03:0...
(6 rows)

其结构与前面的并行顺序扫描计划相似,只是行通过 Parallel Index Only Scan 获取。

3.5.4 并行位图堆扫描

位图堆访问也可以并行执行。

并行位图堆扫描先构建位图,再由多个进程分工访问候选堆页面。
并行位图堆扫描先构建位图,再由多个进程分工访问候选堆页面。

位图本身仍然串行构建:负责初始化的领导进程执行 Bitmap Index Scan,构建共享的位图状态。

之后,工作进程就可以通过 Parallel Bitmap Heap Scan 协作完成堆访问阶段。

统计最近三个月中金额小于 5,000 的预订数:

EXPLAIN (costs off)
SELECT count(*)
FROM bookings
WHERE total_amount < 5000
  AND book_date > '2025-12-01 03:00:00+03';
                         QUERY PLAN
-------------------------------------------------------------
 Finalize Aggregate
   -> Gather
        Workers Planned: 2
        -> Partial Aggregate
             -> Parallel Bitmap Heap Scan on bookings
                  Recheck Cond: (total_amount < '5000'::num...
                  Filter: (book_date > '2025-12-01 03:00:00...
                  -> Bitmap Index Scan on bookings_total_am...
                       Index Cond: (total_amount < '5000'::n...
(9 rows)

负责初始化的进程在 Bitmap Index Scan 中构建位图。位图选出的堆数据块,则由 Parallel Bitmap Heap Scan 并行处理。

删除为这些示例创建的索引:

DROP INDEX bookings_book_date_idx;
DROP INDEX bookings_total_amount_idx;
DROP INDEX tickets_ticket_no_book_ref_idx;

3.6 访问方法比较

不存在一个通用的选择率阈值,能够确定某种访问方法从何时起优于另一种方法。不过,总体规律并不难理解。

当谓词筛选能力很强,只需要一行或表中很小一部分数据时,常规索引扫描最有优势。此时,沿索引检索并读取少量堆页面的成本,远低于扫描整个关系。

当匹配行比例处于中间范围时,位图扫描往往具有竞争力。虽然需要付出构建位图的启动成本,但可以减少对相同堆页面的反复访问。在最坏情况下,常规索引扫描的堆访问工作量大致随匹配元组数量增长;位图堆扫描则围绕包含候选元组的堆页面来组织访问。

访问方法的示意比较:横轴为通过条件的行比例,纵轴为时间;常规索引扫描、位图堆扫描和顺序扫描在不同范围各有优势,交点不是固定阈值。
访问方法的示意比较:横轴为通过条件的行比例,纵轴为时间;常规索引扫描、位图堆扫描和顺序扫描在不同范围各有优势,交点不是固定阈值。

当谓词筛选能力较弱、大多数行都能通过过滤时,顺序扫描通常最好。既然已经需要读取大部分堆页面,再读索引只会增加工作量。这种差异在机械硬盘上更明显,因为随机读取远慢于顺序读取连续页面。

不同方法的成本交点高度依赖于表、索引、物理相关性、行宽、缓存状态、存储设备、规划器成本参数,以及具体查询。规划器会综合这些估算,而不是使用“匹配行比例低于 X% 就使用索引”这样的固定规则。

另一个因素是顺序。B 树索引扫描按索引顺序返回行,因此可以省去单独的排序。即使单看获取行的成本并不是最低,索引扫描仍可能因此更具优势。

仅索引扫描的时间范围:全部可见页面比例会影响堆访问量,从而使其表现介于较好与较差的情况之间。
仅索引扫描的时间范围:全部可见页面比例会影响堆访问量,从而使其表现介于较好与较差的情况之间。

仅索引扫描的性能,很大程度上取决于可见性映射,以及有多少堆页面处于全部可见状态。

因此,规划器估算仅索引访问的成本时,会考虑全部可见页面比例的估算值。