PostgreSQL 查询系列 — 4. 索引扫描
原文:https://habr.com/en/companies/postgrespro/articles/578196/ (作者 Egor Rogov,PostgresPro)
引言
上一篇讲了"从头扫到尾"的顺序扫描;这一篇转向"借助索引跳着找"的索引扫描。索引扫描并不是单一算法,而是一族访问方式的统称:普通索引扫描(Index Scan)、仅索引扫描(Index-Only Scan)、位图扫描(Bitmap Heap Scan / Bitmap Index Scan),它们各自适合不同的场景,选择依据同样落在代价估算上,而代价估算又离不开上一篇讲过的 correlation 等统计量。
索引扫描三兄弟
普通索引扫描(Index Scan)
最基本的方式:优化器先根据查询条件在索引(默认 B-tree)里定位,索引本身按顺序存储着"键值 → 行标识符(TID,指向堆表中具体的页和槽位)"的映射,扫描过程逐个从索引里取出满足条件的行标识符,然后逐条回到堆表(heap)里把真正的行数据取出来。
EXPLAIN 输出里,能直接被索引本身判断、不需要读堆表数据就能确定"要不要这一行"的条件显示为 Index Cond;而那些索引结构本身无法直接判断、必须先把行从堆表里取出来之后才能核实的条件,会显示为额外的 Filter。这个区分很重要:Index Cond 减少的是"要访问的索引项和要回表的行数",而 Filter 只是在已经付出了回表代价之后再做一次筛选,并不能减少 I/O。
由于每一次回表通常对应堆表里一个随机位置的页面,普通索引扫描的代价里包含相当比例的随机 I/O(用 random_page_cost,默认 4.0 计费),这也是为什么并不是"能用索引就一定比顺序扫描快"——当命中的行数占比很高、且这些行在物理上分布得很分散时,大量随机 I/O 的总代价可能反而超过一次纯顺序扫描。
仅索引扫描(Index-Only Scan)
如果一个查询需要的所有列,恰好都已经包含在索引本身(索引键列,或者通过 INCLUDE 附带的非键列)里,那么理论上根本不需要回表,直接从索引里就能拿到全部需要的数据,省掉了随机 I/O 这一大块开销。
但这里有个 MVCC 相关的限制:索引本身不记录行的可见性(哪个事务能看到这一版本),所以要确认一条索引项对当前事务是否可见,原则上还是要去看一眼堆表里对应的行。PostgreSQL 用可见性图(visibility map)来规避这个问题:可见性图按页记录"这一页里的所有行是否都已经对所有活跃事务可见"(即该页不存在还未被所有事务看到的新版本或死元组),只要索引项所在的堆表页面在可见性图里被标记为"全可见",就可以放心地跳过回表这一步;否则还是得老老实实回表确认一次。因此 Index-Only Scan 的实际收益高度依赖于表的可见性图状态——一张频繁更新、VACUUM 又跟不上的表,可见性图会有大量页面不是"全可见",仅索引扫描退化成普通索引扫描的比例就会升高。
位图扫描(Bitmap Index Scan + Bitmap Heap Scan)
当一次查询预计要命中的行数比较多、而这些行在堆表里的物理位置又比较分散(相关性低)时,如果直接用普通索引扫描,会产生大量杂乱无章的随机 I/O。位图扫描把访问过程拆成两步来规避这个问题:
- Bitmap Index Scan:先扫描索引,把所有满足条件的行所在的页面(而不是逐行的具体位置)记录进一个内存中的位图结构;
- Bitmap Heap Scan:拿着这个位图,按照堆表页码从小到大的顺序去访问这些页面,一次性把该页里所有命中的行都取出来。
这样一来,原本杂乱的随机访问被重新组织成了"按页码有序"的访问模式,虽然仍然不是完全的顺序 I/O(会跳过不相关的页),但比起逐行随机跳转要高效得多,可以看作是顺序扫描和随机索引扫描之间的一种折中方案。
代价估算涉及的关键因素
相关性(correlation)
延续第 2 篇讲到的统计量:correlation 描述列的逻辑值顺序和物理存储顺序的吻合程度。相关性接近 ±1 时,按索引顺序访问对应的堆表页面,页面本身在磁盘上大体也是按顺序排列的,即便命中行数较多,索引扫描依然能保持接近顺序 I/O 的效率;相关性接近 0 时,同样数量的命中行会散布在几乎随机的页面位置上,每次回表都可能是一次独立的随机 I/O,代价急剧上升。文章给出的代价公式把"随机页代价"和"顺序页代价"按相关性做了加权组合,本质上是在这两个极端之间插值。
选择率(selectivity)
即条件预计能筛选出多大比例的行,这部分继续依赖上一篇提到的 MCV 列表、直方图等列级统计——选择率越低,需要访问的索引项和回表次数越少,索引扫描相对顺序扫描的优势越明显。
缓存效应(effective_cache_size)
这个参数用来给优化器一个"操作系统缓存 + 共享缓冲池大概能装下多少数据"的估计(并不直接分配内存,只是一个用于代价建模的提示值)。当有效缓存足够大、经常被访问的数据大概率已经在缓存里时,即便是随机访问,实际付出的物理 I/O 代价也会被打折;effective_cache_size 越大,优化器对索引扫描(尤其是需要多次随机访问的场景)的代价估算就会相对越乐观,从而更倾向于选择索引扫描而不是顺序扫描。
覆盖索引:INCLUDE 子句
从 PostgreSQL 11 开始,CREATE INDEX 支持 INCLUDE 子句,允许把一些"非搜索用"的列附加进索引结构里,而不必让它们成为索引键的一部分(不参与排序、不能用于 Index Cond 判断,仅仅是"顺带存一份")。这样做的好处是:一方面避免这些列参与索引键比较带来的额外开销和唯一性约束的干扰,另一方面又能让查询只需要这几个附加列时,直接用 Index-Only Scan 满足需求,不必回表——这正是"覆盖索引"(covering index)思路的具体实现。
位图运算:BitmapAnd 与 BitmapOr
当一个查询的 WHERE 条件涉及多个可以分别走各自索引的子条件时(比如 WHERE a = 1 AND b = 2,a 和 b 各有自己的索引),PostgreSQL 并不局限于只能用其中一个索引:它可以分别对 a 和 b 做 Bitmap Index Scan,得到两个位图,然后:
- 用 BitmapAnd 把两个位图取"与",对应 AND 条件——只保留两个索引都命中的页面/行;
- 用 BitmapOr 把两个位图取"或",对应 OR 条件——只要任意一个索引命中就保留。
这样一条查询就能同时利用多个单列索引组合出接近多列索引的效果,而不需要事先专门建一个复合索引,代价是要为构建和合并位图额外付出一些 CPU 开销。
小结
索引扫描不是单一算法而是一组访问方式:普通 Index Scan 逐行回表、代价里包含随机 I/O;Index-Only Scan 在可见性图配合下能省去回表,但效果取决于表的"全可见"页面比例;Bitmap Heap/Index Scan 通过"先收集页面再按页码顺序访问"把随机 I/O 重新组织成较为有序的访问模式,适合命中行数较多但仍希望利用索引的场景。三者之间以及它们和顺序扫描之间的取舍,最终都归结到代价公式里的几个关键输入:选择率(决定要处理多少行/页)、相关性(决定回表是接近顺序还是接近随机)、effective_cache_size(决定随机访问的"实际代价"打几折)。PostgreSQL 11 起的 INCLUDE 索引和 BitmapAnd/BitmapOr 则分别从"扩展索引覆盖范围"和"组合多个单列索引"两个角度,进一步丰富了索引扫描能够覆盖的查询形态。下一篇文章将从单表访问转向多表连接,从最直观的嵌套循环连接开始。