引言

线上 PostgreSQL 的某个接口从 80ms 涨到了 2.3s,DBA 看一眼慢查询日志,丢下一句话:"加个索引就好了"。加完之后确实快了——但这种"加索引玄学"掩盖了一个问题:你不知道为什么快,也不知道下一次该加在哪里。

本文用一个贯穿始终的真实表结构,教你读懂执行计划,并建立一套索引决策的思考框架。读完你能回答这三个问题:什么时候该建索引、建什么类型的索引、建了之后怎么验证。


一、从一个慢查询开始

1.1 表结构

sql
CREATE TABLE orders (
  id           BIGSERIAL PRIMARY KEY,
  user_id      BIGINT NOT NULL,
  product_id   BIGINT NOT NULL,
  status       TEXT NOT NULL DEFAULT 'pending',  -- pending / paid / shipped / cancelled
  amount_cents BIGINT NOT NULL,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- 已有 500 万行数据
-- 业务需求:查询某个用户的已支付订单,按时间倒序

1.2 慢查询

sql
SELECT id, product_id, amount_cents
FROM orders
WHERE user_id = 42 AND status = 'paid'
ORDER BY created_at DESC
LIMIT 20;

这条查询在一段时间内执行了 250 万次,平均耗时 1.9s。数据量不大,查询也不复杂,为什么这么慢?我们看执行计划。


二、读懂 EXPLAIN

2.1 第一版执行计划

sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, product_id, amount_cents
FROM orders
WHERE user_id = 42 AND status = 'paid'
ORDER BY created_at DESC
LIMIT 20;

输出(节选):

code
Limit  (cost=0.56..116.44 rows=20 width=24)
  ->  Index Scan Backward using orders_pkey on orders
        (cost=0.56..5802.94 rows=1002 width=24)
        Filter: ((user_id = 42) AND (status = 'paid'::text))
        Rows Removed by Filter: 4998998

逐字段解读:

核心认知:慢查询的 90% 原因不是"没索引",而是"走了索引但过滤比例太低"。执行计划里的 Rows Removed 就是证据。

2.2 三个必须盯住的数字

code
cost=0.56..116.44   —— 估算成本,上下限
rows=1002           —— 估算返回行数(优化器的猜测)
Buffers: heap=...   —— 实际读了多少个 8KB 数据页

当 rows 估算和实际结果偏差超过 100 倍时,先怀疑统计信息过期(该跑 ANALYZE),再怀疑索引设计。优化器是根据估算行数决定用不用索引的,估算错得离谱,计划就一定烂。


三、索引选型:每个场景该建什么

3.1 场景一:等值条件 + 排序(复合索引)

上面的查询,正确解法是复合索引:

sql
CREATE INDEX idx_orders_user_status_created
  ON orders (user_id, status, created_at DESC);

重新看执行计划:

code
Limit  (cost=0.42..2.64 rows=20 width=24)
  ->  Index Scan using idx_orders_user_status_created on orders
        (cost=0.42..11.54 rows=1002 width=24)
        Index Cond: ((user_id = 42) AND (status = 'paid'::text))

1.9s → 0.6ms,提升约 3000 倍。

为什么有效:

  1. 等值条件走最左前缀:user_id、status 两个等值列排前面,索引能直接定位到对应叶子节点。
  2. created_at 排在最后且为 DESC:因为等值条件已经过滤完了,剩下的行在索引里天然按时间倒序排列,ORDER BY + LIMIT 不需要额外的 Sort 操作。

规则:复合索引的列顺序 = 先等值、后范围/排序。等值条件的列放前面,让索引最窄化(selectivity 高)。

3.2 场景二:前缀模糊搜索(GIN 索引)

需求变成:搜索产品名称。

sql
SELECT * FROM products WHERE name ILIKE '%折叠%';
-- 或数组字段
SELECT * FROM articles WHERE tags @> ARRAY['postgresql'];

普通 B-tree 索引对 %xxx% 毫无办法——它只能加速前缀匹配。正确选择是 GIN 索引:

sql
-- 对数组/全文搜索用 GIN
CREATE INDEX idx_articles_tags ON articles USING gin (tags);

-- 全文搜索
CREATE INDEX idx_articles_fts
  ON articles USING gin (to_tsvector('chinese', body));

选型对照表:

场景 索引类型 是否合适
等值 / 范围 / 排序 B-tree ✅ 首选
数组包含 / 全文 / JSONB GIN ✅
只求唯一 / 精确匹配 UNIQUE 约束 ✅ 顺带建
向量相似度 ivfflat / hnsw ✅(pgvector)
前缀 LIKE 'abc%' B-tree + text_pattern_ops ✅ 特殊操作符

3.3 场景三:覆盖索引(避免回表)

如果查询只用到少数几个列,可以让索引把这些列全部"盖住":

sql
CREATE INDEX idx_orders_user_covering
  ON orders (user_id, status, created_at DESC)
  INCLUDE (product_id, amount_cents);

此时上面那条查询的全部数据都在索引里,PostgreSQL 走 Index Only Scan,完全不用回表读堆。对高并发、重复执行的核心查询,这一步往往是量级级收益。


四、索引失效的五大陷阱

4.1 在索引列上做函数运算

sql
-- 坏:无法使用 idx_orders_user_created 上的 created_at
WHERE date(created_at) = '2026-08-01'

-- 好:改成范围条件
WHERE created_at >= '2026-08-01'
  AND created_at <  '2026-08-02'

索引存的是原始列值,date() 把值改写后,B-tree 的有序性就失效了。让列保持裸状态,把函数移到常量一侧。

4.2 隐式类型转换

sql
-- user_id 是 BIGINT,传字符串时隐式转文本类型比较
WHERE user_id = '42'

MySQL 里这个写法常见坑是索引失效;PostgreSQL 相对宽松,但参数类型不匹配一样会导致无法使用索引。接口层统一类型,SQL 里别依赖隐式转换。

4.3 违反最左前缀

sql
-- 有索引 (user_id, status, created_at)
-- 坏:跳过第一列直接查 status
WHERE status = 'paid' AND created_at > '2026-01-01'

B-tree 的最左前缀原则:查询条件必须从索引第一列开始连续匹配,跳过中间列,后面的列就全废了。

4.4 统计信息过期

sql
-- 大批量写入/删除后,优化器还在用旧估算
ANALYZE orders;
-- 或开启自动 analyze

4.5 选择性太低,优化器主动放弃索引

sql
-- status 只有 4 个取值,'pending' 占 90% 的行
WHERE status = 'pending'

如果索引要返回 90% 的行,顺序扫全表比随机读索引更快。优化器选全表扫描不是 bug,是正确决策。这时该做的是倾斜数据修复,而不是再加索引。


五、索引的代价:写入慢与磁盘膨胀

索引不是免费的午餐。

code
每加一个索引:
- INSERT/UPDATE/DELETE 都要同步维护索引 → 写入变慢
- 磁盘空间额外占用 → 5 百万行订单,每个索引约几十 MB
- VACUUM 和 autovacuum 负担加重 → 更新频繁的表更明显

决策原则:

  1. 先分析,后建索引——用执行计划和 pg_stat_statements 找出 Top N 慢查询。
  2. 索引数量控制在合理范围:写密集的表 < 5 个,读密集的表可以多。
  3. 用 pg_stat_user_indexes 找从来没用过的索引(idx_scan = 0),果断删除。
sql
-- 找出没被使用过的索引
SELECT schemaname, relname, indexrelname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY relname;

六、验证:建完索引怎么确认有效

sql
-- 1. 必须重新查看执行计划
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;

-- 2. 看三件事
--    a. 是否用上了新索引(Index Scan using xxx)
--    b. Buffers 读页数是否大幅下降
--    c. 实际耗时(ANALYZE 是真实执行)

-- 3. 跑同一查询两次,第二次看是否命中缓存
--    第一次:Buffers: shared read=...
--    第二次:Buffers: shared hit=...

不要只看耗时。同一条 SQL 在测试环境快、生产慢,通常就是测试数据量不足导致优化器选了全表扫描——这就是为什么验证必须看执行计划和行数估算,而不是盯着毫秒数。


七、决策清单

遇到慢查询时,按这个顺序走:

code
1. 定位慢 SQL(pg_stat_statements / 日志)
2. 跑 EXPLAIN (ANALYZE, BUFFERS)
3. 盯住 Rows Removed / rows 估算
4. 判断瓶颈:过滤太多?排序?还是回表?
5. 选型:复合索引 / GIN / 覆盖索引 / 部分索引
6. 建索引,重新 EXPLAIN,对比 Buffers 和耗时
7. 上线后观察 pg_stat_user_indexes 确认被使用

部分索引值得一提——它只给符合条件的那部分行建索引,适合低频场景:

sql
-- 只给 1% 的异常订单建索引,索引体积和写入开销都极小
CREATE INDEX idx_orders_abnormal ON orders (created_at)
  WHERE status IN ('pending', 'processing');

结语

索引不是玄学。它是一道选择题:等值条件决定列顺序,查询需求决定索引类型,写入频率决定索引数量。

下回再有人说"加个索引就好了",你可以回一句:"先跑个 EXPLAIN,看 Rows Removed。"——然后优雅地指出该建复合索引还是覆盖索引,以及为什么这次不该建索引。