第 04 章

索引

第 1 章给了堆和 ctid,第 3 章给了可见性图——这一章讲索引如何利用它们加速查询,以及不同索引类型各自的机制与战场。主线在这里收口:PG 的索引全是次级的,每个索引项都指向堆里的 ctid;它们靠可见性图省掉回表(index-only scan);而在被更新的列上加索引,会直接破坏第 2 章的 HOT。

把索引想成一本字典的目录:按某个键排好序,告诉你「这个词在第几页」。这个画面对 PG 大体成立,但缺了三处关键。其一,目录指向的「页码」是堆里的物理坐标 ctid,拿到坐标还得翻回正文(回表);其二,不同形态的数据需要不同形态的目录——有序键用 B-tree,多值文档用倒排,大表的物理顺序用块摘要;其三,目录本身不记录「这一版对谁可见」,可见性永远要回堆确认,除非可见性图替它担保。这一章把这三处补齐,并把索引接回前三章铺好的物理地基。

本章你将建立的 schema

  • 所有索引都是次级的,索引项 = (key, ctid)——PG 没有「主索引决定行物理位置」一说,每个索引都独立地把键映射到堆里的 ctid,命中后回堆取行。
  • B-tree 管有序与等值范围——叶子按键有序、双向链相连,覆盖 = < > BETWEEN ORDER BY 这一整族查询。
  • GIN 管多值——把一行里的多个键(jsonb 每个键值、数组每个元素、全文每个 lexeme)倒排到包含它的行。
  • BRIN 管物理有序的大表——只为每段 block range 存 min/max 摘要,索引极小,适合按时间追加的日志表。
  • index-only scan 靠可见性图省回表——查询所需列都在索引里、且目标页在可见性图标为 all-visible 时,不回堆。

§1B-tree:默认索引,管有序与等值范围

叶子节点按键有序、双向链相连;索引项是 (key, ctid),命中后拿 ctid 回堆取整行。

为什么需要它

第 1 章立下「堆无序」:按某列取数据,堆本身帮不上忙。B-tree 把这件事补上——它在键空间里维持一棵平衡多叉树,叶子按键有序串成双向链表,于是等值查找走对数级路径、范围扫描沿叶子链顺序推进、ORDER BY 直接借用叶子的天然有序。一种结构同时吃下 = < > BETWEEN ORDER BY 这一整族最常见的查询,这是它成为默认索引的原因。

B-tree 是 CREATE INDEX 不指定类型时的默认访问方法。它的形状是经典的:一个 root 页,若干层 internal(中间)页负责导航,最底层 leaf(叶子)页存实际的索引项。每个 leaf 页通过 special space(第 1 章 §2 提过的页尾保留区)里的左右兄弟指针,与相邻叶子双向相连——这条叶子链是范围扫描和 ORDER BY 不必排序就能顺序输出的物理基础。

root internal internal leaf · 键有序 10 · 14 · 19 leaf 23 · 28 · 30 leaf 41 · 50 · 62 heap:ctid (block, offset) 回表
图 4.1·aB-tree 三层结构:root→internal 导航,leaf 按键有序并双向链相连。注意:每个 leaf 项是 (key, ctid),键命中后顺着 ctid 回堆取整行——这一步「回表」是除 index-only scan 外的常态。

底层机制(比文档深一层)。 关键在索引项的内容:一个 leaf 项 = (键值, ctid),ctid 正是第 1 章 隐藏系统列里那个物理坐标 (block, offset)。索引本身不存整行,只存「键 + 这行在堆里的哪个槽」。于是一次索引查找分两步:先在 B-tree 里按键定位到 leaf 项拿到 ctid,再拿 ctid 回堆把整行读出来——这一步在执行计划里就是 Index Scan 节点伴随的堆访问。回表是索引的常态成本;唯一免掉它的路径是 §6 的 index-only scan。理解了「索引项指向 ctid、命中要回堆」,就理解了 PG 索引的全部基本盘:它是一层「键 → 物理位置」的映射,而非数据本身的另一份拷贝。

另一个值得记住的细节:B-tree 项里既然带 ctid,那么同一行的不同版本(第 1 章那些并存的 tuple)在索引里是不同的项——每个版本各有自己的 ctid。这正是「在频繁更新的列上多建索引会加剧膨胀」的根源,留到 §8 算这笔账。

PG 18 新增 · skip scan

多列索引 (a, b) 过去有个硬限制:查询若不带前导列 a 的等值谓词(只给了 b 的条件),这个索引基本用不上,只能退化成全表扫描。PG 18(2025-09 起)引入 B-tree skip scan:规划器能让 B-tree 在前导列的各个不同取值之间「跳跃」,对每个 a 值各做一次内部查找,从而在缺少前导列等值谓词时也利用上该索引。前导列基数越低(不同值越少),跳跃次数越少、收益越大。这把「多列索引必须带满前导列」的旧经验放宽了一档。

§2访问方法概览:按数据形态选索引

B-tree 之外,PG 还内置 Hash、GIN、GiST、SP-GiST、BRIN——选哪个取决于数据的形态,不取决于表多大。

为什么需要它

B-tree 假设键之间存在一维全序(能比大小、能排序)。但现实里的查询谓词远不止「比大小」:判断一个 jsonb 是否包含某子结构、一个数组是否含有某元素、一段文本是否匹配某词、两个几何对象是否相交、按距离取最近邻——这些都不是全序能表达的。PG 把「索引」抽象成可插拔的访问方法(access method),每种访问方法实现一套自己的查找语义,于是不同形态的数据各有称手的索引。选型的第一性原理因此是「数据与谓词长什么形态」,而不是「表大不大」。

PG 内置的索引访问方法及其主战场:

内置索引访问方法
访问方法核心结构主战场
B-tree平衡多叉树,叶子有序双向链= < > BETWEEN ORDER BY,默认选择
Hash哈希桶仅等值 =;多数场景 B-tree 已够用,少见
GIN倒排索引多值:jsonb 包含、数组包含、全文检索
GiST通用平衡树框架几何、范围类型、最近邻(KNN)
SP-GiST空间分区(非平衡)四叉树 / trie 形态:点数据、IP 前缀、文本前缀
BRIN每段 block range 的 min/max 摘要物理顺序与列值高度相关的大表
等值/范围/排序? 标量全序 是 B-tree 默认 否 jsonb/数组/全文? 一行多值 是 GIN 否 几何/最近邻? 空间 · KNN 是 GiST 否 超大表 + 物理有序 → BRIN 否则回到 B-tree
图 4.2按数据形态选索引的决策树。注意:分叉问的是「数据与谓词长什么形态」,不是「表大不大」。表大本身不构成「加 B-tree」的理由;只有当大表的物理存储顺序与列值高度相关时,BRIN 才胜过 B-tree。

下面三节按这棵决策树,逐一拆开 GIN、GiST、BRIN 的机制——B-tree 已在 §1 讲透,Hash 与 SP-GiST 留作扩展(前者多数场景被 B-tree 取代,后者是 GiST 的非平衡变体,适合四叉树 / trie 类的分区数据)。

§3GIN:把一行的多个值倒排出去

倒排索引——把一行里的每个键(jsonb 键值、数组元素、全文 lexeme)映射到包含它的所有行。

为什么需要它

B-tree 的索引项是「一行 → 一个键」,可 jsonb、数组、全文这类列是「一行 → 多个键」:一个 jsonb 文档有几十个键值对,一个数组有若干元素,一段文本切词后有许多 lexeme。要问「哪些行的数组含有 5」「哪些文档包含 {"a":1}」,需要的是反方向的映射:从单个值 → 含有它的行集合。GIN(Generalized Inverted Index,通用倒排索引)就是这张反向表。

GIN 的结构是倒排表:把每一行拆成它包含的多个键(element),为每个键维护一个「含有该键的行的 ctid 列表」(posting list / posting tree)。查询 tags @> ARRAY['a','b'] 时,GIN 分别取 'a' 和 'b' 的 ctid 列表,求交集,得到同时含两者的行。它的主战场:

  • jsonb 包含:data @> '{"status":"active"}'——jsonb 默认的 GIN opclass 把每个键和值都拆成可检索的 element。
  • 数组包含:tags @> ARRAY['x']、tags && ARRAY['x','y'](重叠)。
  • 全文检索:to_tsvector(body) @@ to_tsquery('postgres & index')——每个 lexeme 是一个 element。

底层机制(比文档深一层)。 GIN 的代价集中在写入维护。一次 INSERT 一行含 30 个键的 jsonb,意味着要往 30 个不同 element 的 posting list 里各插一个 ctid——若每次都即时改这 30 处,写入会非常昂贵。GIN 用 fastupdate 机制缓冲:新增项先追加进一个临时的pending list(顺序写、便宜),积累到阈值或 VACUUM/autovacuum 触发时,再批量合并进正式的倒排结构。代价是查询时若 pending list 非空,得额外扫一遍它来补齐结果。所以 GIN 的性格是「读极强、写要摊销」:适合读多写少、或能容忍批量合并延迟的多值检索;对写入极频繁的多值列,要权衡 fastupdate 与合并开销。

对照 · GIN vs B-tree on jsonb

在 jsonb 列上也能建 B-tree,但那只支持把整个 jsonb 当一个不可分的值做 = 比较,无法回答「包含某子结构」。要走 @> 这类包含查询,必须用 GIN。反过来,若只按某个固定路径的标量取值过滤(如总是 data->>'status' = 'active'),在该表达式上建 B-tree 往往比整列 GIN 更小更快——选型仍回到「谓词长什么形态」。

§4GiST:几何、范围与最近邻的通用框架

一棵可定制的平衡树框架——内部节点存子树的「包围摘要」,支持几何相交、范围重叠、按距离取最近邻。

为什么需要它

有些查询连「一行多值」都不是,而是「这一行的值是否与查询在某种空间关系上命中」:两个矩形是否相交、一个时间段是否与另一个重叠、离某点最近的 10 个坐标。这类谓词没有一维全序,但有「包含 / 重叠 / 距离」的层级结构。GiST(Generalized Search Tree,通用搜索树)提供一棵平衡树骨架:每个内部节点存一个能覆盖其全部子树的摘要(如最小包围盒),查询时用这个摘要快速判断「该子树是否含命中项」,确定不含就整棵剪掉。

GiST 不是某一种具体索引,而是一个框架:实现一组接口(如何求两个摘要的「联合」、如何判断查询与摘要是否「一致」、如何度量「距离」),就能把它特化成针对某种数据类型的索引。内置和扩展提供了几类常用特化:

  • 几何与空间:point、box、polygon 的相交 / 包含查询;PostGIS 的空间索引底层正是 GiST。
  • 范围类型:tstzrange、int4range 的重叠 &&、包含 @>,常配排他约束(EXCLUDE)防止时间段重叠。
  • 最近邻(KNN):ORDER BY 列 <-> 目标点 LIMIT k——GiST 能按距离有序地吐出最近的若干行,避免「先全表算距离再排序」。

底层机制(比文档深一层)。 KNN 是 GiST 区别于其它索引最独特的能力。它把「距离」做成索引可理解的度量函数,于是查询执行时维护一个按「节点到目标的距离下界」排序的优先队列,每次取出队首子树展开——保证先返回的就是最近的。这让 ORDER BY 距离 LIMIT k 能在拿到 k 个结果后立即停止,而非扫全表。SP-GiST 是 GiST 的近亲,把「平衡树」换成「空间分区」(非平衡,按四叉树 / k-d 树 / trie 切分空间),适合点数据、IP 前缀、文本前缀这类天然分区的形态——一句话带过即可,机制与 GiST 同源。

§5BRIN:为物理有序的大表存块摘要

只为每段连续的 block range 存一个 min/max 摘要,索引体积极小——前提是物理顺序与列值高度相关。

为什么需要它

一张按时间不断追加的日志表,几十亿行、上百 GB。在 created_at 上建 B-tree,索引本身就要占几个 GB、还得随插入持续维护。但这类表有个被忽略的性质:行的物理存储顺序恰好与 created_at 高度一致(先到先存)。BRIN(Block Range Index,块范围索引)专吃这个性质——既然物理相邻的行时间也相邻,那只需记住「第 0~127 个 block 里 created_at 的 min/max」这样一段摘要,就能在按时间范围查询时整段整段地排除。索引大小从「与行数成正比」降到「与 block 段数成正比」,常常小到几十 KB。

BRIN 把表的 block 切成固定大小的 block range(默认每段 128 个 page,由 pages_per_range 控制),每段只存该段内目标列的摘要——对默认的 minmax opclass,就是 min 和 max 两个值。查询 WHERE created_at BETWEEN ... AND ... 时,BRIN 扫一遍这些极小的摘要:某段的 [min, max] 与查询区间无交集,整段 block 直接跳过;有交集,则该段所有 block 进入候选。

查询:created_at ∈ [300, 360] BRIN 摘要 [100,180] [280,370] [400,520] block range 段 #0 跳过 段 #1 进候选 段 #2 跳过 候选段交给 bitmap heap scan 二次校验
图 4.3BRIN 用每段的 min/max 排除不相交的 block range。注意:BRIN 只能说「这段落在查询范围内」,不定位具体行——是 lossy 的,候选段必须交给 bitmap heap scan 逐行二次校验(详见 第 5 章 §bitmap)。

底层机制(比文档深一层)。 BRIN 与 B-tree 有一个本质区别:它不定位具体行,只排除整段 block range。摘要 [min, max] 说「这段与查询范围有交集」,但段内具体哪些行命中、哪些是噪声,BRIN 一无所知——它是 lossy(有损)的。因此 BRIN 扫描的产物不是行,而是一张「候选 block 的位图」,必然接一个 bitmap heap scan:按位图把候选 block 整段读进来,再对每一行重新核对谓词,剔除不满足的。这条「BRIN → bitmap heap scan 二次校验」的链路是 BRIN 计划的固定形态,下一章 §bitmap 会从执行器视角再讲一遍。

由此也推出 BRIN 的适用红线:物理顺序与列值相关性越高,每段的 [min, max] 区间越窄、越不重叠,排除越干净。若相关性差(比如在一个随机乱序的列上建 BRIN),几乎每段的区间都横跨整个值域、彼此重叠,结果是「哪段都排除不掉」,BRIN 退化成几乎全表扫描。所以 BRIN 不是「大表的省空间索引」,而是「物理有序的大表的省空间索引」——这正是图 4.2 决策树把它放在「超大表 + 物理有序」分支的原因。

§6部分索引、表达式索引、覆盖索引

三种正交的裁剪手段——只索引一部分行、索引一个计算结果、把额外列塞进索引叶子。

为什么需要它

前五节选的是「索引类型」;这一节调的是「索引什么」。同一种 B-tree,可以只覆盖满足条件的行(更小)、可以索引一个表达式的结果(让函数谓词走索引)、可以额外携带几个列(支持不回表)。这三个旋钮互相正交,是把索引从「能用」调到「精准命中且尽量小」的常规手段。

部分索引(partial index)

在 CREATE INDEX 上加 WHERE,只为满足条件的行建索引项:

部分索引 sql
CREATE INDEX idx_orders_active ON orders (created_at)
  WHERE status = 'active';

若业务里 99% 的查询只关心 status='active' 的订单,而历史订单大多是 'archived',这个索引就只为那 1% 的活跃行建项——索引体积、维护成本随之骤降,且只在查询条件能蕴含该 WHERE 时才被规划器选用。典型用法还包括「只索引 NOT NULL 的行」「只索引未删除的行」。

表达式索引(expression index)

索引一个计算结果而非原始列。最常见的是大小写无关查找:

表达式索引 sql
CREATE INDEX idx_users_lower_email ON users (lower(email));

-- 这样下面这条才能走索引(谓词左侧必须与索引表达式完全一致):
SELECT * FROM users WHERE lower(email) = 'a@b.com';

普通的 email 索引帮不了 lower(email) = ...,因为索引里存的是原值、查询要的是函数值,两者对不上。表达式索引直接把 lower(email) 的结果建进 B-tree,谓词左侧与索引表达式逐字一致时即可命中。代价是写入时要为每行计算一次该表达式。

覆盖索引(covering index / INCLUDE)

用 INCLUDE 把查询要返回、但不参与过滤排序的额外列塞进索引叶子:

覆盖索引 sql
CREATE INDEX idx_orders_cover ON orders (customer_id) INCLUDE (status, total);

INCLUDE 里的列只存在 leaf 项里,不参与 B-tree 的排序与导航(不进 internal 页)。它的唯一目的,是让「按 customer_id 查、只取 status 和 total」这类查询所需的列全在索引里,从而满足下一节 index-only scan 的前提——把本该回堆取的那几列,预先放进索引叶子。这是把 §7 的优化「喂」给规划器的主要手段。

§7Index-only scan:靠可见性图省掉回表

查询所需列都在索引里、且目标页在可见性图标为 all-visible 时,不回堆——这一步省略由 VACUUM 维护的可见性图担保。

为什么需要它

§1 说清了:索引命中后通常要回堆取整行,这步回表是随机 I/O,往往比扫索引本身还贵。如果查询只需要索引里已有的列,回堆就显得多余——除了一件事:索引项不带可见性信息,不回堆就无法确认「这一版对当前快照可见吗、是不是已被删的死元组」。index-only scan 要解决的正是这个矛盾:怎样在不回堆的前提下,仍然保证只返回可见的行。答案是借用第 3 章的可见性图。

index-only scan 的成立需要两个条件同时满足:

  • 列覆盖:查询引用的所有列,都能从索引项里取到(包括 INCLUDE 进去的列)。少一列要回堆,就退化成普通 Index Scan。
  • 页可见:索引项指向的堆页,在可见性图里被标为 all-visible(整页所有 tuple 对所有事务都可见)。
索引项 (key, ctid) 上路 · 回表 回 heap page 读整行 · 随机 I/O 返回行 下路 · index-only VM = all-visible? 查可见性图 是 直接返回 不回堆 否 → 回堆
图 4.1回表 vs index-only scan。上路命中后回 heap page 取整行;下路先查可见性图,若该页标为 all-visible 则直接返回、不回堆。注意:免回堆的前提是可见性图说「整页可见」——页一旦因写入失去 all-visible 标记,下路就回落到上路。

底层机制(比文档深一层)。 核心矛盾在于:索引项不携带可见性信息。一个 leaf 项只有 (key, ctid),没有 xmin/xmax——它无从判断这一版是不是死元组、是否对当前快照可见。所以正常的 Index Scan 必须回堆,读出 tuple 头部的 xmin/xmax 才能定可见性。第 3 章的可见性图给了一条捷径:当某个堆页被标记为 all-visible,意味着「这一整页的所有 tuple 都已对所有事务可见、无任何待清理的死元组」——此时索引项指向的那行必然可见,可见性确认这一步可以跳过,于是不必回堆。

这条因果链直接推出 index-only scan 的脆弱性:可见性图由 VACUUM 维护(见第 3 章),而任何对页的写入(INSERT/UPDATE/DELETE)都会清掉该页的 all-visible 标记。因此一张刚刚大量写入、还没来得及 VACUUM 的表,可见性图里几乎没有 all-visible 页,index-only scan 会大面积退化成普通 Index Scan(每行都回堆确认)。这解释了一个常见现象:同一条查询,在数据稳定(已 vacuum)的表上走 Index Only Scan 飞快,刚灌完数据时却变成 Index Scan 慢一截。结论:index-only scan 不是「建了覆盖索引就永久生效」的静态优化,它依赖 VACUUM 把页维护成 all-visible——这是第 3 章与本章在主线上的直接咬合点。

失败模式 · 误以为覆盖索引就稳吃 index-only

EXPLAIN 里出现 Index Only Scan 不代表回堆已被完全消除——计划节点下方的 Heap Fetches 才是真相。它统计「因目标页非 all-visible 而仍回堆」的次数。Heap Fetches 居高不下,说明该表 VACUUM 不及时、可见性图覆盖率低,名义上的 index-only scan 实质仍在大量回堆。处理方向是让 autovacuum 跟上写入节奏,而非再加索引。

§8索引与 HOT 的权衡:索引不是越多越好

每加一个索引,被它覆盖的列上的更新就无法走 HOT——写放大加索引膨胀,这笔账要算。

为什么需要它

前面七节都在讲索引带来的读收益。这一节算写的代价,把主线收口回第 2 章的 HOT。「多建几个索引反正不亏」是个错觉:索引在读路径上是助力,在写路径上是负债,且这笔负债通过 HOT 这个机制具体计价。理解它,才能回答「这张高频更新的表,到底该建几个索引」。

回顾第 2 章的 HOT(Heap-Only Tuple):当一次 UPDATE 满足「新版本能落在同一个 Page」且「没有改动任何被索引的列」两个条件时,PG 走 HOT 更新——新版本通过页内 line pointer 的重定向链挂在旧槽后面,所有索引都无需更新,因为索引项指向的旧 line pointer 槽顺着重定向就能找到新版本。HOT 是 PG 缓解「更新即新增版本」开销的核心手段。

底层机制(比文档深一层)。 HOT 的触发条件里,「没改任何被索引的列」是与索引直接挂钩的那一项。这意味着:一个列一旦被某个索引覆盖,对它的 UPDATE 就再也不能走 HOT——因为索引项是 (key, ctid),键变了或新版本要新的 ctid,索引必须同步插入新项、标记旧项失效。后果是双重的:

  • 写放大:本可一次 HOT 解决的更新,现在要额外维护每一个覆盖该列的索引——每个索引各写一个新 leaf 项。索引越多、被更新的列被越多索引覆盖,单次 UPDATE 的写入越重。
  • 索引膨胀:旧版本的索引项不会立即消失,要等 VACUUM 回收(与第 3 章死元组回收同源)。频繁更新被索引的列,索引里堆积大量指向死元组的旧项,索引随之膨胀、查询要扫的页变多。

结论:索引不是越多越好。在一张频繁更新的表上,每加一个索引都要问两件事——它覆盖的列会被更新吗?如果会,这次更新就从「一次 HOT」变成「一次堆写 + N 个索引维护 + N 份待 VACUUM 的索引垃圾」。理想的布局是:把高频更新的列排除在索引之外,让这些更新尽量走 HOT;只在真正承担过滤 / 排序的列上建索引。这是「读收益」与「写代价 + HOT 机会成本」之间的工程权衡,没有放之四海的数字,但算账的框架就是这条。

把主线连起来

四章在这里闭环:第 1 章的 ctid 是每个索引项指向的目标;第 2 章的 HOT 决定「更新要不要动索引」——本章 §8 证明加索引会掐断它;第 3 章的可见性图是 index-only scan(§7)免回堆的唯一凭据,而它由 VACUUM 维护。索引不是独立的加速插件,而是嵌在这套存储 + MVCC + 回收机制里的一层映射。下一章 第 5 章接着回答:面对这么多索引与扫描方式,规划器凭什么选其中一个。

§9动手观察:从 Index Scan 到 Index Only Scan 到 GIN

把 §1、§6、§7、§3 拧成一条可观察的实验线:建表灌数据 → 建 B-tree 看 Index Scan → 加 INCLUDE 看 Index Only Scan → 在 jsonb 上建 GIN 看 @> 走 Bitmap Index Scan。下面的 EXPLAIN 输出未在本机执行(具体行数、代价因实例与数据分布而异),用于演示该看计划里的哪个节点、它对应本章哪个概念。

三种扫描方式的 EXPLAIN 对照 sql
CREATE TABLE orders (
  id          bigserial PRIMARY KEY,
  customer_id int,
  status      text,
  total       numeric,
  attrs       jsonb
);
-- 灌入一批数据后(此处省略),并 VACUUM 使可见性图就绪:
VACUUM ANALYZE orders;

-- (1) 普通 B-tree:命中后回堆取整行 → Index Scan
CREATE INDEX idx_cust ON orders (customer_id);
EXPLAIN SELECT * FROM orders WHERE customer_id = 42;
--  Index Scan using idx_cust on orders            <- 未在本机执行
--    Index Cond: (customer_id = 42)

-- (2) 覆盖索引:所需列全在索引里 + 页 all-visible → Index Only Scan
CREATE INDEX idx_cust_cover ON orders (customer_id) INCLUDE (status, total);
EXPLAIN SELECT customer_id, status, total FROM orders WHERE customer_id = 42;
--  Index Only Scan using idx_cust_cover on orders <- 未在本机执行
--    Index Cond: (customer_id = 42)
--    Heap Fetches: 0

-- (3) GIN on jsonb:@> 包含查询 → Bitmap Index Scan on GIN
CREATE INDEX idx_attrs ON orders USING gin (attrs);
EXPLAIN SELECT * FROM orders WHERE attrs @> '{"vip": true}';
--  Bitmap Heap Scan on orders                     <- 未在本机执行
--    Recheck Cond: (attrs @> '{"vip": true}')
--    ->  Bitmap Index Scan on idx_attrs
--          Index Cond: (attrs @> '{"vip": true}')

逐行解读,把每个计划节点映射回本章概念:

  • (1) Index Scan对应 §1:B-tree 按 customer_id 定位到 leaf 项拿 ctid,因为 SELECT * 要全部列,必须回堆取整行。计划名 Index Scan 即「索引定位 + 回表」。
  • (2) Index Only Scan对应 §7 + §6 覆盖索引:查询只要 customer_id/status/total,全被索引(键 + INCLUDE)覆盖;又因 VACUUM ANALYZE 已把页标为 all-visible,Heap Fetches: 0 证明一次堆都没回。这正是可见性图担保下的免回表。
  • (3) Bitmap Index Scan on GIN对应 §3:GIN 对 @> 不产出有序的单行定位,而是吐出一张「命中行的位图」,故上层是 Bitmap Heap Scan、下层是 Bitmap Index Scan on idx_attrs。Recheck Cond 表示按位图取回堆页后还要逐行复核 @>(位图按页粒度有损)——bitmap 扫描的机制留到 第 5 章 §bitmap。
预测一下

orders.status 只有 3 个取值(active/archived/cancelled),其中 WHERE status='active' 命中全表约 60% 的行。已在 status 上建了 B-tree。规划器查 WHERE status='active' 时会用这个索引吗?

展开答案

多半不会。 索引的价值在选择性——筛掉的行越多越值。命中 60% 意味着要回表读全表大半的行,每次回表是随机 I/O,总代价比直接顺序扫全表(Seq Scan)还高;规划器据此多半弃用该索引,选 Seq Scan 或(若要配合其它条件)Bitmap Heap Scan。低选择性列上的 B-tree 常常是「建了也不会被用」的死索引。为什么规划器算得出这一点、代价模型怎么估——正是 第 5 章的主题。

预测一下

一张只增不改、按 created_at 顺序写入的 10 亿行日志表,绝大多数查询是「取某个时间范围的日志」。最省空间、又能有效裁剪的索引是哪种?

展开答案

BRIN(建在 created_at 上)。这张表的物理写入顺序与 created_at 高度相关(只增不改、顺序追加),正好命中 §5 BRIN 的前提:每段 block range 的 [min,max] 时间区间窄且几乎不重叠,按时间范围查时能整段整段排除。索引体积只与 block 段数成正比,常常小到几十 KB——相比之下 created_at 上的 B-tree 要占数 GB 并持续维护。代价是 BRIN 是 lossy 的,候选段要走 bitmap heap scan 二次校验,但对「大范围时间扫描」这类查询完全可接受。

自测

  1. PG 的索引项里存的是什么?为什么说所有索引都是「次级的」?
  2. Index Only Scan 需要哪两个条件同时成立?其中「页可见」这一条由哪个机制、哪个后台过程保障?
  3. 一张物理上随机乱序的大表,在某个列上建 BRIN 几乎不裁剪任何 block——为什么?
  4. 在一个会被频繁 UPDATE 的列上新建一个索引,为什么会同时带来写放大和索引膨胀?
查看参考答案

1. 索引项是 (键值, ctid)——键加上这行在堆里的物理坐标 (block, offset)。「次级」指索引不存整行、也不决定行的物理位置,它只是一层「键 → ctid」的映射;行始终住在无序的堆里(第 1 章),命中索引后要顺着 ctid 回堆取行。PG 没有「主索引」一说,所有索引平权,都是这种间接映射。

2. 其一列覆盖:查询引用的所有列都能从索引项取到(含 INCLUDE 列)。其二页可见:索引指向的堆页在可见性图里标为 all-visible。「页可见」由可见性图保障,而可见性图由 VACUUM/autovacuum 维护——刚大量写入、尚未 vacuum 的表,all-visible 页稀少,index-only scan 会退化成普通 Index Scan。

3. BRIN 只为每段 block range 存 [min,max] 摘要。物理乱序时,每段 block 里的列值几乎横跨整个值域,导致每段的 [min,max] 区间都很宽、彼此大量重叠;任何查询区间都与几乎所有段相交,于是「哪段都排除不掉」,BRIN 退化成近似全表扫描。BRIN 的前提是物理顺序与列值高度相关,乱序表违背了这个前提。

4. 因为该列被索引后,对它的 UPDATE 不再满足 HOT 的「不改任何被索引列」条件,无法走 HOT。写放大:每次更新都要在每个覆盖该列的索引里插入新 (key, ctid) 项;索引膨胀:旧版本的索引项要等 VACUUM 回收,频繁更新使索引里堆积大量指向死元组的旧项。读收益要拿这两笔写代价去抵。

进阶挑战

用 Heap Fetches 证明 index-only scan 依赖 VACUUM

在第 9 节那张 orders 表和覆盖索引 idx_cust_cover 上,构造出「同一条 Index Only Scan 查询,Heap Fetches 从大变 0」的对照:先制造一批非 all-visible 页,跑查询看 Heap Fetches 很高;再 VACUUM,重跑看它归零。用这个实验把 §7 的「依赖可见性图、可见性图依赖 VACUUM」从论断变成可观测的事实。

提示

路径:UPDATE orders SET total = total WHERE customer_id BETWEEN ... ; 改一批行——即便值没变,也会写新版本、清掉这些行所在页的 all-visible 标记。紧接着 EXPLAIN (ANALYZE, BUFFERS) SELECT customer_id, status, total FROM orders WHERE customer_id = 42;,看 Index Only Scan 节点下的 Heap Fetches: 是个较大的数(被迫回堆确认可见性)。然后 VACUUM orders;,重跑同一条 EXPLAIN——Heap Fetches: 0。把「写入清标记 → Heap Fetches 升高 → VACUUM 重建标记 → 归零」这条链与本章 §7、第 3 章可见性图对上,闭环就成立了。