返回

MySQL 5.7 优化器为何不用 Index Merge?深度解析

mysql

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),或者甚至不使用你强制指定的索引,得看看几个关键点。

  1. OR 条件的复杂性: 连接条件中使用了 OR,这通常会让优化器犯难。对于包含 OR 的查询,MySQL 通常难以高效使用索引,除非 OR 的每个部分都可以单独使用索引,且结果集的合并代价较低。

  2. FORCE INDEX 的局限性: 即使使用 FORCE INDEX,MySQL 也并非 一定 会使用指定的索引。 FORCE INDEX 只是强烈建议,最终决定权还是在优化器手里。 如果优化器认为全表扫描更快,它还是会选择全表扫描。

  3. Index Merge 的适用条件: MySQL 的 Index Merge 优化策略,是把多个索引扫描的结果合并。但这个策略有适用条件:一般是单表上多个 AND 条件,分别能用不同索引,然后把结果求交集;或者多个 OR 条件,分别能用不同索引,然后把结果求并集。 而现在这个查询,是连接查询, 还包含复杂的OR条件,不符合 Index Merge 的使用场景。

  4. 数据分布和统计信息: MySQL 优化器会根据表的统计信息(如行数、数据分布等)来决定最佳执行计划。如果统计信息过时或不准确,可能导致优化器做出错误的选择。

  5. 连接类型和条件: 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_iduser_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_mobileaccount的连接条件,复制一份到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_mobileaccount 了。
  • 创建一个包含 user_idtype 的联合索引: 考虑经常要根据user_id, 或者同时根据user_idtype来查询, 还可以创建一个 (user_id, type) 的联合索引.

操作步骤 (以添加 account 字段为例):

  1. donate_info 表添加 account 字段:

    ALTER TABLE donate_info ADD COLUMN account VARCHAR(255); -- 长度根据实际情况调整
    
  2. 更新 donate_info 表,填充 account 字段:

    UPDATE donate_info di
    JOIN home_user hu ON di.user_id = hu.id
    SET di.account = hu.account;
    
  3. donate_info 表的 account 字段上创建索引:

    CREATE INDEX account_idx ON donate_info (account);
    
  4. 修改原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 查询优化是个复杂的问题,需要综合考虑多种因素,很多时候并没有一招鲜的解决办法, 要具体情况具体分析。 上面提供的几种思路, 可以根据你的实际情况, 尝试组合使用,重点是根据执行计划逐步的进行优化。