☰
PostgreSQL覆盖索引实战:用INCLUDE消除回表,让Index Only Scan真正提速
2026/10/13 11:00:00 网站建设 项目流程

做 PostgreSQL 性能优化这些年,我踩过最深的坑之一,就是“明明加了索引,查询还是慢”。后来仔细看执行计划才发现,这类问题十有八九是回表导致的,而回表问题的标准解药,就是覆盖索引。覆盖索引(covering index)这个能力,PostgreSQL 11 开始才真正具备完整形态:通过在 B-tree 索引里附带 INCLUDE 列,让查询所需的字段全部可以从索引扫描中拿到,从而把“Index Scan + 回表”变成“Index Only Scan”。这篇就用一个非常常见的订单查询场景,把覆盖索引的创建方法、适用场景和避坑要点一次讲清楚,省得你再走弯路。

这篇文章适合几类人:写过业务接口、发现列表页越来越卡的开发者;被慢查询日志追着跑的 DBA;以及刚接手一套 PostgreSQL 业务库,想系统性做索引体检的同学。内容不堆概念,核心就回答三个问题:覆盖索引到底解决了什么、怎么创建、什么时候用才划算。

1. 覆盖索引到底做了什么:一次回表引发的性能事故

1.1 一个“建了索引还慢”的真实场景

假设业务里有一张订单表,每天产生几十万行,表里除了主键还有 customer_id、status、total_amount、created_at 这些业务字段。你接到的需求很简单:查某个客户最近 90 天的订单金额明细,SQL 长这样:

SELECT customer_id, total_amount, created_at FROM t_order WHERE customer_id = 12345 AND created_at >= now() - interval '90 days';

业务方也懂点索引,很自然地就建了一个复合索引:

CREATE INDEX idx_order_customer_created ON t_order (customer_id, created_at);

结果怎么样?在表里数据量到 200 万行以后,这个查询开始变慢。把执行计划拉出来一看,不是顺序扫描,索引也确实用了,可计划显示的是 Index Scan 而不是 Index Only Scan。同样查出 3000 行结果,整个查询要访问几十个数据页的堆表块,有时候还会出现大量随机读。这时你才发现,索引只是帮你快速定位了行所在的位置,并没有把你要的 total_amount 给你。

这其实就是很多人对索引的误解:索引是给 WHERE 条件准备的,它负责“找到行”,而不是“提供数据”。找到行之后,查询需要的其它列还得回到原表里取,这一步就是回表。回表一次两次没关系,可当命中行数变成几千上万,而这些行又分散在表的不同数据页上时,每次回表都变成一次随机 IO,慢就成了必然。

1.2 堆表结构:为什么 PostgreSQL 绕不开回表

要理解覆盖索引,得先理解 PostgreSQL 的存储模型。PostgreSQL 的表是堆表(heap table),真正的行数据存在数据页里。索引不管你是主键索引还是普通索引,本质上都只保存两部分内容:索引键的值,以及指向堆表中对应行的行号 TID。也就是说,主键索引也不是聚簇索引,它并不会把整行数据都存在索引的叶子页里。

这和 MySQL InnoDB 有本质差异。InnoDB 的主键是聚簇索引,叶子页里直接存整行;普通二级索引的叶子存主键值,用主键去聚簇索引回表。而 PostgreSQL 是一个完全独立的索引结构加堆表,任何索引查到了 TID 之后,都必须再去堆表走一趟,才能拿到非索引列的值。这个“必须”,就是覆盖索引存在的意义——把需要的数据提前搬进索引的叶子页,让数据库在扫描索引的过程中直接把所有列拿齐,堆表一趟都不用回。

1.3 覆盖索引的“几何视角”:目录里顺便抄重点

打个生活化的比方:B-tree 索引就像一本书的目录,普通目录里每一行只写“章节标题 + 页码”,你想看具体某一段内容,还得按页码翻到正文那一页。覆盖索引的做法很朴素,它在目录的每一行后面,顺手把这一章的核心结论也抄在目录里。这样你要的东西在目录里就能看到,正文那页根本不用翻。

PostgreSQL 11 引入的 INCLUDE 语法就是干这件事的。它允许你在索引键列之外,额外追加一些列到索引的叶子页里存储。这些附加列不参与索引的排序和搜索,只作为“载荷”被带进索引,等索引扫描结束后直接把值吐出来。查询优化器一旦发现“你要的所有列,索引里全都有”,它就会选择 Index Only Scan,从索引扫描本身就把结果集构建出来。

需要特别注意的是,Index Only Scan 并不简单等于“不回堆表”。PostgreSQL 的 MVCC 机制使得旧版本的行可能还残留在数据页里。数据库想确认索引返回的行是否“可见”,有时还需要回到堆表做可见性检查。在触发可见性检查时,执行计划里会出现 Heap Fetches 这个指标,它代表实际访问堆表的行数。关于这块的坑,后面单独细说。

2. 创建覆盖索引:语法、位置与字段选择的讲究

2.1 INCLUDE 语法入门:五秒钟学会

覆盖索引的创建语法非常直接,就是在普通 CREATE INDEX 基础上加一个 INCLUDE 子句:

CREATE INDEX idx_order_cover ON t_order (customer_id, created_at) INCLUDE (total_amount);

这条语句创建了一个 B-tree 索引,customer_id 和 created_at 是索引键列,total_amount 是 INCLUDE 列。索引键列决定了索引的排序、搜索、范围扫描能力,INCLUDE 列则只在叶子页里保存数值,不参与任何键值比较。

我习惯把这种索引的列分成两类去看。第一类“键列”,负责回答“你要查哪部分数据”,它的价值和顺序与普通复合索引完全一致。第二类“载荷列”,负责回答“查出之后要用哪些字段”,它的存在只为了让查询不用回堆表。建索引前先在 SQL 里把键列和载荷列分开,思路会清晰很多。

2.2 键列与 INCLUDE 列:职责差异对照

很多刚开始用覆盖索引的人会混淆这组概念,我把它们的关键区别列成一张表:

对比维度键列(Key Columns)INCLUDE 列
是否参与索引排序参与不参与
是否可用于 WHERE 匹配可以不可以
是否可用于 ORDER BY可以不可以
是否参与 B-tree 内部节点参与只在叶子页出现
对索引体积的影响每层都占用只增大叶子页
与查询覆盖的关系过滤 + 覆盖仅覆盖

这张表里最容易被误解的是“INCLUDE 列不能用于 WHERE 匹配”。不少人建了 INCLUDE 列之后,跑一条用该列做过滤条件的查询,发现执行计划里压根不是 Index Only Scan,然后疑惑为什么没生效。原因很简单:数据库对某一列进行等值或范围匹配,要求这一列必须在索引键列里,并且遵循最左前缀规则。INCLUDE 列只是数据的搬运工,不负责找数据。

2.3 为什么不用普通复合索引替代 INCLUDE

你可能会有个很自然的想法:既然想让 total_amount 也跟着索引走,那把 total_amount 也放进索引键列里不就行了?比如建一个 (customer_id, created_at, total_amount) 的三列复合索引,查询时同样不需要回表。

这个思路确实“能用”,但不准确到了哪里?首先,total_amount 放进去之后,它会进入 B-tree 索引的所有层级,包括内部节点。你会为了一个纯粹只是“带出去”的字段,让整个索引树的每层节点都变大。其次是排序逻辑,一旦 total_amount 成为键列,索引的叶子页会按照 total_amount 再排一层序,可是这个排序对你上面的查询毫无用处,白白增加维护成本。

更麻烦的是,如果你将来再加一个范围查询到这一列上,复合索引的列顺序还得重新考虑。而 INCLUDE 列永远不会改变索引树的排列规则,它只是“挂在叶子页上”的一个附加属性。这个设计让覆盖索引在不干扰键列结构的前提下,完成了数据载荷的搬运,这也是为什么要用 INCLUDE 而不是硬塞进键列。

2.4 键列顺序怎么排:先等值,后范围

键列的顺序依旧遵守普通复合索引的基本法:把选择性高、或用于等值过滤的列放前面,把范围过滤的列放后面。还是用开始那个例子,customer_id 是等值条件,created_at 是范围条件,那么 (customer_id, created_at) 就是合理的键列顺序。如果你把一个范围列放在第一个,后面所有键列都无法高效地参与索引匹配,查询性能直接打折。

我见过有些人为了“覆盖更多查询”,把可能的过滤列全部放进键列,最后建了一个六列的巨型复合索引。这种做法在小表上没什么感觉,一旦数据量上来,索引体积、写入开销、缓存命中率都会成为新的问题。正确的态度是:覆盖索引服务于高优先级的少数 SQL,不要指望一个索引覆盖全表的全部查询模式。

3. 覆盖索引的适用场景:什么时候上,什么时候别上

3.1 高频只读查询:报表、列表页、数据导出

覆盖索引收益最大的场景,是那些每天都跑、只读、返回固定几列数据的查询。典型的就是报表统计、后台列表分页、接口数据导出。这类查询的特点是:过滤条件相对固定,结果列范围明确,执行频率极高。给它们配一个覆盖索引,可以把单次查询的 IO 成本按一个数量级压缩。

举个具体例子。有一张千万级的流水表,业务方每天凌晨会把前一天的数据汇总到报表里,查询固定是“按用户、按日期范围取金额合计”。这种 SQL 一旦配上覆盖索引,执行计划从 Index Scan + Heap 回表变成 Index Only Scan 之后,响应时间普遍能下降 40% 到 70%。数据量越大、命中行数越多,收益越明显。注意这里是“命中行数多”才收益大,如果一条查询就过滤出三五行,回表成本本来就可忽略,覆盖索引也不是雪中送炭。

3.2 聚合与排序:让索引直接喂数据

覆盖索引的另一个甜点是聚合和排序。比如SELECT customer_id, count(*), sum(total_amount) FROM t_order WHERE customer_id = 12345 GROUP BY customer_id这类语句,如果 customer_id、total_amount 都在索引里,PostgreSQL 在执行分组聚合时可以直接从有序的索引数据流里连续读取,不需要回到堆表。索引天然有序的特性,对 GROUP BY 和 ORDER BY 的优化帮助很大。

还有一类场景是 count(),很多人可能没意识到,PostgreSQL 的 count() 如果走索引覆盖扫描,可以完全避免访问堆表。虽然它在表比较小的时候可能竞争不过顺序扫描,但在大表、过滤条件稳定的情况下,一个小而窄的覆盖索引往往是最便宜的统计方案。

3.3 什么情况下别硬上覆盖索引

覆盖索引不是万能药,下面这几种情况我一般不推荐:

第一,写多读少的表。每一次 INSERT、UPDATE、DELETE 都需要同步维护索引。表本身写入压力已经很大,再为一个偶尔跑的报表查询加覆盖索引,只会增加写放大。

第二,被包含的列更新频繁。如果 INCLUDE 列本身是高更新频率字段,比如某个状态位,每一行都会频繁变更。INCLUDE 列一旦更新,索引叶子页也要同步修改,这个开销比普通回表还贵。

第三,过宽的大字段。text、jsonb、长 varchar 这类字段塞进 INCLUDE,会迅速把索引叶子页胀大,降低索引页的缓存命中率,反而影响整体性能。记住,索引叶子页也是有容量限制的,载荷列越多,单个叶子页能存放的索引条目就越少。

第四,命中行数很少的查询。覆盖索引的意义在于“省掉大量回表”。如果一条查询平均只返回十几行,回表成本微乎其微,加一个覆盖索引除了让写入变慢,几乎带不来收益。

3.4 用执行计划判断该不该上覆盖索引

我不建议凭感觉决定要不要加覆盖索引,最靠谱的方法是先看执行计划。在你建覆盖索引之前,先对目标 SQL 跑一次EXPLAIN (ANALYZE, BUFFERS),重点看两个地方:一是扫描方式,二是 Buffers 数据。

如果计划里出现大量Heap Fetches或者 Buffers 里的 shared read / shared hit 数量明显偏高,而查询本身只有那么几列,这时候覆盖索引就有用武之地。反之,如果计划本身就是 Index Only Scan,或者命中的行数很小、Buffer 访问量很低,那就没有加的必要。做这个判断只需要几秒钟,却能避免不少无效索引。

4. 从零到一:覆盖索引实操对照实验

4.1 实验环境与造数

为了把收益和原理讲清楚,我搭了一套可复现的对照实验。环境是 PostgreSQL 15,测试表设计成订单表结构,造了约 200 万行数据。表结构如下:

DROP TABLE IF EXISTS t_order; CREATE TABLE t_order ( id bigserial PRIMARY KEY, customer_id bigint NOT NULL, status smallint NOT NULL DEFAULT 0, total_amount numeric(12,2) NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), remark text );

造数脚本用随机函数生成,目的是模拟真实业务中数据均匀分散的情况:

INSERT INTO t_order (customer_id, status, total_amount, created_at) SELECT (random() * 99999)::int + 1, (random() * 4)::smallint, round((random() * 1000)::numeric, 2), now() - (random() * 365 * 24 * 3600) * interval '1 second' FROM generate_series(1, 2000000);

这里把 customer_id 分散到 10 万个取值内,平均每个客户大约 20 行,created_at 分布在一年范围内,total_amount 随机分布。这样构造出来的数据,回表时会命中大量互不相邻的数据页,能最大程度放大回表代价。

4.2 第一轮:普通复合索引的表现

先建普通复合索引:

CREATE INDEX idx_order_customer_created ON t_order (customer_id, created_at);

然后跑目标查询:

EXPLAIN (ANALYZE, BUFFERS) SELECT customer_id, total_amount, created_at FROM t_order WHERE customer_id = 12345 AND created_at >= now() - interval '90 days';

我这边的执行计划核心部分大概长这样:

Bitmap Index Scan on idx_order_customer_created Index Cond: ((customer_id = 12345) AND (created_at >= (now() - '90 days'::interval))) Bitmap Heap Scan on t_order Recheck Cond: ((customer_id = 12345) AND (created_at >= (now() - '90 days'::interval))) Buffers: shared hit=372 shared read=26

这个计划说明 Postgres 用了位图扫描:先在索引里定位符合条件的行,再拿着 TID 去堆表批量取数。看起来已经很“聪明”了,但问题依旧明显:total_amount 不在索引里,所以必须在堆表里访问对应行。执行时间大概在 20ms 上下。你可能觉得 20ms 不慢,可一旦这个问题跑到线上一秒执行几十次,加上并发,总耗时就会被放大。

4.3 第二轮:覆盖索引的收益

接着把覆盖索引建上,注意先保留普通索引,后面我们要对比大小和写入成本:

CREATE INDEX idx_order_cover ON t_order (customer_id, created_at) INCLUDE (total_amount);

再把同样的 SQL 跑一遍,执行计划变成了:

Index Only Scan using idx_order_cover on t_order Index Cond: ((customer_id = 12345) AND (created_at >= (now() - '90 days'::interval))) Heap Fetches: 0 Buffers: shared hit=78

这里有两个关键变化。第一,扫描方式变成了 Index Only Scan,说明系统已经确认所有需要的列都在索引里。第二,Heap Fetches 为 0,说明连可见性检查都没有访问堆表。整条查询访问的共享缓冲页从 398 个左右降到了 78 个,执行时间也明显下降。注意,我这里给出的是实验机上的一次典型输出,实际环境数值会有差异,但走势是稳定的。

这个实验最直观的结论是:在数据量一致、查询条件一致的前提下,覆盖索引把“访问数据页数量”压缩了五倍左右。页面访问量下降,通常意味着更少的 IO、更低的延迟和更高的并发吞吐。

4.4 从执行计划确认覆盖是否真的生效

看执行计划时,最直接的标志是Index Only Scan字样。只要输出不是 Index Scan、Bitmap Heap Scan 或 Seq Scan,而是 Index Only Scan,就说明查询没有回表取数。同时留意 Heap Fetches 字段,它表示即使走了 Index Only Scan,也不得不去堆表检查可见性的行数。数值为 0 是最理想的状态。

另外,EXPLAIN (ANALYZE, BUFFERS)里有一组 Buffers 信息,这里的 shared hit 和 shared read 都很值得看。覆盖索引生效时,shared hit 数量通常会显著降低,因为要读的数据页从“索引页 + 堆表页”变成了“索引页”。

5. 常见问题与避坑实录

5.1 为什么建了覆盖索引,却还是 Index Scan

这个是我被问得最多的问题。原因通常有四个:

第一,查询里选择了 INCLUDE 之外的列。覆盖索引只保证“INCLUDE 列 + 键列”能被索引提供,如果你 SELECT 了 remark 这种没有进索引的列,查询优化器就只能老实回表。

第二,过滤条件里用到了 INCLUDE 列做 WHERE 匹配。INCLUDE 列没有键值排序信息,不能参与索引搜索条件。你要么把它升级成键列,要么换一个过滤方案。

第三,可见性映射(VM)没跟上。表做了大量更新或删除之后,如果没有及时 VACUUM,很多数据页的 all-visible 标志位没有设置。这种情况下即使 Index Only Scan 计划被选中,它也需要回到堆表确认可见性。你可以在 EXPLAIN 输出里看到 Heap Fetches 大于 0。解决办法是执行VACUUM t_order,或者让 autovacuum 正常跑起来。

第四,查询中带有专有函数或表达式,导致优化器无法判断是否可以用索引覆盖。遇到这类情况,检查 WHERE 或 SELECT 里的表达式,把能被索引覆盖的列尽量写成原生列。

5.2 INCLUDE 列能不能用于 WHERE 和 ORDER BY

不能。INCLUDE 列不参与排序、不参与搜索,它的存在只是为了“覆盖查询的列需求”。所以你在写 SQL 时,如果把一个 INCLUDE 列放在 WHERE 条件或 ORDER BY 里,大概率会导致覆盖索引无法发挥全部价值,甚至让规划器放弃 Index Only Scan。

这里有一个很容易掉进去的坑:你想对 total_amount 做范围查询,同时希望索引覆盖 result 列。如果 total_amount 是 INCLUDE 列,WHERE 里的范围过滤不会使用这个索引;你必须把 total_amount 改造成键列,否则范围查询就是一场空。所以设计覆盖索引的时候,先分清楚每个字段的职责:过滤字段必须进键列,纯输出的字段才适合进 INCLUDE。

5.3 覆盖索引的写放大到底有多明显

覆盖索引本质上还是索引,每次写入都要维护。INCLUDE 列比键列多出来的开销,主要是叶子页变大之后,一个数据页能容纳的条目变少,B-tree 更容易频繁分裂。如果表本身写入量很大,你就要权衡读优化和写损耗之间的关系。

我给一个简单的估算思路:假设原来一个索引叶子页能放 200 条记录,加了一个 numeric 类型的 INCLUDE 列后,可能只能放 80 条。这意味着完成同样数量的索引插入,需要更多的叶子页,缓存命中率也下降。所以覆盖索引的列最好短小精悍,能用 int 别用 text,能用 numeric 别用 varchar。对长字符串、JSON 这类大体积字段,坚决不往 INCLUDE 里放。

5.4 覆盖索引和部分索引的搭配玩法

有一种很实用的组合,是覆盖索引部分索引(partial index)一起用。比如订单表里大部分查询只关注 status = 1 的活跃订单,那可以这样建:

CREATE INDEX idx_order_active_cover ON t_order (customer_id, created_at) INCLUDE (total_amount) WHERE status = 1;

这样索引里只保存活跃订单的条目,体积小、扫描快、维护成本低。前提是查询语句里必须带 status = 1 条件,并且优化器能推导出这个条件成立,否则索引不会被使用。如果你有大量“带上状态过滤”的查询,这个组合非常值得尝试。

5.5 NULL 值与参数带来的隐性影响

PostgreSQL 索引默认把 NULL 值排在最后,如果你查询里有ORDER BY created_at DESC或 NULLS FIRST 之类的要求,索引键的顺序可能和你的预期不一致,从而影响排序效率。这不算覆盖索引特有的问题,但容易被忽略。遇到排序关键字与索引顺序不一致时,可以用CREATE INDEX ... ON t_order (customer_id, created_at DESC)之类的方式显式指定方向。

还有一个经常被谈到的参数是 random_page_cost。默认值是 4,它表示数据库认为一次随机页访问的成本是顺序页访问的四倍。如果你把库部署在 SSD 上,随机 IO 成本远没有这个倍数高,常见优化是把 random_page_cost 调到 1.1 左右。这个参数会影响优化器在“回表访问堆表”和“直接扫描索引”之间的取舍。覆盖索引降低了回表成本,但优化器是否愿意选择它,还受这个参数影响。线上调优时,可以在确认执行计划前先检查一下这个参数的值。

5.6 给新手的几点实操建议

最后整理几条我用真金白银换来的经验:

  • 先跑 EXPLAIN (ANALYZE, BUFFERS),确认瓶颈确实是回表,再加覆盖索引,不要凭感觉建。
  • 键列负责“查”,INCLUDE 列负责“带”,把职责分清楚,不要一股脑全塞进键列。
  • 覆盖索引的列要短小,能放定长数值就不要放变长文本。
  • VACUUM 对 Index Only Scan 的生效至关重要。高频更新的表要确保 autovacuum 不被关闭,否则覆盖索引的优势会大打折扣。
  • 覆盖索引的数量宁缺毋滥。一个高价值查询配一个精准的覆盖索引,好过十个低价值索引拖慢写入。

做多了之后你会发现,覆盖索引不是那种炫技式的优化,它更像一种“把成本提前算清楚”的工程思路:数据既然注定要读,那就从最合适的地方读。我在实际项目中见过太多表,查询其实就靠那两三个固定接口撑住,结果索引建了七八个,个个都在做重复劳动。与其这样,不如把一个覆盖索引设计到位,让它真正吃到 Index Only Scan 的红利。

我个人实践下来的体会是:覆盖索引应该放在索引优化清单的中后期去做,先排除缺索引、烂索引和参数问题,再考虑用覆盖索引压极限。但如果你已经明确了目标 SQL 的列集合和过滤条件,并且查询频率足够高,那它就值得被第一时间安排上。后面的扩展思路也很多,比如覆盖索引加 partial index 组合、覆盖索引配合物化视图做报表加速,这些都是同一个方向的延伸。先把 INCLUDE 语法和 Index Only Scan 的执行计划看明白,剩下的事会顺很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询