覆盖索引(INCLUDE)
B-Tree 索引只保存被索引列的值。当查询还需要返回没被索引的列时,数据库必须拿着索引里找到的物理地址(TID,Tuple Identifier)回到堆表(Heap Table)里取出整行——这一步俗称「回表」。一次查询命中几千行,就是几千次随机 I/O,索引带来的收益常常被回表抵消掉。
覆盖索引(Covering Index)的目标就是消灭回表:把查询需要的返回列一并塞进索引,让索引自己就能回答整个查询,这种执行方式叫仅索引扫描(Index-Only Scan)。PostgreSQL 11 起提供了 INCLUDE 子句,用「非键列(Non-Key Columns)」的形式优雅地表达覆盖列。本节讲清它背后的可见性映射(Visibility Map)机制、Heap Fetches 指标的含义、INCLUDE 与多列索引的本质区别,以及它的真实代价。
前置知识
阅读本节前,建议先了解:B-Tree 索引原理、多列索引、部分索引(Partial Index)
基础概念
回表:索引无法避免的第二步
假设 orders 表上有 (user_id) 索引,执行下面的查询:
SELECT id, amount, created_at FROM orders WHERE user_id = 42;索引能飞快定位到 user_id = 42 的所有条目,但索引条目里只有 user_id 加一个 TID。id、amount、created_at 都不在索引中,于是执行器只能:
第一步:在索引里找到 user_id = 42 的条目 → 拿到 TID 列表
第二步:按 TID 逐个访问堆表页面,取出整行 → 「回表」
第三步:从整行里筛出 id / amount / created_at回表是随机 I/O。命中 5000 行、堆表又大于内存时,第二、三步的开销会远超第一步,优化器甚至可能因此放弃索引改用顺序扫描。
普通 B-Tree 索引: [ user_id | TID ] ──回表──> 堆表整行
覆盖索引(INCLUDE): [ user_id | id | amount | created_at ] ──> 直接返回Index-Only Scan 与 Visibility Map
「仅索引扫描」就是在索引里把查询从头到尾答完,不回表。但它有一个绕不开的麻烦:MVCC 可见性。
索引条目里没有行版本信息,优化器无法直接判断这个 TID 对应的元组对当前事务是否可见。PostgreSQL 用一个叫可见性映射(Visibility Map,VM)的旁路结构来解决:VM 为堆表的每个页面记录两个比特(bit):
| 比特 | 名称 | 含义 |
|---|---|---|
| bit 0 | all-visible(全可见) | 该页所有元组对所有事务都可见 |
| bit 1 | all-frozen(全冻结) | 该页所有元组都已冻结,无需再 VACUUM |
如果索引命中的 TID 落在 all-visible 的页面上,执行器就确信该元组可见,无需回表;否则必须回表确认。EXPLAIN (ANALYZE) 输出的 Heap Fetches: 行,统计的正是「被迫回表确认」的次数。
VM = 1 1 1 1 1 0 1 1 1 1 ...
↑ 这一页 all-visible → 索引命中的元组直接采信,不回表
↑ 这一页脏 → 必须回表核对可见性一句话总结:Heap Fetches: 0 才是理想的仅索引扫描;Heap Fetches 越大,你会越接近「带索引的回表扫描」。
INCLUDE:把载荷列放进索引
PostgreSQL 11 引入了 INCLUDE 子句。它把索引列分成两类:
| 类别 | 英文 | 作用 | 参与排序/搜索 | 参与唯一性判定 | 可被仅索引扫描返回 |
|---|---|---|---|---|---|
| 键列 | Key Columns | 索引的搜索维度 | 是 | 是 | 是 |
| 非键列 | Non-Key Columns(INCLUDE 列) | 纯粹的载荷(payload) | 否 | 否 | 是 |
它解决了多列索引的一个尴尬:为了覆盖返回列而把 amount 加进键里,会让索引的排序维度、唯一性语义、体积全部变形。INCLUDE 让「载荷」和「键」彻底分开:
-- 多列索引:amount 成为搜索维度的一部分,影响排序与体积
CREATE INDEX idx_orders_user_amount ON orders (user_id, amount);
-- 覆盖索引:amount 只是载荷,不参与搜索与排序
CREATE INDEX idx_orders_user_inc ON orders (user_id) INCLUDE (amount);INCLUDE 的索引方法支持范围
INCLUDE 最初(PostgreSQL 11)只支持 B-Tree,PostgreSQL 12 起扩展到 GiST 与 SP-GiST。Hash、GIN、BRIN 不支持 INCLUDE,这些方法上写 INCLUDE 会直接报语法/语义错误。
语法与用法
-- 基本形式:键列在前,INCLUDE 列在后
CREATE [ UNIQUE ] INDEX 索引名 ON 表名 (键列 [, ...] ) INCLUDE (非键列 [, ...]);
-- 可以与部分索引的 WHERE 谓词共存(顺序:INCLUDE 在 WHERE 之前)
CREATE INDEX 索引名 ON 表名 (键列) INCLUDE (载荷列) WHERE 谓词;
-- 可以与表达式键列共存
CREATE INDEX 索引名 ON 表名 (lower(email)) INCLUDE (id, nickname);
-- 非键列可以有多个(受总列数上限约束)
CREATE INDEX 索引名 ON 表名 (a, b) INCLUDE (c, d, e);
-- 唯一索引 / 主键也可以用 INCLUDE(非键列不参与唯一性判定)
CREATE UNIQUE INDEX 索引名 ON 表名 (tenant_id, slug) INCLUDE (title, updated_at);
ALTER TABLE 表名 ADD CONSTRAINT 约束名 PRIMARY KEY (id) INCLUDE (created_at);
-- 在线创建覆盖索引
CREATE INDEX CONCURRENTLY 索引名 ON 表名 (user_id) INCLUDE (amount);
-- 反向:不能用 INCLUDE 做降序、排序等语义(那是键列的事)
-- 错误!INCLUDE 列不能指定排序方向或操作符类
CREATE INDEX idx ON t (a) INCLUDE (b DESC);最小可运行示例:
DROP TABLE IF EXISTS orders;
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL,
amount numeric(12,2) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- 只把 user_id 当键,把查询常返回的列放进 INCLUDE
CREATE INDEX idx_orders_user_cover
ON orders (user_id) INCLUDE (id, amount, created_at);DROP TABLE
CREATE TABLE
CREATE INDEX详细说明
Index-Only Scan 的两个基本前提
优化器要走仅索引扫描,必须同时满足两个条件,缺一不可:
- 索引类型支持:索引必须能把查询引用到的列原值返回。B-Tree 总是支持(它存储完整的键值副本);GiST、SP-GiST 只对部分操作符类支持;GIN、BRIN、Hash 从不支持——GIN 存的是元素/倒排项,BRIN 存的是块范围摘要,都拿不回原始列值。
- 查询只引用索引中存在的列:
SELECT列表、WHERE、ORDER BY里出现的每一列,都必须能由索引本身提供。
-- 索引:ON orders (user_id) INCLUDE (amount)
-- 可以走 Index Only Scan:三列都在索引里
SELECT user_id, amount FROM orders WHERE user_id = 42;
-- 只能走 Index Scan(回表):created_at 不在索引里
SELECT user_id, amount, created_at FROM orders WHERE user_id = 42;注意 WHERE 里出现但不在索引里的列,同样会破坏仅索引扫描——因为过滤需要读取该列的值。
Visibility Map 与 Heap Fetches
即使两个前提都满足,是否真的不回表仍取决于 VM。这是最容易误解的一点:INCLUDE 列并不会让索引「自带可见性信息」。
VM 的规则很严格:
| 事实 | 说明 |
|---|---|
VM 只由 VACUUM 设置 | VACUUM(含 autovacuum)扫描后把干净页面标为 all-visible |
| 任何数据修改都会清除对应页的 VM 位 | INSERT / UPDATE / DELETE 之后该页就不是 all-visible 了 |
| 索引本身没有 VM | VM 是堆表的附属文件(_vm 后缀),索引不记录可见性 |
只有 VACUUM FREEZE 才设置 all-frozen | 常规 VACUUM 只设 all-visible |
堆表文件: base/16384/16400 ← 数据页
可见性映射:base/16384/16400_vm ← 每页两个 bit(VM fork)看一个计划输出:
Index Only Scan using idx_orders_user_cover on orders (cost=0.42..8.45 rows=1 width=40) (actual time=0.021..0.024 rows=1 loops=1)
Index Cond: (user_id = 42)
Heap Fetches: 0
Planning Time: 0.120 ms
Execution Time: 0.048 msHeap Fetches: 0 表示所有命中的元组都落在 all-visible 页面上,完全没有回表。如果刚做过大批量写入,同一查询可能变成:
Index Only Scan using idx_orders_user_cover on orders (cost=0.42..8.45 rows=1 width=40) (actual time=0.031..0.140 rows=1 loops=1)
Index Cond: (user_id = 42)
Heap Fetches: 1287节点名依然是 Index Only Scan,但 Heap Fetches: 1287 说明它实际上回表了 1287 次。判断覆盖索引是否真的生效,看的是 Heap Fetches,不是节点名。
可以直接查询 VM 的状态:
-- relallvisible:VM 中标记为 all-visible 的页数
SELECT relname, relpages, relallvisible,
round(100.0 * relallvisible / nullif(relpages, 0), 2) AS vmap_pct
FROM pg_class
WHERE relname = 'orders';
-- 更细:用 pg_visibility 扩展统计 all-visible / all-frozen 页数
CREATE EXTENSION IF NOT EXISTS pg_visibility;
SELECT * FROM pg_visibility_map_summary('orders'); relname | relpages | relallvisible | vmap_pct
---------+----------+---------------+----------
orders | 12345 | 12345 | 100.00
(1 row)
all_visible | all_frozen
-------------+------------
12345 | 0
(1 row)INCLUDE 的语法与非键列语义
INCLUDE 子句的位置固定:紧跟在键列列表之后、WHERE 谓词之前。
CREATE [ UNIQUE ] INDEX name ON table (key_1, key_2) INCLUDE (payload_1, payload_2) WHERE pred;
└── 必须在这里 ──┘非键列的语义(官方定义):
- 出现在
INCLUDE里的列在索引中物理存储,因此仅索引扫描可以返回它们; - 它们不能用于索引扫描的搜索条件(即不能写进
WHERE去走索引查找); - 它们不参与任何唯一性或排除约束的判定;
- 它们不需要操作符类——因此连索引方法本身不直接支持的数据类型也能放进 INCLUDE。
-- B-Tree 没有 jsonb 的操作符类,jsonb 不能当键列……
-- 错误!data type jsonb has no default operator class for access method "btree"
CREATE INDEX idx_bad ON events (payload);
-- ……但可以当 INCLUDE 列(非键列不需要操作符类)
CREATE INDEX idx_events_cover ON events (id) INCLUDE (payload);非键列也不需要指定排序方向、NULLS FIRST/LAST 或操作符类,写了就是语法错误。
非键列的三个「不」
把这三点单独拎出来,因为它们决定了什么时候不该用 INCLUDE。
不参与搜索。 下面这个查询用不到 amount 的索引加速,因为 amount 是非键列:
-- 索引:ON orders (user_id) INCLUDE (amount)
-- amount 只是载荷,这个 WHERE 无法用它做索引查找
SELECT id FROM orders WHERE amount > 1000;不参与唯一性。 唯一性只看键列:
-- 唯一性由 (tenant_id, slug) 决定,title / updated_at 可以重复
CREATE UNIQUE INDEX uq_pages_slug
ON pages (tenant_id, slug) INCLUDE (title, updated_at);不参与排序。 ORDER BY 只能利用键列的排序结果:
-- 索引:ON orders (user_id) INCLUDE (amount)
-- 可以走索引顺序:ORDER BY user_id
SELECT user_id, amount FROM orders ORDER BY user_id;
-- 不能:amount 是非键列,无法提供有序输出
SELECT user_id, amount FROM orders ORDER BY amount;想清楚「这一列到底是被搜的,还是被返回的」
如果某列既要出现在 WHERE 的等值/范围条件里,又要出现在返回列表里,它应该是键列,而不是 INCLUDE 列。INCLUDE 只解决「返回」,不解决「查找」。
INCLUDE vs 多列索引
两者都能让仅索引扫描返回更多列,但性质完全不同。考虑索引 (a) INCLUDE (b) 与 (a, b):
| 维度 | (a) INCLUDE (b) | (a, b)(多列键) |
|---|---|---|
b 参与搜索 | 否 | 是 |
b 参与排序 | 否 | 是(可作为第二排序键) |
b 参与唯一性 | 否 | 是 |
上层索引页是否存 b | 否(后缀截断) | 是 |
| 索引体积 | 更小 | 更大 |
| 适合 | b 只被返回 | b 被搜索或排序 |
后缀截断(Suffix Truncation)是关键区别:B-Tree 在构建上层(非叶子)页面时,只会保留区分度足够的最小前缀,非键列总是从上层页面中被移除。因此 INCLUDE 列不会放大索引的高层结构,索引更紧凑。
-- 场景:只按 a 查找,同时要返回 b
-- 推荐:b 是纯载荷
CREATE INDEX idx_t_a_inc ON t (a) INCLUDE (b);
-- 场景:既要按 a 查找,也可能按 (a, b) 联合过滤或排序
-- 推荐:b 是搜索维度
CREATE INDEX idx_t_ab ON t (a, b);还有一个容易忽略的点:INCLUDE 索引永远不会使用 B-Tree 去重(Deduplication)。去重是 PostgreSQL 13 引入的优化,能把大量重复的键合并存储;但只要索引带了 INCLUDE,去重就被彻底禁用——因为去重后的条目无法再承载各自不同的载荷列。
-- 去重生效(无 INCLUDE,且重复键多)
CREATE INDEX idx_t_a ON t (a);
-- 错误认知!带了 INCLUDE 就不可能去重
CREATE INDEX idx_t_a_inc ON t (a) INCLUDE (b);INCLUDE 的代价
覆盖索引不是免费的。它在四个方面付出成本:
一、体积膨胀。 非键列在每一个索引条目里重复存储一次,等于把堆表的这些列复制了一份。
SELECT indexrelid::regclass AS idx,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE relname = 'orders'
ORDER BY pg_relation_size(indexrelid) DESC; idx | index_size
----------------------+------------
idx_orders_user_cover| 86 MB
idx_orders_user | 26 MB二、破坏 HOT 更新。 堆元组优化(HOT,Heap-Only Tuple)要求 UPDATE 不修改任何被索引引用的列。INCLUDE 列是「被索引引用的列」,所以只要 UPDATE 改到了载荷列,就无法走 HOT,必须更新所有相关索引。
HOT 可用的条件:新元组落在同一页 + 被索引引用的列一个都没变
└─ 包括 INCLUDE 载荷列、部分索引谓词列、表达式索引依赖列三、宽列风险。 索引元组有最大尺寸限制。B-Tree 版本 4 的上限是 2704 字节,超了会直接让插入失败:
-- 错误!index row size 3120 exceeds btree version 4 maximum 2704 for index "idx_docs_cover"
-- DETAIL: Index row references tuple (0,1) in relation "docs".
-- HINT: Values larger than 1/3 of a buffer page cannot be indexed.
-- Consider a function index of an MD5 hash of the value, or use full text indexing.
CREATE INDEX idx_docs_cover ON docs (id) INCLUDE (content);四、列数上限。 索引最多 32 列,这个上限包含 INCLUDE 列。键列 + 非键列的总数不能超过 32。
-- 系统目录里可以看清「总列数」与「键列数」的区别
SELECT indexrelid::regclass AS idx,
indnatts AS total_attrs, -- 总列数:键列 + INCLUDE 列
indnkeyatts AS key_attrs -- 键列数:不含 INCLUDE 列
FROM pg_index
WHERE indrelid = 'orders'::regclass; idx | total_attrs | key_attrs
-----------------------+-------------+-----------
idx_orders_user_cover | 4 | 1
idx_orders_user | 1 | 1
(idx_orders_user_cover 的 4 = user_id + id + amount + created_at,key_attrs 只有 1)表达式索引 + INCLUDE 的变通
INCLUDE 列不能是表达式,官方明确说明:表达式既不能用于索引查找、也无法在仅索引扫描中重建,因此不支持。但这带来一个真实痛点:如果你想把 f(x) 作为键、同时又要返回 x 本身,该怎么办?
-- 错误!expressions are not supported as included columns
CREATE INDEX idx_bad ON tab (lower(email)) INCLUDE (lower(email));标准变通是把表达式依赖的原列放进 INCLUDE:
-- 推荐:键是表达式,INCLUDE 放原列
CREATE INDEX tab_f_x ON tab (f(x)) INCLUDE (x);
-- 实际例子:按小写邮箱查,同时要返回原始大小写
CREATE INDEX idx_users_lower_email
ON users (lower(email)) INCLUDE (email, id);SELECT id, email FROM users WHERE lower(email) = 'alice@example.com';Index Only Scan using idx_users_lower_email on users (cost=0.42..8.44 rows=1 width=36) (actual time=0.023..0.025 rows=1 loops=1)
Index Cond: (lower(email) = 'alice@example.com'::text)
Heap Fetches: 0注意 INCLUDE (email) 里放的是原始列 email,不是表达式。这样索引条目里既有 lower(email) 的排序键,又有 email 的原值,仅索引扫描就能同时返回两者。
部分索引 + 覆盖索引
INCLUDE 与部分索引的 WHERE 谓词可以叠加,这是生产环境里最常用的组合拳:用部分索引缩小索引规模,用 INCLUDE 消除回表。
-- 只索引待处理订单,同时把常用返回列带上
CREATE INDEX idx_jobs_pending_cover
ON jobs (created_at) INCLUDE (user_id, kind)
WHERE status = 'pending';这里有一个反直觉的点值得澄清:WHERE status = 'pending' 里的 status 并不在索引的键列或 INCLUDE 列中,但仅索引扫描依然可行。
原因是:部分索引的谓词在建索引时就已保证「索引里的每一行都满足 status = 'pending'」。执行器只要计划选择了这个部分索引,就无需对谓词部分重新做堆检查(这一点在 PostgreSQL 9.6 起被优化器正确识别)。因此 status 不需要出现在索引里。
部分索引 idx_jobs_pending_cover:
条目 = [ created_at | user_id | kind ] 仅 status='pending' 的行
谓词 status='pending' 由「索引定义」保证 → 无需回表 recheck-- 三列都能从索引直接返回;status 由谓词保证
SELECT created_at, user_id, kind FROM jobs WHERE status = 'pending' AND created_at >= now() - interval '1 day';Index Only Scan using idx_jobs_pending_cover on jobs (cost=0.29..12.60 rows=8 width=24) (actual time=0.028..0.041 rows=8 loops=1)
Index Cond: (created_at >= (now() - '1 day'::interval))
Heap Fetches: 0什么时候用、什么时候别用
覆盖索引的收益完全取决于「表够不够冷」和「查询形态够不够稳定」。官方文档对此有一句非常犀利的提醒:除非表的变化速度足够慢,否则往索引里加载荷列意义不大——因为页面刚被修改就丢了 VM 标记,Heap Fetches 会立刻飙升,你还是得回表。
| 情形 | 是否适合 INCLUDE 覆盖 | 理由 |
|---|---|---|
| 读多写少的字典/配置/报表表 | 适合 | VM 长期保持 all-visible,Heap Fetches 稳定为 0 |
| 高频更新的事务表 | 谨慎 | 修改频繁清除 VM,载荷列还破坏 HOT |
| 只需返回 1~2 个小列 | 适合 | 体积代价小,收益明确 |
| 需返回大文本 / 宽 JSON | 不建议 | 索引急剧膨胀,可能触碰 2704 字节上限 |
| 列既要被搜索又要被返回 | 用键列而非 INCLUDE | INCLUDE 列无法参与搜索 |
| JOIN 中需要返回连接表的少量列 | 视情况 | 见下 |
JOIN 场景要特别小心:INCLUDE 只能覆盖本索引所在表的列。如果查询 A JOIN B,即使 A 的索引覆盖了 A 的全部返回列,从 B 取列的那部分依然要访问 B。
-- A 的索引能覆盖 a.*,但 b.name 仍需访问 B
SELECT a.id, a.amount, b.name
FROM orders a JOIN users b ON b.id = a.user_id
WHERE a.user_id = 42;要让 JOIN 也走仅索引扫描,需要对 B 也建覆盖索引,且 B 的相关页面保持 all-visible。这是两件事,不要混为一谈。
实战示例
下面用一张 100 万行的 orders 表,完整走一遍「发现问题 → 建覆盖索引 → 用 Heap Fetches 验证」。
第一步:建表并灌入数据。
DROP TABLE IF EXISTS orders;
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL,
status text NOT NULL DEFAULT 'pending',
amount numeric(12,2) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO orders (user_id, status, amount, created_at)
SELECT (random() * 50000)::bigint,
(ARRAY['pending','paid','shipped','done'])[1 + (random() * 3)::int],
round((random() * 2000)::numeric, 2),
now() - (random() * 365 || ' days')::interval
FROM generate_series(1, 1000000);
ANALYZE orders;第二步:先建一个普通单列索引,观察回表。
CREATE INDEX idx_orders_user ON orders (user_id);
ANALYZE orders;
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, amount, created_at FROM orders WHERE user_id = 1234;Index Scan using idx_orders_user on orders (cost=0.42..96.10 rows=20 width=40) (actual time=0.030..0.152 rows=21 loops=1)
Index Cond: (user_id = 1234)
Buffers: shared hit=26
Planning Time: 0.141 ms
Execution Time: 0.190 ms节点是 Index Scan(不是 Index Only Scan),因为它必须回表取 id、amount、created_at。
第三步:建成覆盖索引,再看执行计划。
CREATE INDEX CONCURRENTLY idx_orders_user_cover
ON orders (user_id) INCLUDE (id, amount, created_at);
VACUUM (ANALYZE) orders;EXPLAIN (ANALYZE, BUFFERS)
SELECT id, amount, created_at FROM orders WHERE user_id = 1234;Index Only Scan using idx_orders_user_cover on orders (cost=0.42..4.90 rows=20 width=40) (actual time=0.029..0.041 rows=21 loops=1)
Index Cond: (user_id = 1234)
Heap Fetches: 0
Buffers: shared hit=5
Planning Time: 0.135 ms
Execution Time: 0.058 ms三个关键变化:
| 指标 | 覆盖索引前 | 覆盖索引后 | 说明 |
|---|---|---|---|
| 节点类型 | Index Scan | Index Only Scan | 不再回表 |
| 共享缓冲区命中 | hit=26 | hit=5 | I/O 显著下降 |
Heap Fetches | 无此行 | 0 | 真正零回表 |
第四步:验证 VM 被破坏后的退化。 更新该用户的一批订单,并且改到 INCLUDE 列,然后立刻重跑同一个查询(不做 VACUUM):
UPDATE orders SET amount = amount + 1 WHERE user_id = 1234;
EXPLAIN (ANALYZE)
SELECT id, amount, created_at FROM orders WHERE user_id = 1234;Index Only Scan using idx_orders_user_cover on orders (cost=0.42..4.95 rows=20 width=40) (actual time=0.035..0.171 rows=21 loops=1)
Index Cond: (user_id = 1234)
Heap Fetches: 21节点名仍是 Index Only Scan,但 Heap Fetches: 21 说明它把 21 行全部回表核对了一遍——覆盖索引的收益此刻归零。VACUUM orders 之后 VM 恢复,Heap Fetches 才会重新归零。
第五步:对比 INCLUDE 与多列索引的体积。
CREATE INDEX idx_orders_user_amount ON orders (user_id, amount);
SELECT indexrelid::regclass AS idx,
pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE relname = 'orders' AND indexrelid::regclass::text LIKE '%user%'
ORDER BY pg_relation_size(indexrelid) DESC; idx | size
-----------------------+---------
idx_orders_user_cover | 78 MB
idx_orders_user_amount| 54 MB
idx_orders_user | 26 MBINCLUDE 版比多列版更大(多覆盖了两列),但它的键只有 user_id,不会因为 amount 的排序语义而误导优化器做一些无意义的「有序扫描」选择。
注意事项
别只看节点名,要看
Heap Fetches。Index Only Scan节点不保证真的不回表;Heap Fetches: 0才是。sql-- 用 ANALYZE 而不是裸 EXPLAIN,才会出现 Heap Fetches 行 EXPLAIN (ANALYZE) SELECT id, amount FROM orders WHERE user_id = 42;VM 只由
VACUUM建立,任何写入都会清零对应页的标记。 刚批量写入、大批量UPDATE后,覆盖索引形同虚设,必须等 autovacuum 或手工VACUUM追上。INCLUDE列不能是表达式。 想覆盖表达式的结果,就把它的原列放进 INCLUDE。sql-- 错误!expressions are not supported as included columns CREATE INDEX idx_bad ON tab (lower(email)) INCLUDE (lower(email));INCLUDE列不能参与搜索条件。 把要在WHERE里过滤的列放进INCLUDE,是纯粹的浪费——它既不能加速过滤,还占体积。sql-- 不推荐:amount 想用来过滤,放 INCLUDE 就白建了 CREATE INDEX idx_bad ON orders (user_id) INCLUDE (amount); SELECT * FROM orders WHERE user_id = 42 AND amount > 100; -- amount 部分仍需回表过滤INCLUDE索引永远不会去重。 期待用去重压缩重复键的假想彻底落空;重复键多且载荷列小的时候,多列索引反而更省空间。宽列慎入
INCLUDE。 大文本、大 JSON 会让索引体积失控,还可能触发index row size ... exceeds btree version 4 maximum 2704导致写入失败(这是运行时报错,不是建索引时报错,往往在生产插入时才炸)。sql-- 错误!运行期插入大 content 时才会失败 CREATE INDEX idx_docs_cover ON docs (id) INCLUDE (content);INCLUDE列会破坏 HOT 更新。UPDATE一旦改到载荷列,就无法走 HOT,必须同步维护索引,写放大明显。注意列数上限 32(含 INCLUDE 列)。 键列 + 非键列总数超限,
CREATE INDEX直接失败。Hash、GIN、BRIN 不支持
INCLUDE。 在这些方法上写INCLUDE会报错,覆盖这些索引类型需要另想办法(如把列并入键列,但受限)。索引只覆盖本表。 JOIN 中另一侧的表列仍需访问那张表,除非对那张表也建覆盖索引。
最佳实践
只把「稳定被返回、且很小」的列放进
INCLUDE。 一个bigint主键、一两个numeric或timestamptz通常是甜点区;大文本、宽 JSON 一律不进。sqlCREATE INDEX idx_orders_user_cover ON orders (user_id) INCLUDE (id, amount, created_at);用
pg_index.indnkeyatts与indnatts复核设计意图。 两者之差就是 INCLUDE 列数,一眼看出索引是不是被塞得太胖。sqlSELECT indexrelid::regclass AS idx, indnkeyatts AS keys, indnatts AS total FROM pg_index WHERE indrelid = 'orders'::regclass;上线后用
Heap Fetches验收,而不是只看节点名。 在真实负载下跑EXPLAIN (ANALYZE, BUFFERS),确认Heap Fetches接近 0。sqlEXPLAIN (ANALYZE, BUFFERS) SELECT id, amount FROM orders WHERE user_id = 42;覆盖索引 + 部分索引组合,把索引做到「又小又全」。 只索引热数据,同时带上常用的返回列。
sqlCREATE INDEX idx_jobs_pending_cover ON jobs (created_at) INCLUDE (user_id, kind) WHERE status = 'pending';别为了「顺手覆盖」而无脑加列。 每多一个 INCLUDE 列,就多一份写入放大和 HOT 破坏的风险;先用监控数据证明回表是真实瓶颈,再动手。
高频写的表要给 autovacuum 更积极的策略。 VM 靠 VACUUM 恢复,覆盖索引在写密集表上要配合更频繁的 autovacuum 才有意义。
sqlALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.02, autovacuum_analyze_scale_factor = 0.01);优先用
INCLUDE而非多列键来表达「纯载荷」。 这样索引的排序语义、唯一性语义保持干净,INCLUDE列也不会污染上层的后缀截断。建覆盖索引走
CONCURRENTLY,建完必ANALYZE。 大表上直接CREATE INDEX会长时间持写锁。sqlCREATE INDEX CONCURRENTLY idx_orders_user_cover ON orders (user_id) INCLUDE (id, amount, created_at); ANALYZE orders;定期复核覆盖索引的使用率与体积。
idx_scan = 0或体积远大于收益的,列入清理清单。sqlSELECT indexrelid::regclass AS idx, idx_scan, pg_size_pretty(pg_relation_size(indexrelid)) AS size FROM pg_stat_user_indexes WHERE relname = 'orders' ORDER BY idx_scan, pg_relation_size(indexrelid) DESC;
下一节
继续学习:降序索引