MySQL 5.7 优化器为何不用 Index Merge?深度解析
2025-03-16 00:59:38
MySQL 5.7 优化器为何不使用 Index Merge?— 深度解析与解决方案
最近遇到一个棘手的 SQL 优化问题: 尽管对 donate_info
表加了索引, 也使用了 FORCE INDEX
提示, MySQL 5.7 仍然选择了全表扫描。 想搞清楚, 为啥 MySQL 不用我指定的索引。
表 home_user
大概有 50000 条记录,donate_info
表有 100000 条记录。 要计算每个 home_user
的总捐赠次数(donate_count
)。
执行计划如下:
explain
SELECT
COUNT(DISTINCT `hu`.`id`) AS aggregate,
COUNT(`di`.`id`) AS donate_count
FROM
`home_user` AS `hu`
LEFT JOIN
`donate_info` AS `di`
FORCE INDEX (user_id_idx, ut_idx)
ON (
`di`.`user_id` = `hu`.`id`
OR (
`di`.`type` = 'proxy'
and `di`.`user_mobile` = `hu`.`account`
)
)
\G;
donate_info
表有两个索引:
KEY `user_id_idx` (`user_id`)
KEY `ut_idx` (`user_mobile`,`type`)
一、 问题原因分析
要理解为什么 MySQL 不使用索引合并(Index Merge),或者甚至不使用你强制指定的索引,得看看几个关键点。
-
OR
条件的复杂性: 连接条件中使用了OR
,这通常会让优化器犯难。对于包含OR
的查询,MySQL 通常难以高效使用索引,除非OR
的每个部分都可以单独使用索引,且结果集的合并代价较低。 -
FORCE INDEX
的局限性: 即使使用FORCE INDEX
,MySQL 也并非 一定 会使用指定的索引。FORCE INDEX
只是强烈建议,最终决定权还是在优化器手里。 如果优化器认为全表扫描更快,它还是会选择全表扫描。 -
Index Merge 的适用条件: MySQL 的 Index Merge 优化策略,是把多个索引扫描的结果合并。但这个策略有适用条件:一般是单表上多个 AND 条件,分别能用不同索引,然后把结果求交集;或者多个 OR 条件,分别能用不同索引,然后把结果求并集。 而现在这个查询,是连接查询, 还包含复杂的OR条件,不符合 Index Merge 的使用场景。
-
数据分布和统计信息: MySQL 优化器会根据表的统计信息(如行数、数据分布等)来决定最佳执行计划。如果统计信息过时或不准确,可能导致优化器做出错误的选择。
-
连接类型和条件:
LEFT JOIN
的特性,会先遍历左表 (home_user
) 的所有行, 然后根据连接条件去右表 (donate_info
) 查找。 这种情况下, 如果右表的连接条件不能有效利用索引,就很容易导致全表扫描。
二、解决方案
针对这个问题,提供几个解决思路。
1. 拆分 OR 条件,使用 UNION ALL
把复杂的 OR
连接条件拆开,分别查询,然后用 UNION ALL
合并结果。
SELECT
COUNT(DISTINCT hu.id) AS aggregate,
SUM(donate_count) AS donate_count
FROM
home_user AS hu
LEFT JOIN
(
(SELECT di.user_id, COUNT(di.id) AS donate_count
FROM donate_info AS di
WHERE di.user_id IS NOT NULL
GROUP BY di.user_id)
UNION ALL
(SELECT di_alias.user_id, COUNT(di_alias.id) AS donate_count
FROM donate_info di_alias
INNER JOIN home_user hu_alias on di_alias.user_mobile = hu_alias.account
where di_alias.type = 'proxy' and di_alias.user_mobile is not null
GROUP BY di_alias.user_id)
)as di_counts ON hu.id = di_counts.user_id;
这样使用union all,会走user_id 和 ut_idx 这两个索引,但union all 会隐式distinct,会有性能消耗。
通过将原查询拆解,,使每个子查询都能高效利用索引。第一个子查询使用 user_id_idx
索引,第二个子查询使用 ut_idx
索引。 注意 UNION ALL
前后查询的字段要对应。
进阶技巧: 如果 user_id
和 user_mobile
存在大量 NULL 值, 可以在 WHERE 子句中添加 IS NOT NULL
条件, 进一步优化索引使用:
WHERE di.user_id IS NOT NULL
和
WHERE di.user_mobile IS NOT NULL AND di.type = 'proxy'
2. 添加冗余的连接条件(针对 type
= 'proxy' 的情况)
如果type
= 'proxy'的数据不多, 可以在连接条件里,把基于
user_mobile和
account的连接条件,复制一份到
user_id` 。
SELECT
COUNT(DISTINCT `hu`.`id`) AS aggregate,
COUNT(`di`.`id`) AS donate_count
FROM
`home_user` AS `hu`
LEFT JOIN
`donate_info` AS `di`
ON (
`di`.`user_id` = `hu`.`id`
OR (
`di`.`type` = 'proxy'
AND `di`.`user_mobile` = `hu`.`account`
AND EXISTS (SELECT 1 FROM home_user hu2 WHERE hu2.account = di.user_mobile AND hu2.id = di.user_id)
)
)
group by `hu`.`id`;
这里用了 EXISTS
子查询。 它的作用是,对于 type
='proxy' 的情况,额外检查一下,看能否通过 user_mobile
找到对应的 home_user
记录, 并且 user_id
也匹配。 这个额外的 AND
条件,可能会让优化器倾向于使用 user_id_idx
索引。
- 这种方式的前提是,
type = 'proxy'
的数据占比不能太高。否则,这个额外的条件,反而可能增加开销。*
进阶技巧: 在account建立唯一索引可以避免全表扫描
3. 优化数据模型(如果可行)
如果以上方法效果都不理想, 而且你有修改表结构的权限, 可以考虑从数据模型层面优化。
比如:
- 在
donate_info
表中添加account
字段: 直接把home_user
表的account
字段冗余到donate_info
表,这样连接时就不用跨表比较user_mobile
和account
了。 - 创建一个包含
user_id
和type
的联合索引: 考虑经常要根据user_id
, 或者同时根据user_id
和type
来查询, 还可以创建一个(user_id, type)
的联合索引.
操作步骤 (以添加 account
字段为例):
-
在
donate_info
表添加account
字段:ALTER TABLE donate_info ADD COLUMN account VARCHAR(255); -- 长度根据实际情况调整
-
更新
donate_info
表,填充account
字段:UPDATE donate_info di JOIN home_user hu ON di.user_id = hu.id SET di.account = hu.account;
-
在
donate_info
表的account
字段上创建索引:CREATE INDEX account_idx ON donate_info (account);
-
修改原sql,去掉基于user_mobile的连接条件
SELECT
COUNT(DISTINCT `hu`.`id`) AS aggregate,
COUNT(`di`.`id`) AS donate_count
FROM
`home_user` AS `hu`
LEFT JOIN
`donate_info` AS `di`
ON (
`di`.`user_id` = `hu`.`id`
OR (
`di`.`type` = 'proxy'
and `di`.`account` = `hu`.`account`
)
)
group by `hu`.`id`;
修改后的查询,直接在 `donate_info` 表内部比较 `account` 字段。
安全建议:
- 数据一致性: 如果采用冗余字段的方式,要注意保持数据一致性。 当
home_user
表的account
更新时,也要同步更新donate_info
表的account
。 可以考虑用触发器, 或者在应用代码里,保证更新的原子性。
4. 分析并更新统计信息
确保 MySQL 的表统计信息是最新的。
ANALYZE TABLE home_user, donate_info;
这会重新收集表的统计信息, 让优化器能更准确地估算行数、选择度等, 从而做出更好的执行计划决策。
5. 使用 Profile 查看详细执行过程(进阶)
可以使用 MySQL 的 profiling 功能来查看查询执行的各个阶段的耗时情况。
SET profiling = 1;
-- 执行你的查询
SELECT ...;
SHOW PROFILES;
SHOW PROFILE FOR QUERY [query_id]; -- query_id 从 SHOW PROFILES 的结果中获取
SET profiling = 0;
通过 profile 信息, 能更细致地看到, 时间到底花在哪儿了. 比如,是卡在 accessing data 阶段,还是 sending data 阶段? 这有助于更精确地定位问题。
三. 总结
MySQL 查询优化是个复杂的问题,需要综合考虑多种因素,很多时候并没有一招鲜的解决办法, 要具体情况具体分析。 上面提供的几种思路, 可以根据你的实际情况, 尝试组合使用,重点是根据执行计划逐步的进行优化。