
慢查询并不总是“少了一个索引”。优化器会在顺序扫描、索引扫描和各种连接算法之间估算成本;即使索引存在,它也可能判断直接读表更便宜。与其凭感觉加索引,不如先读取执行计划,确认数据库实际做了什么,再用数据验证改动是否有效。
先区分计划与实测
以 PostgreSQL 为例,EXPLAIN 展示优化器准备采用的计划,EXPLAIN ANALYZE 会真正执行 SQL,并补充时间、实际行数与循环次数。后者更接近现场,但会产生真实读写;对 UPDATE、DELETE 等语句,应在事务中执行后回滚,或在安全副本上分析。
1 | EXPLAIN (ANALYZE, BUFFERS) |
拿到结果后,先读最耗时的节点,而不是只看最上面的 Limit。常见节点含义如下:
| 节点 | 表示什么 | 需要追问的问题 |
|---|---|---|
Seq Scan |
顺序读取整张表 | 过滤条件是否足够少、统计信息是否准确? |
Index Scan |
经索引定位后回表取数据 | 索引列顺序是否匹配过滤和排序? |
Bitmap Heap Scan |
汇总多个索引命中后批量回表 | 命中行是否太多,导致回表成本上升? |
Nested Loop |
外层每一行驱动一次内层查找 | 外层实际行数会不会被低估? |
Hash Join |
建哈希表后匹配两侧数据 | 内存是否够用,是否出现临时磁盘读写? |
cost 是优化器内部的相对成本,不是毫秒;真正要对照的是预估 rows 与 actual rows。若预估 10 行而实际返回 10 万行,后续的连接、排序或循环都会沿着错误的规模决策。此时盲目创建索引通常治标不治本,先检查统计信息、数据分布与条件写法。
顺着执行树找到代价来源

执行计划是一棵树:叶子节点读取表或索引,父节点完成过滤、连接、聚合与排序。actual time 的结束时间、rows 和 loops 要一起看;一个看似只花 2ms 的节点,如果循环 1,000 次,累计成本就不小。
下面这种查询常被误判为“索引失效”:
1 | SELECT * |
对列做函数或类型转换,可能让普通的 created_at 索引无法直接限定范围。若业务语义是某一天,应改成可搜索的半开区间:
1 | SELECT * |
同类陷阱还有前置通配符的 LIKE '%error'、隐式类型转换,以及把多个互斥条件塞进复杂表达式。先将条件改写为能缩小原始列范围的形式,再重新执行同一条 EXPLAIN ANALYZE,才能把效果归因到改动本身。
按访问路径设计复合索引

索引应服务于稳定的访问路径,而不是为每一列各建一个。对于“租户和状态等值过滤,再按时间倒序取前 50 条”的查询,通常可以从等值过滤列开始,再放范围或排序列:
1 | CREATE INDEX CONCURRENTLY idx_orders_tenant_status_created_at |
这并非放之四海皆准的列顺序。选择性很低的 status 单独放在索引前部常常帮助有限;而当查询只按 tenant_id 和时间过滤时,上面的索引仍可能可用。是否需要覆盖列、部分索引或表达式索引,取决于实际查询、写入频率与存储成本。每增加一个索引,都要承担写入维护、真空清理和迁移时间,不能把它当作免费开关。
建索引后不要只确认节点名称从 Seq Scan 变成 Index Scan。在相同参数和相近数据量下复测总耗时、共享缓冲区命中与返回行数;如果原查询本来就要读取大部分表,顺序扫描反而可能是正确选择。
一套可复用的诊断闭环
把慢查询排查变成固定流程,能避免“生产环境看到一个计划就立刻改索引”的冲动:
| 步骤 | 操作 | 产出 |
|---|---|---|
| 固定样本 | 记录参数、返回行数和数据量 | 可复现的 SQL |
| 采集计划 | 在安全环境运行 EXPLAIN ANALYZE |
执行树与实测指标 |
| 找偏差 | 对比预估行数和实际行数 | 错误假设或瓶颈节点 |
| 小步改动 | 改写条件、更新统计或增加索引 | 单一可解释的变更 |
| 回归验证 | 用同一输入复测,并观察写入代价 | 前后对比记录 |
最后,把代表性计划、查询参数范围和索引意图写进变更说明。执行计划会随着数据规模与分布变化,今天合理的索引未来也可能变得多余。持续保留“为什么这样设计”的证据,才能让 SQL 优化从一次救火,变成团队可复用的诊断能力。