大家好,我是小耶,写功课只是为了我踩过的坑,你们别再踩了!
刚做DBA那阵子,我对“加索引”这件事有一种朴素的信仰——查询慢?加索引。再慢?再加一个。
直到有一次,一条查询本来跑200ms,我信心满满地加了个索引,结果变成了2秒。
我当时的第一反应是:索引建错了?查了一下,索引建对了,字段类型也没问题。再跑EXPLAIN,优化器确实用了这个新索引。
用了索引,反而更慢了。
那次之后我才明白,索引不是“建了就行”。优化器对索引的选择,有一套自己的逻辑。这套逻辑在大多数时候是对的,但在特定场景下,它会做出看起来合理、实际很糟糕的决定。
先把这件事里绕不开的几个概念说清楚:
索引选择性——索引列中不同值的数量与总行数的比值。选择性越高(不同值越多),索引过滤效果越好。比如主键的选择性是1,status只有5个值,选择性是0.000005。
回表——通过二级索引找到主键值后,再回到聚簇索引读取完整行数据。每次回表是一次随机I/O。
Index Merge——优化器同时使用多个索引,把各自的结果集做交集(Intersection)或并集(Union),再合并输出。
一、优化器为什么会选错索引
优化器选择索引的依据是代价估算。它估算每个候选索引的I/O代价和CPU代价,选最低的那个。问题在于,这个估算有两个容易出偏差的地方。
偏差一:基数估算不准。
优化器用采样估算索引列的基数(不同值的数量)。如果数据分布倾斜,采样结果可能严重偏离实际。比如status列有5个值,但status='DONE'占了90%。优化器采样时如果恰好抽到几个PENDING的行,会以为status的选择性很高,选了这个索引。实际执行时,90%的行都要回表,回表次数远超预期。
偏差二:回表代价被低估。
优化器估算回表代价时,用的是固定的成本常数。但真实的回表代价取决于数据在磁盘上的分布——如果主键是随机的(UUID),回表就是随机I/O;如果主键是自增的,回表可能是顺序I/O。优化器不区分这两种情况。
我那次加索引反而变慢,就是踩了第二个偏差。新加的索引选择性看起来不错,但回表的主键是随机的,每次回表都是随机I/O,几十万次随机I/O的开销,远超全表扫描的顺序读。
二、Index Merge:看起来聪明,实际很贵
Index Merge是另一个容易踩坑的场景。
当WHERE条件涉及多个索引列时,优化器可能选择Index Merge——同时扫描两个索引,取交集或并集。听起来很合理,但实际上合并操作本身的开销可能超过全表扫描。
SELECT * FROM orders
WHERE user_id = 12345 AND status = 'PAID';
如果user_id和status各有一个单列索引,优化器可能同时扫描两个索引,取交集。但如果user_id=12345返回10万行,status='PAID'返回50万行,交集运算要处理这两个结果集——合并、去重、排序,开销可能比直接全表扫描还大。
更危险的是Union类型的Index Merge(OR条件触发)。两个索引的结果集取并集,去重和排序的代价更高。
Index Merge的代价藏在三个地方:多次索引扫描、结果集在内存中的合并运算、以及多次回表。优化器在估算时,往往低估了合并运算和多次回表的开销。
三、怎么确认优化器选错了
方法一:EXPLAIN ANALYZE对比估算与实际
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE user_id = 12345 AND status = 'PAID';
MySQL 8.0.18+的EXPLAIN ANALYZE会输出每个步骤的实际执行行数和时间。对比rows(估算)和actual rows(实际),如果差距很大(比如差10倍以上),说明基数估算有偏差。
方法二:看Extra列的Index Merge信号
EXPLAIN SELECT * FROM orders
WHERE user_id = 12345 OR status = 'PAID';
如果Extra列出现Using union(...)或Using intersect(...),说明触发了Index Merge。这时候要评估合并的代价是否真的比全表扫描低。
方法三:用FORCE INDEX验证
-- 强制走单个索引,对比响应时间
SELECT * FROM orders FORCE INDEX(idx_user_id)
WHERE user_id = 12345 AND status = 'PAID';
如果强制走单个索引比让优化器自由选择更快,说明优化器选错了。这是最直接的验证方法。
四、解决方案
方案一:更新统计信息。
ANALYZE TABLE orders;
80%的索引选错问题,更新统计信息就能解决。优化器拿到准确的基数估算后,会重新评估代价。
方案二:建更匹配查询模式的联合索引。
-- 替代两个单列索引 + Index Merge
CREATE INDEX idx_user_status ON orders(user_id, status);
联合索引一次扫描就能定位到目标行,不需要Index Merge的合并运算,也不需要多次回表。这是比Hint更根本的解法。
方案三:删掉低效索引。
如果某个索引建了之后,优化器频繁选错,而且实际执行效果很差,考虑删掉它。索引不是越多越好——每个索引都会影响优化器的选择空间。
方案四:用FORCE INDEX作为临时方案。
确认优化器选错后,用FORCE INDEX强制走正确的索引。但这是临时方案——数据分布变化后,Hint可能产生反效果。
五、小结
索引建了查询反而变慢,根因往往在优化器的选择:基数估算偏差导致它选了“看起来选择性高”但回表代价巨大的索引,或者触发了Index Merge,合并运算的开销超过全表扫描。排查的核心是用EXPLAIN ANALYZE对比估算与实际、看Extra列是否出现Index Merge、用FORCE INDEX验证。解决方案优先更新统计信息和建联合索引,FORCE INDEX只是临时手段。
那次加索引反而变慢之后,我改掉了“慢就加索引”的习惯。加索引之前先看执行计划,加完之后用EXPLAIN ANALYZE验证实际效果。索引是工具,不是信仰。
小耶在手,SQL 不愁
还有什么想了解的,欢迎留言!小耶一定知无不言言无不尽……我们下次见~