Skip to content
Published on

建了索引却不走索引的原因 — 那些优化器对而你错的情况

分享
Authors

引言 — 索引已经建好了,为什么还是 Seq Scan

找到慢查询、建好索引、再跑一次 EXPLAIN,计划却一点没变。CREATE INDEX 明明成功了,在 pg_indexes 里也能看到。可优化器依旧在扫描整张表。

在这种情况下,搜索能找到的建议通常有两种:用 SET enable_seqscan = off 强制,或者加提示。两者都不是诊断,而是压制症状。强行走索引反而让查询变得更慢的情况,在现实中相当常见。

先把前提纠正过来。优化器不使用索引,多半是正确的判断。少数不正确的情况原因是固定的,而这些原因都会在执行计划里留下痕迹。本文就来梳理如何按原因去读这些痕迹。

前提 — 不使用索引多半是正确的判断

基于成本的优化器会计算各个可行计划的成本,选出最便宜的那个。索引扫描的成本包括读取索引页,以及为每个匹配行随机访问堆页面的开销。顺序扫描按物理顺序读页,因此能享受到预取和读取合并的收益。

所以强行走索引之后,往往只是确认了优化器本来就是对的。

-- 优化器的选择
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE status = 'completed';
Seq Scan on orders  (cost=0.00..20117.00 rows=612430 width=88)
                    (actual time=0.014..184.221 rows=611884 loops=1)
  Filter: (status = 'completed')
  Rows Removed by Filter: 188116
  Buffers: shared hit=1211 read=10906
Execution Time: 231.774 ms
-- 强制走索引的结果
SET enable_seqscan = off;
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE status = 'completed';
RESET enable_seqscan;
Index Scan using idx_orders_status on orders
  (cost=0.42..68914.33 rows=612430 width=88)
  (actual time=0.061..1042.883 rows=611884 loops=1)
  Index Cond: (status = 'completed')
  Buffers: shared hit=8842 read=598112
Execution Time: 1094.201 ms

从 231ms 变成 1094ms,慢了四倍以上。读取的块数从 12117 涨到 606954,足足 50 倍。对于一个会返回 76 个百分点行数的条件,优化器早就知道索引是亏本买卖。

enable_seqscan = off 并不是禁止顺序扫描,只是给它的成本加上一个很大的常数。它作为诊断工具非常好用,但绝不能当作生产环境的配置。

选择性 — 索引反而变成亏本买卖的临界点

分界线大概在哪里?常被引用的数字是 5 到 10 个百分点。但只背这个数字会让判断出错。真正决定分界线的变量有三个。

第一是 random_page_cost。默认值 4.0 是按机械硬盘定的。在 SSD 或 NVMe 上如果原封不动地保留这个值,优化器就会把随机访问估得比实际贵四倍,从而低估索引。

-- SSD 环境下更现实的值
ALTER SYSTEM SET random_page_cost = 1.1;
SELECT pg_reload_conf();

仅这一行就能改变计划的情况,比想象中多得多。「索引不走」这类咨询里,相当一部分只靠这一个设置就解决了。

第二是物理有序度。当索引顺序与堆顺序一致时,随机访问实际上就变成了顺序访问。

SELECT attname, n_distinct, correlation
FROM pg_stats
WHERE tablename = 'orders'
  AND attname IN ('created_at', 'user_id', 'status');
  attname   | n_distinct | correlation
------------+------------+-------------
 created_at |         -1 |    0.998304
 user_id    |      48210 |    0.003911
 status     |          6 |    0.412008

created_at 的相关度接近 1,所以即便查询很宽的范围,索引依然有利。user_id 接近 0,所以在相同选择性下要不利得多。同样是 5 个百分点,答案却因列而异,原因就在这里。

第三是返回的列。SELECT * 必然要访问堆,但如果需要的列全部包含在索引里,就有可能走 Index Only Scan。

-- 用 INCLUDE 建覆盖索引 (PostgreSQL 11 以上)
CREATE INDEX CONCURRENTLY idx_orders_status_covering
  ON orders (status) INCLUDE (id, total, created_at);
Index Only Scan using idx_orders_status_covering on orders
  (actual time=0.038..142.118 rows=611884 loops=1)
  Index Cond: (status = 'completed')
  Heap Fetches: 1204

只有 Heap Fetches 足够小才有意义。这个值很大说明可见性映射不是最新的,需要执行 VACUUM

不 sargable 的条件 — 函数、隐式转换、前导通配符

索引是按列值本身排序的。一旦在列上套了什么东西,这份排序就失去了作用。

-- 不走索引: 列上套了函数
SELECT * FROM orders WHERE date(created_at) = '2026-07-26';

-- 走索引: 改写成范围条件,让列保持原样
SELECT * FROM orders
WHERE created_at >= '2026-07-26'
  AND created_at <  '2026-07-27';

-- 不走索引: 列这一侧有运算
SELECT * FROM order_items WHERE price * quantity > 100000;

-- 走索引: 移到生成列或表达式索引上
CREATE INDEX idx_items_amount ON order_items ((price * quantity));

LOWER(email) 也属于同一类。不过这里附带着一个常见误解。建了表达式索引之后,查询也必须使用完全相同的表达式

CREATE INDEX idx_users_email_lower ON users (lower(email));

-- 走索引
SELECT * FROM users WHERE lower(email) = 'a@example.com';

-- 不走索引: 表达式不同
SELECT * FROM users WHERE lower(trim(email)) = 'a@example.com';

隐式转换失败得更安静。PostgreSQL 在类型不匹配时大体会报错,但对可以转换的组合会悄悄地完成转换。

-- code 列是 varchar,索引也是按 varchar 建的
EXPLAIN SELECT * FROM products WHERE code = 12345;
ERROR:  operator does not exist: character varying = integer

PostgreSQL 在这里会报错。问题出在相反的方向。

-- id 是 bigint,而参数以 numeric 传进来的情况
EXPLAIN SELECT * FROM orders WHERE id = 1001::numeric;
Seq Scan on orders  (cost=0.00..24117.00 rows=4001 width=88)
  Filter: ((id)::numeric = '1001'::numeric)

列这一侧被转换成了 (id)::numeric,那一刻 bigint 索引就用不上了。如果在计划里看到列名上挂着 :: 转换,那就是这个问题。

在 MySQL 里发生得频繁得多。把字符串列和数字比较时,它会不报错地把两边都转成数字,而由于被转换的是列这一侧,索引就死了。此外,如果参与连接的两个列排序规则不同,也会发生同样的事。把 utf8mb4_general_ciutf8mb4_unicode_ci 的列连接起来,然后搞不清为什么慢的案例非常常见。

LIKE 的前导通配符也是同一个道理。

-- 走索引: 前缀被固定,可以做索引范围查找
SELECT * FROM users WHERE name LIKE 'kim%';

-- 不走索引: 不知道起点,只能全部看一遍
SELECT * FROM users WHERE name LIKE '%kim%';

这里常见的错误答案是「那就引入全文搜索引擎吧」。在那之前,很多情况在 PostgreSQL 内部就能解决。

-- pg_trgm: 用索引处理子串搜索
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_users_name_trgm
  ON users USING gin (name gin_trgm_ops);

EXPLAIN ANALYZE SELECT * FROM users WHERE name LIKE '%kim%';
Bitmap Heap Scan on users  (actual time=2.114..8.902 rows=412 loops=1)
  Recheck Cond: (name ~~ '%kim%'::text)
  ->  Bitmap Index Scan on idx_users_name_trgm  (actual time=1.884..1.884 rows=498 loops=1)
        Index Cond: (name ~~ '%kim%'::text)

在 C 以外的区域设置下,有时连前缀 LIKE 都用不上普通的 B-Tree 索引。这时要用 text_pattern_ops 运算符类建一个辅助索引。

CREATE INDEX idx_users_name_prefix
  ON users (name text_pattern_ops);

复合索引的前导列规则、OR 与 NULL

复合索引 (a, b, c) 的结构是先按 a 排序,a 相同的再按 b 排序,b 也相同则按 c 排序。就像电话簿先按姓排序、同姓的再按名排序一样。不知道姓,只拿着名字是找不到人的。

CREATE INDEX idx_orders_multi ON orders (user_id, status, created_at);
  • WHERE user_id = 1 — 可以查找
  • WHERE user_id = 1 AND status = 'paid' — 可以查找
  • WHERE user_id = 1 AND created_at >= '2026-07-01' — 只用 user_id 收窄,created_at 按过滤处理
  • WHERE status = 'paid' — 没有前导列,无法查找

即便是最后这种情况,PostgreSQL 也并非完全用不上索引。如果索引比表小得多,它可能会选择用 Index Only Scan 扫遍整个索引的计划。但那是全扫描而不是查找,所以达不到你期待的性能。

决定列顺序的原则很明确:把等值条件的列放前面,范围条件的列放后面。因为范围条件所在列之后的那些列无法用于查找,只能作为过滤条件工作。

OR 条件稍有不同。如果每一项都有索引,PostgreSQL 可以用位图把它们合并起来。

EXPLAIN ANALYZE
SELECT * FROM users WHERE email = 'a@b.com' OR phone = '01012345678';
Bitmap Heap Scan on users  (actual time=0.061..0.064 rows=2 loops=1)
  Recheck Cond: ((email = 'a@b.com') OR (phone = '01012345678'))
  ->  BitmapOr  (actual time=0.052..0.052 rows=0 loops=1)
        ->  Bitmap Index Scan on idx_users_email  (actual time=0.031..0.031 rows=1 loops=1)
        ->  Bitmap Index Scan on idx_users_phone  (actual time=0.019..0.019 rows=1 loops=1)

看到 BitmapOr 节点,就说明处理得不错。问题在于 OR 中只有一项没有索引的时候。那一瞬间整体就退化成顺序扫描。请记住,只要有一项没有索引,其余的索引就成了废物。这种时候用 UNION ALL 拆开确实管用。

NULL 也常被误解。「索引不存储 NULL」这种说法流传很广,但那说的是 Oracle 的 B-Tree 索引。PostgreSQL 的 B-Tree 会存储 NULL,所以 IS NULL 条件同样能由索引处理。

EXPLAIN ANALYZE
SELECT * FROM orders WHERE cancelled_at IS NULL;
Index Scan using idx_orders_cancelled_at on orders
  (actual time=0.022..1.884 rows=304 loops=1)
  Index Cond: (cancelled_at IS NULL)

不过如果 IS NOT NULL 会返回表中的大部分行,那就又回到了前面讲的选择性问题。

统计信息一旦陈旧,优化器就会精确地算错

大批量导入或大批量删除之后计划突然变怪,几乎总是统计信息的问题。优化器会依据统计信息精确地计算,但当这些统计信息与现实不符时,结果自然也与现实不符。

-- 确认最后一次 ANALYZE 的时间以及此后的变更量
SELECT relname,
       n_live_tup,
       n_mod_since_analyze,
       last_analyze,
       last_autoanalyze
FROM pg_stat_user_tables
WHERE relname = 'orders';
 relname | n_live_tup | n_mod_since_analyze |    last_analyze     | last_autoanalyze
---------+------------+---------------------+---------------------+------------------
 orders  |     804112 |              611903 | 2026-07-19 03:12:44 | 2026-07-19 03:12:44

一张 80 万行的表里改动了 61 万条,统计信息却还是一周前的。autovacuum 的默认 analyze_scale_factor 是 0.1,也就是要变动 10 个百分点才会触发,而在大表上这个阈值实在太大了。

-- 大表按表级别调低阈值
ALTER TABLE orders SET (
  autovacuum_analyze_scale_factor = 0.01,
  autovacuum_analyze_threshold = 5000
);

-- 批量导入结束后显式刷新
ANALYZE orders;

有一点需要注意。如果是做大批量导入的批处理流水线,导入结束后主动调用 ANALYZE,永远比等 autovacuum 更好。autovacuum 在导入之后的几分钟内可能都不会运行,而这期间进来的查询就会按错误的计划执行。

部分索引成为答案的场景,以及索引另一面的成本

把到目前为止的原因整理成一张表,就是下面这样。

症状 (计划里能看到的现象)原因解法
Seq Scan 带着很大的 Rows Removed by Filter选择性低部分索引、覆盖索引、调整 random_page_cost
Filter 中出现函数调用条件不 sargable改写成范围条件或使用表达式索引
列上挂着类型转换标记类型或排序规则不匹配让参数类型与列保持一致
Filter 中出现 LIKE 前导通配符无法固定前缀pg_trgm 的 GIN 索引
复合索引条件里缺少前导列前导列规则重新设计列顺序或另建索引
预估 rows 与实际 rows 差距很大统计信息老化或列间相关性ANALYZE、调高 STATISTICS、扩展统计信息

部分索引是能一次解决其中多个问题的工具。尤其对那种只有整体百分之几会被查询的状态列,威力很大。

-- 只查询未处理订单的工作负载
CREATE INDEX CONCURRENTLY idx_orders_pending
  ON orders (created_at)
  WHERE status = 'pending';

-- 大小对比
SELECT indexrelname,
       pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE relname = 'orders';
      indexrelname       |  size
-------------------------+---------
 idx_orders_status       | 42 MB
 idx_orders_pending      | 312 kB

42MB 的索引变成了 312KB。缩小的不只是体积,写入成本也随之下降,因为不符合条件的行在 INSERT 和 UPDATE 时根本不会碰这个索引。

这里必须谈谈索引另一面的成本。索引不是免费的。

  • 每一次 INSERT 和 DELETE 都会更新该表上的全部索引
  • UPDATE 在被修改的列不在任何索引里时可以通过 HOT 更新跳过索引,但索引一多、页面没有富余空间时,HOT 就会失效。调低 fillfactor 留出余量会有帮助。
  • 索引一多,候选计划也随之增多,规划耗时同样会上升。

所以必须定期清理那些没在用的索引。

SELECT s.relname AS table_name,
       s.indexrelname AS index_name,
       s.idx_scan,
       pg_size_pretty(pg_relation_size(s.indexrelid)) AS size
FROM pg_stat_user_indexes s
JOIN pg_index i ON i.indexrelid = s.indexrelid
WHERE s.idx_scan = 0
  AND NOT i.indisunique
  AND NOT i.indisprimary
ORDER BY pg_relation_size(s.indexrelid) DESC;
 table_name | index_name                 | idx_scan |  size
------------+----------------------------+----------+--------
 orders     | idx_orders_updated_at      |        0 | 88 MB
 events     | idx_events_legacy_type     |        0 | 41 MB

删除前请先确认两件事。第一,pg_stat_user_indexes 是自上次统计重置以来的累计值,所以必须确认重置发生在什么时候。只在月末批处理中使用的索引,不能在月初就下判断。第二,在只读副本上使用的索引不会计入主库的统计。需要在副本上也跑同样的查询来确认。

-- 确认统计收集的起始时间
SELECT stats_reset FROM pg_stat_database WHERE datname = current_database();

-- 如果只想把它排除出计划先观察一段时间而不删除 (直接修改目录,不建议在生产使用)
UPDATE pg_index SET indisvalid = false
WHERE indexrelid = 'idx_orders_updated_at'::regclass;

PostgreSQL 没有安全停用索引的官方命令。上面的做法是直接动系统目录,生产环境应当避免。现实可行的流程是:删除之前先把索引定义以文本形式保存下来,出问题就原样重建。

SELECT indexdef FROM pg_indexes WHERE indexname = 'idx_orders_updated_at';
DROP INDEX CONCURRENTLY idx_orders_updated_at;

结语 — 怀疑索引之前,先读执行计划

「索引不走」这个问题其实分两类:优化器判断正确所以不走,和自己写的查询形态本身就无法使用索引所以走不了。这两者的应对方式截然相反。前者要么换索引要么放弃,后者则要改查询。

区分方法很简单。看执行计划里的 Filter 那一行。如果条件原样照搬地写在那里,就是选择性问题;如果条件被函数或类型转换包住了,就是查询问题。而如果预估 rows 与实际 rows 相差很大,那就是统计信息问题。

这三者没有一个能靠 enable_seqscan = off 修好。强制选项只在验证假设时使用,验证结束后就回到真正的原因上去。