索引建了,EXPLAIN 里却是全表扫描,或者用了另一个你没想到的索引。 下面按症状排列,每条给出真正的机制,并且能直接用你自己的数字验证。
机制部分按 PostgreSQL 源码核对,PG 13–18 · 标注了各条在哪些版本上成立
这是压倒性的第一名。索引扫描要按行的物理地址一行一行回表,每次都是一次随机 IO。 命中几万行时,几万次随机读远比把表顺序读一遍还贵 —— PG 选全表扫是对的,不是它笨。
怎么确认:看 EXPLAIN 里的 rows。 如果它已经是表行数的百分之几,那索引本来就不该赢。真正该改的是让条件更准,或者让索引 盖住整个查询(INCLUDE)从而免掉回表。
→ 用计算器看这笔账:同一条查询命中 100 万行时,索引直接出局、全表扫 95,193 分;把 amount 补进 INCLUDE 免掉回表之后,仅索引扫描 52,364 分,索引赢回来了。
索引条目里只有键列的值 + 行的物理地址。条件列如果不在索引里,PG 只能先按索引定位、回表读整行、 再测那个条件 —— 不匹配的行,那次随机 IO 就白花了。
怎么确认:EXPLAIN ANALYZE 里 Rows Removed by Filter 很大,就是这个症状。 把过滤列加进索引(能定位就当键列,只为了过滤就放 INCLUDE)。
→ 在计算器里把那一列加进索引,看「回表 IO」这一项掉多少。
B-tree 从首列开始连续定位,首列没条件就定位不了。但 PG 不会因此丢掉这个索引, 而是整个索引扫一遍、逐条过滤(这就是 Bitmap Index Scan 出现在 非首列条件上的原因)。它通常贵,但在别的路径更贵时真的会被选中。
PG 18 起:nbtree 会给缺 = 条件的前导列 自动补一个跳跃数组(skip scan),等价于 首列 = ANY('{每个取值}')。 所以老规矩要改成 —— 首列的 n_distinct 要足够小。首列只有几个取值时,PG 18 上这个索引依然很能打。
→ 看它被真的选中:条件只落在第二列 courier_id、首列 channel 没有条件,PG 17 上索引照样被选中,走位图扫描 8,037 分 —— 前提是表还不大。
→ 切换 PG 17 / 18 看差别:同一组输入,PG 17 上索引用不了、并行全表扫 74,693 分;PG 18 的 skip scan 让它活过来,位图扫描 11,932 分,便宜 6.3 倍。
PG 16 及以前,IN / = ANY 的元素个数是 直接连乘当下潜次数的:两个 300 元素的数组 = 90,000 次下潜。代价被高估到索引直接出局。
PG 17 修了:下潜次数不超过索引页数的 1/3 —— 因为 B-tree 本来就会把相邻的数组键 合并成一次连续扫描,下潜数不可能超过扫到的叶子页数。同一条 SQL,升个小版本就自己好了。
→ 切换 PG 16 / 17,看整张决策地图重画:并行全表扫 77,449 分 → 位图扫描 15,065 分,便宜 5.1 倍。
表超过 min_parallel_table_scan_size(默认 8MB)后 PG 才考虑并行, 之后每翻三倍加一个 worker,上限 max_parallel_workers_per_gather(默认 2)。 并行把 CPU 成本除以约 2.4,看起来很划算。
但 Gather 要按行收钱(parallel_tuple_cost 默认 0.1 分/行)。 所以规律是:返回行少时并行赢,返回行多时并行反而输。 想验证是不是它,把 max_parallel_workers_per_gather 设成 0 再看计划。
→ 用计算器看这条线在哪:300 万行时并行全表扫 74,693 分赢,把行数拖回 30 万,索引以 8,037 分赢回来。
有 LIMIT 时,规划器比的不是总代价,而是 启动代价 + 比例 × 运行代价。一个能提供所需顺序的索引, 启动代价几乎为零、取够 n 行就能停 —— 哪怕它一个 WHERE 条件都没匹配上,也照样赢。
反过来也是坑:如果那个索引的顺序和过滤条件不相关,PG 可能要沿着索引扫很久才凑够 n 行, 于是「加了 LIMIT 反而更慢」。这时给 (过滤列, 排序列) 建一个复合索引才是解。
→ 切换比价口径看名次怎么变:不带 LIMIT 时位图扫描 77,662 分夺冠,加上 ORDER BY id LIMIT 20 之后主键索引以 6.29 分登顶。
代价模型的每一个输入 —— 行数、页数、n_distinct、correlation —— 都来自 ANALYZE 采样。大批量导入之后、或者 autovacuum 跟不上时,规划器眼里的表可能还是一个月前的样子。
怎么确认:跑 EXPLAIN ANALYZE,比较 rows=(估算)和 actual rows=(实际)。 差一两个数量级就是统计信息的问题,先 ANALYZE 你的表; 再看。 倾斜严重的列还可以 ALTER TABLE … ALTER COLUMN … SET STATISTICS 1000; 提高采样精度。
→ 这一条不用计算器 —— 先把统计信息刷新了再来。
列是 varchar 而参数传进来是 int, 或者列上套了函数(WHERE lower(name) = …), 索引就匹配不上这个条件 —— 这不是代价问题,是这个条件压根不算「索引条件」。
怎么确认:EXPLAIN 里这个条件出现在 Filter: 而不是 Index Cond: 里。 解法是让两边类型一致,或者建一个表达式索引(CREATE INDEX … ON t (lower(name)))。
看到 BitmapAnd / BitmapOr 就是这个: PG 分别扫两个索引得到两张位图,求交或求并之后再一次性回表。 这往往比任何单个索引都便宜,也是「我明明建了复合索引,PG 却用了两个单列索引」的答案。
本工具的边界:只枚举两两 AND 组合, 三个以上索引求交、以及 PG 那套启发式挑选逻辑(choose_bitmap_and)没有建模。 判决里会明确提示。
GIN(全文检索、jsonb)、GiST(几何、范围)、 BRIN(超大表的块级摘要)、hash —— 每一种都有完全独立的代价函数,输入也不同(比如 GIN 还要看 pending list 的页数)。
本工具只建模 B-tree。粘进来的统计信息里如果有非 B-tree 索引, 它不会参与比价,并且会明确告诉你 —— 宁可少说,不能说错。
都对不上号?那多半是这几种:分区表(每个分区各自选路径,看 Append 下面)、join 内侧的参数化路径(同一个索引在 join 里和单独查时代价不同)、 或者计划缓存里那条通用计划(PREPARE 出来的计划看不见参数值,按均匀假设估行数, 倾斜列上会差几个数量级)。这三种本工具都没建模。