优化器选错索引时:强制索引该不该加

上周帮一个朋友的站做体检,他说最近首页偶尔会卡两秒以上,但 CPU、内存、磁盘 IO 曲线全都平得像一张纸。我让他开日志看了一眼,问题很快就露出来了:慢查询日志里躺着一条 SELECT count(*) FROM posts WHERE status = 'publish',耗时 1.8 秒,而这条 SQL 明明有索引。

这就是「强制索引」这类话题之所以值得单独写一篇的原因。索引存在、EXPLAIN 也显示用上了索引,SQL 依然慢,这中间隔着的不是配置错误,而是优化器对这个索引的估算本身就错了。本文不讨论怎么加索引,只讨论一件更窄的事:当优化器判断失误时,我们怎样用 FORCE INDEX 把它拉回正确路径,以及用它的代价与边界在哪里。

一、先分清三个完全不同的概念

中文语境里「强制索引」经常被混着用,实际对应三件不同的事,处置方式完全不同:

第一,优化器选错索引。候选索引有好几个,优化器按成本模型选了一个,但选错了。这时 FORCE INDEX 是有效手段。典型场景是统计信息过期、数据分布倾斜、或者索引列上有函数运算导致基数估算离谱。

第二,索引根本没被用上。比如对索引列做了函数包裹(WHERE DATE(created_at) = '2026-10-01')、隐式类型转换(字符串列传了数字)、或者用了前置通配的 LIKE。这种情况加 FORCE INDEX 也没用,因为优化器不是「不想用」,而是「用不了」。硬加会直接报错或者退化成全表扫描加过滤。

第三,优化器把索引用成了不该用的形态。比如本该走覆盖索引扫描,结果退化成回表;本该走范围扫描,结果变成全索引扫描。这类问题看 EXPLAIN 的 type 和 Extra 字段比看 key 更有价值。

只有第一种才值得用 FORCE INDEX。判断方法很简单:EXPLAIN 看优化器选的 key,再用同一条件跑一次 EXPLAIN 对比强制后的 key 与实际耗时,如果强制之后的实际耗时明显更低,说明确实是优化器选错了。如果强制前后实际耗时差不多,那问题不在索引,去查回表次数、网络往返、或者应用层 N+1 查询。

二、一个可复现的实测场景

下面这个例子是我在自己一台测试机上搭出来的,数据是造的,但结构跟真实场景接近。表结构大致是这样:

CREATE TABLE `posts` (
  `id` int unsigned NOT NULL AUTO_INCREMENT,
  `category_id` int unsigned NOT NULL DEFAULT '0',
  `status` tinyint NOT NULL DEFAULT '0',
  `created_at` datetime NOT NULL,
  `title` varchar(200) NOT NULL,
  `views` int unsigned NOT NULL DEFAULT '0',
  PRIMARY KEY (`id`),
  KEY `idx_cat_status_time` (`category_id`,`status`,`created_at`),
  KEY `idx_status_time` (`status`,`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

表中 80 万行,其中 status=1(已发布)的占比 92%,category_id=7 这个分类下只有 3000 多行。业务查询是「某个分类下最近的已发布文章」:

SELECT id, title, created_at
FROM posts
WHERE category_id = 7 AND status = 1
ORDER BY created_at DESC
LIMIT 20;

按理说 idx_cat_status_time 是最优解,3000 行的子集里排序取前 20 几乎没有代价。但实测 EXPLAIN 出来的 key 是 idx_status_time,rows 估算 73 万,Extra 里带着 Using where; Backward index scan。实际执行时间 640 毫秒。强制之后:

SELECT id, title, created_at
FROM posts FORCE INDEX (`idx_cat_status_time`)
WHERE category_id = 7 AND status = 1
ORDER BY created_at DESC
LIMIT 20;

同样是这批数据,执行时间 1.9 毫秒。差了三百多倍,而 SQL 只多了一行提示。

三、优化器为什么会算错

要判断 FORCE INDEX 值不值得长期留着,得先知道它为什么错。常见的三个原因:

统计信息过期。InnoDB 的索引基数来自随机采样,不是精确统计。表在短期内发生过大批量写入、删除,information_schema.statistics 里的 Cardinality 会明显偏离真实值。8.0 之后可以用直方图补救倾斜列,但直方图本身不会自动更新,需要显式 ANALYZE TABLE ... UPDATE HISTOGRAM。

数据倾斜严重。上例中 status=1 占了 92%,如果索引的列序是 (status, created_at),优化器看到 status 的等值条件就可能认为「走这个索引已经足够」,但实际过滤后还剩 73 万行,随后的排序与 LIMIT 全变成大代价操作。这里的关键是优化器对「过滤后剩余行数」的估算,全表基数乘上等值条件的选择性,而这个乘法的两个因子都可能失真。

成本模型与真实硬件不匹配。默认的成本常数(比如随机 IO 与顺序 IO 的相对代价)是按机械盘时代的模型设的。在 NVMe 上,随机读已经没那么贵,可优化器仍然按老参数比较成本,于是偏向「避免随机读、走顺序扫描」的执行计划。

知道原因之后应当优先做两件事:一是对相关表跑一次 ANALYZE TABLE 并确认统计已刷新(注意,8.0 会自动跳过「变化不到 10%」的表,可能报 already up to date 却没真更新);二是检查是否有函数包裹、隐式转换这类让索引彻底失效的写法。这两步都做过之后仍然选错,FORCE INDEX 才是最后的手段。

四、FORCE INDEX 的正确写法与三个细节

MySQL 里一共有三种提示语法:USE INDEX(给优化器一个候选列表,它仍可以不选)、IGNORE INDEX(排除某些索引)、FORCE INDEX(相当于 USE INDEX 加「全表扫描代价设为无限大」,即强烈建议使用)。三种都支持 FOR INDEX (FOR JOIN / FOR ORDER BY / FOR GROUP BY) 这种作用域限定,8.0 之后也支持跟在新式 /*+ INDEX(t idx) */ 优化器提示里。

细节一:索引名必须写对,且要带反引号。索引名中有连字符或关键字时,不写反引号会直接语法错误;写错名字 MySQL 会报 Key 'xxx' doesn't exist in table,这个报错足够明确,但如果是通过 ORM 动态拼接的表名,很容易在切库之后才暴露。

细节二:FORCE INDEX 指定的索引必须真的能用于该查询。如果查询条件里根本没有那个索引的前缀列,优化器仍然会退化成全表扫描,而且不会报警。比如上面那条 SQL 如果去掉 category_id = 7,只留 status 条件,强制 idx_cat_status_time 就用不上,因为最左列缺失。所以强制之后必须重新跑 EXPLAIN 确认 key 真的变了、并且 type 不是 ALL。

细节三:FORCE INDEX 可以和覆盖索引配合,但要注意它不会帮你补列。如果查询需要回表,强制一个不含所需列的索引只会让回表次数变多。真正的优化是把查询的 SELECT 列控制在索引内,让它变成覆盖扫描。比如上例把 SELECT 改成 id, category_id, status, created_at,索引叶子节点上就有全部所需数据,配合 EXPLAIN 里的 Using index 才是完整的优化。

五、代价:为什么不能随手加

FORCE INDEX 最大的问题不是性能,而是它把一个会随数据自动调整的决策,冻结成了硬编码。数据量涨十倍、分布从倾斜变成均匀、或者业务查询条件改变,优化器本来能自己调整,现在被提示锁死了,结果可能从「偶发慢」变成「持续慢」,而且更难发现,因为 EXPLAIN 显示的是一条看起来很合理的执行计划。

我一般按三条原则决定要不要留:

门槛一:这个查询在慢查询里高频出现。偶发一次慢的 SQL,重写或者加缓存更划算,不值得为它背一个硬编码。至少要在 events_statements_summary_by_digest 或者 pt-query-digest 报表里占到前几名,才值得处理。

门槛二:已经排除了统计信息与写法问题。如果只是因为统计过期而选错,ANALYZE TABLE 或者调 innodb_stats_persistent_sample_pages 更干净。FORCE INDEX 应当被当成「统计信息没法解决」时的补丁。

门槛三:有回归验证的手段。加之前记录 EXPLAIN 的 key、rows、type,加之后记录实际执行时间与扫描行数,并且定期复查。因为一旦数据分布变化,这个提示就会从帮手变成包袱。

六、更根本的替代方案

如果发现自己在很多查询上都要加 FORCE INDEX,那说明表的索引设计本身有问题。这时候该做的是重构索引,而不是到处打补丁。几个常见的方向:

调整联合索引的列序。把等值条件列放在前面、范围或排序列放在后面,让一个索引能吃下更多查询形态。上例如果把 (category_id, status, created_at) 改成 (category_id, created_at, status),对「分类下最近文章」这类查询更友好,但要确认其他用到 status 等值的查询不会因此变慢。

用降序索引消除排序代价。MySQL 8.0 支持真正的降序索引,KEY idx (category_id, created_at DESC),对 ORDER BY created_at DESC 这类查询可以避免 backward index scan 带来的额外代价。这是很多人忽略的一点:8.0 之前 DESC 关键字是被忽略的,加了也没用。

把高频的 count 类查询单独处理。文章开头那个 1.8 秒的 count(*) 就是典型例子。这类查询统计的是全表某个状态的行数,无论走哪个索引都要扫大量行,正确做法是用维护表或者定时聚合,而不是在索引上找办法。InnoDB 的 count 本质上就是一次索引扫描,加提示不会变快。

七、从 README 到变更记录

最后说一个流程上的建议。FORCE INDEX 写在 SQL 里,它在代码历史和上线记录里是有痕迹的;但如果写在 ORM 的查询构造器里、或者散落在各个业务函数中,半年之后就没人说得清为什么要加。我习惯在强制索引的那一行代码上方留一句注释,写明三件事:当时是什么现象、强制之后实测多少、以及当时的数据量级。这样下次数据量翻十倍的时候,接手的人知道该复查什么。

更省事的做法是把这类提示集中在一个数据访问层的常量里,配置化而不是散落各处。这样调整的时候只改一处,而且能一眼看到「这个站一共用了多少个强制索引」,数量一多就该去做索引重构了。

总结起来:加索引是为了让优化器有得选,FORCE INDEX 是告诉它「这次你选错了」。前者是设计,后者是补救。设计做对了,补救就不必要;补救用多了,说明设计该重做了。

Last modification:October 26th, 2026 at 12:24 pm

Leave a Comment