现场还原
一个后台列表页突然变慢。表里 800 万行,查询很普通:
SQL
SELECT id, title, created_at
FROM articles
WHERE tenant_id = 42
AND status = 'published'
ORDER BY created_at DESC
LIMIT 20;EXPLAIN ANALYZE 之后看到的是 Seq Scan on articles (cost=... rows=8000000)——全表扫描,3.2 秒。
第一个索引:顺序很重要
复合索引的顺序应该遵循:等值条件在前,范围/排序在后。
SQL
CREATE INDEX CONCURRENTLY idx_articles_tenant_status_created
ON articles (tenant_id, status, created_at DESC);tenant_id、status是等值条件,放前面;created_at参与排序,放最后,并且声明DESC可以让ORDER BY ... DESC LIMIT 20直接走索引顺序,不需要额外排序。
耗时从 3200ms 降到 9ms。
什么时候索引用不上
| 写法 | 是否走索引 | 原因 |
|---|---|---|
WHERE tenant_id = 42 |
✅ | 最左前缀匹配 |
WHERE status = 'published' |
❌ | 跳过了最左列 |
WHERE tenant_id = 42 AND lower(title) = 'x' |
❌ | 列上套了函数 |
WHERE tenant_id = 42 AND created_at::date = '2026-06-01' |
❌ | 隐式类型转换 |
第 3、4 条的正解是表达式索引:
SQL
CREATE INDEX idx_articles_title_lower ON articles (lower(title));
CREATE INDEX idx_articles_created_date ON articles ((created_at::date));EXPLAIN 里该看什么
SQL
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;我的阅读顺序是:
actual time:真实耗时,倒数第二段是这一层的耗时;rows=估计 vs actual:两个数字差一个数量级,说明统计信息不准,先ANALYZE;Buffers: shared read:读了多少个 8KB 页,能反推数据是否命中缓存;- 出现
Sort或Hash Join且行数很大,通常还有优化空间。
索引不是越多越好
每多一个索引,写入就要多维护一棵 B-tree。我的经验值:
- 单表索引控制在 5 个以内;
- 上线前用
pg_stat_user_indexes检查idx_scan = 0的索引,直接删; - 建索引一定要
CONCURRENTLY,否则会锁表。
SQL
SELECT indexrelname, idx_scan, pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;结论
这次排查真正花时间的不是写索引,而是确认查询到底在做什么。先看执行计划,再动手——顺序反过来,就只是在赌运气。