
上周一个朋友找我调一条 PostgreSQL 慢查询语气里满是困惑orders 表加了个索引结果原来 2 秒的查询变成了 4 秒夜间批处理的写入被拖慢了一大截。这不是我第一次听到这种抱怨。在社区里加了索引反而变慢几乎是每个 PostgreSQL 使用者迟早都要撞上的墙根因不是索引没用而是我们对索引的真实代价、以及优化器做选择时的底层逻辑理解有偏差。这篇指南用真实案例拆解这个现象索引在什么情况下反而拖慢查询、如何用 EXPLAIN 定位问题、以及从统计信息、代价参数到索引维护的完整排查与调优流程。无论你是正在做数据库性能调优的后端开发还是负责生产库的 DBA这篇避坑指南都值得照着走一遍。1. 先看一个让人抓狂的真实案例加索引为什么反而更慢1.1 现场还原一次索引操作后的性能跳水那是一张 5000 万行的订单表结构大致是字段类型说明order_iduuid主键user_idbigint用户IDstatusvarchar(16)PAID / SHIPPED / CANCELED 等created_attimestamptz下单时间total_amountnumeric金额线上有一个高频查询SELECT order_id, total_amount, created_at FROM orders WHERE status PAID AND created_at 2024-06-01 ORDER BY created_at DESC LIMIT 100;业务方看到 WHERE 里出现了 status顺手建了索引CREATE INDEX idx_orders_status ON orders(status);索引建完第二天监控报警就来了这条查询从平均 2.1 秒涨到了 3.8 秒夜间批量任务的窗口也被拉长了。用 EXPLAIN 看PG 确实走了新索引但走的是 Bitmap Heap Scan Bitmap Index Scan而不是大家期待的 Index Scan。问题出在哪关键在于 status PAID 这个条件太宽了。全表 5000 万行里符合这个条件的行占 22%——也就是大约 1100 万行。索引只负责给你一堆行位置的指针真正取数据还得回表1100 万次随机读放到任何磁盘上都是灾难。1.2 索引等于加速的直觉为什么只对了一半索引的本质是一份目录它帮你快速定位记录在堆里的物理位置也就是 TID。如果目标数据只占全表的 0.01%走索引的收益无可替代但当你要取回四分之一甚至一半的行时情况就反过来了——索引扫描要把 1100 万个 TID 逐个转成对堆页的随机访问机械盘上磁盘寻道的时间远高于顺序读换成 SSD 也扛不住大量随机 I/O。打个比方查词典里的生僻词先翻目录非常香但如果你想摘抄整一章的内容直接从头翻书反而更快。索引加速的是定位少量目标而不是搬运大量数据。1.3 索引的隐藏账单每个索引都在持续收钱慢查询只是表象。索引的真正成本每天都在发生而且不止查询这一笔写放大每插入一行相关索引的 B-tree 都要同步新增一条记录UPDATE 一旦动了索引列等价于一次删除和一次插入DELETE 也要在索引里留下死亡标记。单表建 3 个索引写入开销就是表写入 3 份索引维护。缓存挤占索引页同样占用 shared_buffers 和操作系统缓存一组大索引会把原本缓存住的热数据页挤出去间接推高全库的物理 I/O。维护开销autovacuum 需要清理索引里的死元组引用索引越大每一次清理扫描消耗的资源越多。所以加索引不是零成本提提速它更像一笔长期分期付款的贷款——每笔写入都在还款只有查询真正用到它时这笔投资才划算。2. 索引变慢的本质优化器被三样东西带偏了2.1 代价模型优化器心中的那张地图PostgreSQL 规划器不关心你加了什么索引本身它只估算走每条路要花多少钱然后选最便宜的一条。核心比较集中在顺序扫描与索引随机访问之间seq_page_cost顺序读一个页的成本默认 1.0。random_page_cost随机读一个页的成本默认 4.0。默认值是按机械硬盘时代设计的。如果数据落在 SSD 或 NVMe 上random_page_cost 4 会把索引扫描的代价高估三到四倍于是优化器宁可顺序扫全表也不碰你精心设计的索引。另一个关键参数是 effective_cache_size——它表示操作系统能用来做文件缓存的内存页数量一般建议设为物理内存的 50% 到 75%。设置太小规划器会低估缓存命中的可能性同样把计划推向全表扫描。2.2 统计信息失真拿着过期地图赶路再好的代价模型地基也是 pg_class.reltuples 和 pg_statistic 里的统计信息。autovacuum 的 analyze 触发阈值由 autovacuum_analyze_threshold 和 autovacuum_analyze_scale_factor 共同控制默认是 50 行加表行数的 10%。这里就埋着一个坑一张 5000 万行的表需要累计约 500 万行的变更才会触发自动 analyze。如果业务是白天高频更新、某个状态值的行数占比在几个小时内从 5% 冲到 40%而统计信息还停留在昨天优化器就会按 5% 的旧选择率去估算索引收益然后信心十足地选出一个灾难级执行计划。2.3 选择性陷阱有时候统计信息没问题但列本身就不适合建索引还有一类情况统计信息新鲜得很但列的分布决定了它天生不适合建普通 B-tree。status、state、gender 这类低基数列就是典型案例。选择性 去重后的行数 / 总行数。选择性越低单条索引记录能过滤掉的行越少。对低选择性列建普通 B-tree 索引几乎所有查询都会命中上百万行索引扫描的随机读取彻底击穿顺序读的优势。这类场景正确的做法往往是建部分索引WHERE status IN (...)或者干脆不建索引让顺序扫描接管把宝贵的内存留给真正高热的数据页。3. 用 EXPLAIN 解剖慢查询三种执行计划的真实面目3.1 先分清 EXPLAIN 和 EXPLAIN ANALYZE一个是预算一个是审计EXPLAIN 只展示规划器的成本预算EXPLAIN (ANALYZE, BUFFERS) 才是真正的审计。审计里最关键的是 BUFFERS 部分shared hit 代表缓存命中shared read 代表真实磁盘读取。遇到慢查询建议按下面的方式执行EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT order_id, total_amount, created_at FROM orders WHERE status PAID AND created_at 2024-06-01 ORDER BY created_at DESC LIMIT 100;TIMING OFF 可以避免 EXPLAIN 自身因计时产生额外开销。生产库不方便直接压测时用 auto_explain 扩展把超过阈值的语句连同执行计划一起打进日志具体配置我放在第 6 节详细讲。3.2 三种执行计划的对比速查计划读取方式典型场景关键隐患Seq Scan顺序读全表小表、低选择率、无合适索引大表全扫时 CPU 与 I/O 都拉满Index Scan顺序读索引再逐行随机回表高选择性等值或范围返回行数很少返回行数一旦变多随机 I/O 暴涨Bitmap Heap Scan先读索引生成位图再按堆页物理顺序回表中低选择性返回行数可能占全表 10% 到 40%位图合并和 recheck 会放大开销有人以为 Bitmap Heap Scan 一定比 Index Scan 差其实它是 PG 在返回行数不少但索引又有点用时的折衷方案——先把 TID 按物理顺序排好再回表理论上优于指针乱跳。但选择率实在太高时它照样打不过全表顺序扫描。3.3 实例读一份走了索引反而慢的执行计划回到开头那个案例执行计划大概长这样Bitmap Heap Scan on orders (cost450123.82..912345.67 rows11223344 width24) Recheck Cond: (status PAID::text) - Bitmap Index Scan on idx_orders_status (cost0.00..448331.63 rows11223344 width0)第一眼就要警惕 rows11223344——规划器估算要返回 1100 万行。看到这个量级你就该意识到回表访问的开销已经接近全表随机读recheck Cond 还意味着每行都要做一次条件复核。读到这一行基本可以判断要么统计信息过期要么这个索引压根不该建。接下来就能用第 4 节的调优流程去验证和修正。4. 调优实操把跑偏的计划拽回正轨4.1 第一步先强制刷新统计信息遇到任何疑似索引失效的场景第一件事永远是 ANALYZE命令简单到让人容易忽略ANALYZE orders;动手之前先查一下统计信息新鲜度SELECT relname, reltuples, last_analyze, last_autoanalyze FROM pg_stat_user_tables WHERE relname orders;如果 last_analyze 距离现在已经好几个业务周期且数据分布变化剧烈刷新之后执行计划很可能立刻改观。对于分布极端倾斜的列比如某个状态值占比异常可以把统计目标调大ALTER TABLE orders ALTER COLUMN status SET STATISTICS 2000; ANALYZE orders;default_statistics_target 默认只有 100对 5000 万行的表100 个采样值很难还原真实分布把单列的采样数提到 2000 后直方图和 MCV 列表会准确得多。4.2 第二步修正磁盘代价参数统计信息新鲜但计划还是偏向顺序扫描问题多半出在代价参数。数据落在 SSD/NVMe 上的生产库建议把 random_page_cost 从 4.0 降到 1.1 到 1.5 之间机械盘或共享存储千万别乱降不然会反噬成狂走索引。例如 64GB 内存、数据在 NVMe 上的配置ALTER SYSTEM SET random_page_cost 1.1; ALTER SYSTEM SET effective_cache_size 48GB; SELECT pg_reload_conf();改完立即对目标查询重新 EXPLAIN。注意这两个参数是全局生效的改完要盯一下其他查询是否有回归别为了救一个索引把全库计划带偏。4.3 第三步处理索引膨胀——数据写得多索引也会变慢很多人容易忽略一个事实索引也会虚胖。UPDATE 和 DELETE 造成的死元组会在索引里留下指向已死亡行的引用autovacuum 虽然会清理但高并发写入场景常常跟不上节奏索引页里堆满死指针扫描一遍相当于翻了几倍的废页。检查索引健康状况SELECT * FROM pgstatindex(idx_orders_status);重点关注 dead_tuple_count 和 free_space。膨胀率超过 20% 到 30%就该考虑重建索引了REINDEX INDEX idx_orders_status;REINDEX 会锁写生产环境优先用 pg_repack 或 pg_squeeze 在线重建或者安排在业务低峰期执行。我自己遇到过一张 2 亿行的日志表索引膨胀到 130%REINDEX 后同一个查询从 9 秒掉到 3 秒这个案例足够说明问题有多典型。4.4 第四步找出从未被用过的索引并删掉最后一个持续收益最大的清理动作找出吃闲饭的索引。SELECT schemaname, relname, indexrelname, idx_scan, idx_tup_read FROM pg_stat_user_indexes WHERE idx_scan 0 ORDER BY pg_relation_size(indexrelname) DESC;持续观察一周后idx_scan 0 说明规划器从来没选过它它只在这段时间里持续贡献写放大和维护成本。删除前确认它不在外键约束、唯一约束或某些查询的隐性依赖里然后执行 DROP INDEX。删除后写入吞吐通常肉眼可见地回升这也是索引少即是多最直观的注解。5. 索引设计阶段就要避开的四个坑5.1 别让 B-tree 扛下一切按数据特征选择索引类型遇到 5 亿行、按时间追加写入的表B-tree 不只是慢还非常肥。BRIN 是这类场景的好选择它按物理页块记录 min/max体积能缩小 90% 以上配合天然按时间顺序写入的数据分布区间查询效果极好CREATE INDEX idx_orders_created_brin ON orders USING brin(created_at);有 JSONB 字段且经常按内部键过滤用 GIN全文检索用 GIN 的 tsvector 索引。索引类型不匹配是很多人在为何索引没生效这个问题里挣扎的第一道坎。5.2 复合索引的列顺序等值放前面范围放后面复合索引的列顺序不是拍脑袋定的两条实用准则等值条件的列放前面范围条件的列放后面。例如查询最常写 status ? AND created_at ?那么建 (status, created_at) 能高效定位status 等值先缩小到一个桶再在桶内用 created_at 的 B-tree 范围跳跃。如果建成了 (created_at, status)B-tree 前导列是范围条件后面 status 的过滤大多只能靠 Filter 逐行复核。另外记住复合索引缺少左前导列的查询基本用不上索引这就是 B-tree 索引的左匹配原则PostgreSQL 和 MySQL 都适用。5.3 表达式索引你以为命中了其实没有最常见的翻车现场是查询写 WHERE lower(email) xxx表上却只建了 email 普通索引。优化器不会自动把 lower(email) 反推到 email 列上必须显式建表达式索引CREATE INDEX idx_orders_email_lower ON orders (lower(email));同理JSONB 的 ? 包含操作需要 GIN时间字段套了 date() 转换后也需要配套表达式索引。判别方法就一条EXPLAIN 之后看 Index Cond 和 Filter 里写的到底是不是你查询里那个表达式。5.4 冗余索引正在慢性拖慢写入的隐形杀手冗余索引很常见已有 (status, created_at)又单独建了 (status)前者在 B-tree 语义里天然可以顶替后者后者就是纯开销同一列上普通索引和唯一索引同时存在或者为了满足不同查询建了三四个互相重叠的索引。日常巡检可以用 pg_stat_user_indexes 配合 pg_index 对比前缀重叠关系确认冗余后删掉。每次写入都在为冗余付费这个道理在 PostgreSQL 和 MySQL 上完全一致区别只是 PG 的清理工具和统计视图更齐全罢了。6. 我把索引后变慢当作生产事故来走的固定流程6.1 五步定位法生产环境不能随手跑 EXPLAIN ANALYZE我的固定流程是这样的先开 auto_explain把慢查询连同执行计划收集出来LOAD auto_explain; SET auto_explain.log_min_duration 500ms; SET auto_explain.log_analyze on; SET auto_explain.log_buffers on;等一个业务高峰收集半小时日志再分析。核对统计信息新鲜度查 pg_stat_all_tables 的 last_analyze / last_autoanalyze落后一个业务周期就手动 ANALYZE。核对实际选择率跑 SELECT count(*) 对照执行计划里的估算行数偏差超过一倍优先处理统计信息或采样数。核对存储类型与代价参数SSD 上用默认 random_page_cost4 的库先改参数再测。核对膨胀与冗余索引pgstatindex 加 pg_stat_user_indexes 一查就清楚该 REINDEX 就起流程。按这个顺序走我在实践中总结出的经验是八成以上的索引后变慢在第二步和第四步就水落石出了。6.2 别忘了索引以外的嫌疑并发、锁与小表还有几个容易误判的场景排查时别把所有锅都甩给索引本身小表数据量只有几千行顺序扫描要读的页屈指可数建索引只是多一层回表优化器不用它是正常表现。并发高峰时建索引可能触发长时间锁等待或者索引刚建完的瞬间大量会话同时尝试使用它系统负载先飙一波。LIMIT 类查询如果 ORDER BY created_at LIMIT 100而 created_at 没有索引PG 选择 Seq Scan 加排序反而可能更快加索引后的收益取决于目标行在整张表里的分布。遇到这些情况先查锁等待时长和系统负载曲线再决定要不要动索引。我个人踩过最狠的一次坑是因为太相信索引连续加了三个应该有用的索引结果批处理吞吐掉了 40%。从那以后我给自己立了条规矩每次建索引之前先记录不带索引的慢查询基线建完索引之后把 EXPLAIN 前后的结果对比贴在 code review 里。索引不是越多越快而是越准越快。如果你现在正被加了索引反而变慢困扰不要急着删索引按统计信息、代价参数、索引膨胀和索引设计四个角度逐一排查解决方案往往就藏在离问题很近的位置。