返回

MySQL为何选基数1索引? 性能变慢原因与解决

mysql

MySQL 为啥会选基数=1的索引?坑点和原理分析

哥们儿,你有没有遇到过这种情况:给 MySQL 表的一个字段建了索引,结果发现这个字段所有行的值都一模一样,SHOW INDEXES 显示这索引的 Cardinality(基数)是 1。按理说,这种完全没区分度的索引,优化器应该躲着走才对。可怪就怪在,有时候 MySQL 偏偏就选了这个烂索引,而且一用查询速度还变慢了!

就像下面这个场景:

有个 categories 表:

CREATE TABLE `categories` (
  `id` int NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL,
  `type` int unsigned NOT NULL,
  PRIMARY KEY (`id`),
  KEY `type` (`type`)  -- 在 type 字段上建了个普通索引
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

然后往里塞了 100 万条数据,其中 type 字段的值 全是 1:

<?php
// 假设已配置好 $pdo 连接
$pdo->beginTransaction();
for ($i = 0; $i < 1000000; $i++) {
    // name 用 uniqid() 保证不同,type 全是 1
    $pdo->exec("INSERT INTO categories (name, type) VALUES ('" . uniqid() . "', 1)");
}
$pdo->commit();
?>

看看索引统计信息:

mysql> SHOW INDEXES FROM categories;
+------------+------------+----------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| Table      | Non_unique | Key_name | Seq_in_index | Column_name | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment | Visible | Expression |
+------------+------------+----------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| categories |          0 | PRIMARY  |            1 | id          | A         |     1000000 |     NULL |   NULL |      | BTREE      |         |               | YES     | NULL       |
| categories |          1 | type     |            1 | type        | A         |           1 |     NULL |   NULL |      | BTREE      |         |               | YES     | NULL       |
+------------+------------+----------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+

没毛病,type 索引的 Cardinality 确实是 1。

接着跑个查询,找 type=1 的记录里 name 最大的那个:

强制忽略 type 索引,走全表扫描:

mysql> SELECT MAX(name) FROM categories IGNORE INDEX(type) WHERE type=1;
+---------------+
| MAX(name)     |
+---------------+
| 6769b0dfec5e7 | -- 假设这是最大值
+---------------+
1 row in set (0.23 sec) -- 注意这个时间

mysql> EXPLAIN SELECT MAX(name) FROM categories IGNORE INDEX(type) WHERE type=1;
+----+-------------+------------+------+---------------+------+---------+------+---------+-------------+
| id | select_type | table      | type | possible_keys | key  | rows    | Extra       |
+----+-------------+------------+------+---------------+------+---------+-------------+
|  1 | SIMPLE      | categories | ALL  | NULL          | NULL | 1000000 | Using where | -- type=ALL, 全表扫描
+----+-------------+------------+------+---------------+------+---------+-------------+

让 MySQL 自己决定用不用索引:

mysql> SELECT MAX(name) FROM categories WHERE type=1;
+---------------+
| MAX(name)     |
+---------------+
| 6769b0dfec5e7 | -- 结果一致
+---------------+
1 row in set (0.80 sec) -- 时间明显变长了!慢了三倍多!

mysql> EXPLAIN SELECT MAX(name) FROM categories WHERE type=1;
+----+-------------+------------+------+---------------+------+---------+-------+--------+-------+
| id | select_type | table      | type | possible_keys | key  | key_len | ref   | rows   | Extra |
+----+-------------+------------+------+---------------+------+---------+-------+--------+-------+
|  1 | SIMPLE      | categories | ref  | type          | type | 4       | const | 500000 | NULL  | -- type=ref, 用了 type 索引
+----+-------------+------------+------+---------------+------+---------+-------+--------+-------+

这就奇怪了,明明统计信息显示 type 索引基数是 1,区分度极低,按理说优化器应该知道用它没好处。结果它还真就选了 type 索引,反而导致查询变得更慢。这是咋回事呢?

问题分析:为啥 MySQL 会犯傻?

MySQL 的查询优化器是基于成本(Cost-Based Optimization, CBO)来决定执行计划的。它会估算不同执行路径(比如用哪个索引、是否全表扫描)的成本,然后选个看起来成本最低的方案。

问题就出在这个“看起来”上。

  1. 成本估算模型不完美: 优化器估算成本依赖很多因素,包括:

    • 需要扫描的行数估算。
    • 是否需要回表(访问主键索引或聚簇索引获取其他列数据)。
    • 是否需要文件排序。
    • I/O 操作和 CPU 操作的成本因子。

    在咱们这个例子里,EXPLAIN 显示,当使用 type 索引时,type 列被识别为 ref 访问类型,并且 refconst(因为 WHERE type=1)。这通常被认为是一种高效的访问方式。优化器看到 WHERE type=1,又正好有个 type 索引,它会觉得:“嗯,用这个索引直接定位 type=1 的行,应该比大海捞针(全表扫描)快吧?”

    同时,它还估算需要扫描的行数 rows 大概是 50 万(可能是总行数的一半左右,这是基于一般性假设的粗略估计,虽然我们知道实际上是 100 万行)。即便基数是 1,优化器可能仍然认为通过索引 ref 访问 50 万行的“预估”成本,低于全表扫描 100 万行的成本。

  2. 回表成本被低估或计算方式不同: 这是关键点!

    • 咱们的查询是 SELECT MAX(name) ... WHERE type=1
    • type 索引只包含 type 列和主键 id(对于 InnoDB)。它不包含 name 列。
    • 当 MySQL 使用 type 索引时,它首先通过 type 索引找到所有 type=1 的记录的索引条目(虽然在这里实际上是所有条目)。
    • 然后,对于每一个找到的索引条目,它需要拿着主键 id 再去访问主表(聚簇索引)以获取 name 列的值,这个过程叫回表
    • 最后,收集所有回表得到的 name 值,再找出最大值。

    在这个场景下,由于 type=1 包含了表里 所有 的行,使用 type 索引实际上意味着对表里的 每一行 都要进行一次 “索引定位 -> 回表查 name” 的操作。这产生了大量的随机 I/O(假设数据不在内存中),成本非常高。

    相比之下,IGNORE INDEX(type) 强制全表扫描:

    • MySQL 直接顺序读取整个表的数据页。
    • 在扫描过程中,对每一行检查 type 是否等于 1(当然这里所有行都满足)。
    • 同时,在扫描过程中,可以直接比较当前行的 name 值,并维护一个当前最大值。
    • 这种方式主要是顺序 I/O,并且避免了大量的回表操作,虽然扫描的总数据量可能更大,但在这种特定查询下,整体 I/O 成本和 CPU 计算反而可能更低。
  3. MAX() 函数的特殊性: 如果查询只是 SELECT COUNT(*) FROM categories WHERE type=1,并且 type 是非空列,那么使用 type 索引(即使基数是1)可能会很快,因为 MySQL 可能可以直接利用索引信息估算行数(虽然在这个场景下,索引覆盖不了 count,依然需要回表,但只是计数不取值)。但这里的 MAX(name) 要求必须读取 name 列,放大了回表的成本。

  4. 统计信息只是参考: Cardinality 只是优化器决策的众多输入之一。优化器会结合查询结构、表的其他统计信息、以及内部成本模型来做决策。有时这个模型对于特定边界情况(比如基数极低但查询又需要访问索引外的列)的预测就不那么准了。

总结一下,MySQL 选了基数=1的 type 索引,主要是因为优化器在成本估算时,可能:

  • 高估了全表扫描 WHERE 条件过滤的成本。
  • 低估了使用低基数索引进行大量回表操作获取 name 列来计算 MAX() 的实际成本。
  • ref 访问类型本身在模型中通常被赋予较低的成本权重。

咋解决这个问题?

知道了原因,解决起来就有方向了。主要思路是:要么“教”优化器做正确的选择,要么让索引变得更有用,要么干脆不用这个索引。

方案一:使用优化器提示(Optimizer Hints)

这是最直接的办法,告诉 MySQL 别用那个索引,或者强制用另一个。

  • 原理: 通过在 SQL 语句中添加特定的注释或子句,指导优化器选择或忽略特定的索引。
  • 操作:
    • IGNORE INDEX : 告诉优化器忽略一个或多个指定索引。这就是上面例子里用到的:
      SELECT MAX(name) FROM categories IGNORE INDEX(type) WHERE type=1;
      
    • USE INDEX : 建议优化器使用列表中的某个索引(但不强制)。
      -- 如果你想建议用主键索引(虽然这里不合适,仅作示例)
      SELECT MAX(name) FROM categories USE INDEX(PRIMARY) WHERE type=1;
      
    • FORCE INDEX : 强制优化器使用列表中的某个索引,除非绝对不可能(比如索引不存在或不适用于查询)。
      -- 强制用主键索引(同样,这里不合适,仅作示例)
      SELECT MAX(name) FROM categories FORCE INDEX(PRIMARY) WHERE type=1;
      
  • 优点:
    • 立竿见影,精确控制。
  • 缺点:
    • 代码耦合:把执行计划的决策硬编码到 SQL 里,如果表结构、数据分布或 MySQL 版本变化,这个提示可能就不再最优甚至变差。
    • 维护成本:需要开发者对具体查询和索引情况有深入了解,并持续关注性能。
  • 安全建议/进阶使用:
    • 谨慎使用!这通常是实在没办法或者对某个特定查询性能瓶颈了如指掌时的最后手段。
    • 优先考虑调整索引或查询本身。
    • 使用前务必用 EXPLAIN 确认效果,并做充分的性能测试。
    • 添加注释说明为啥要加这个 Hint,方便以后维护。

方案二:更新索引统计信息

有时候,统计信息可能不准或者过时了,导致优化器判断失误。虽然咱们这个例子里 SHOW INDEXES 显示基数是准的,但更新统计信息是排查索引问题的常规步骤。

  • 原理: ANALYZE TABLE 会重新采样数据,更新关于表和索引的统计信息(比如基数、数据分布等),供优化器使用。
  • 操作:
    ANALYZE TABLE categories;
    
  • 优点:
    • 操作简单,对应用透明。
    • 通常能解决因统计信息陈旧导致的优化器误判。
  • 缺点:
    • 对于咱们这个基数确实就是 1 的场景,ANALYZE TABLE 可能并不能改变优化器的选择,因为它得到的信息本质上没变(type 索引的区分度就是极低)。
    • 执行 ANALYZE TABLE 对大表可能有性能影响(可能会锁表或消耗 I/O),需要在低峰期进行。
  • 安全建议/进阶使用:
    • 在数据发生大量增删改后,或者发现查询计划异常时,可以尝试执行。
    • 对于 InnoDB,通常 MySQL 会自动更新统计信息,但有时手动触发更及时或更精确。可以查阅 innodb_stats_auto_recalc 等参数。

方案三:删除无用或低效的索引

如果一个索引基数极低,并且分析下来它对绝大多数(甚至所有)查询都没啥好处,甚至像现在这样帮倒忙,那就应该考虑删掉它。

  • 原理: 减少表的索引数量,可以降低插入、更新、删除操作的开销(因为不再需要维护这个索引),也能避免优化器选错索引的可能性。
  • 操作:
    ALTER TABLE categories DROP INDEX type;
    
  • 优点:
    • 一劳永逸地解决这个特定索引引发的问题。
    • 提升写操作性能,减少存储空间。
  • 缺点:
    • 风险高!必须非常确定这个索引对其他查询也没用。可能这个索引对另外某个不常用的报表查询是至关重要的。
  • 安全建议/进阶使用:
    • 务必!务必!务必! 在删除索引前,全面评估这个索引是否被其他查询有效使用。可以借助慢查询日志、performance_schema 或第三方监控工具来分析索引使用情况。
    • 在生产环境操作前,先在测试环境验证。
    • 考虑是否有其他查询依赖这个索引,即使它基数低。例如,即使 type 基数是 1,如果存在 SELECT id FROM categories WHERE type = 1 LIMIT 10 这样的查询,这个索引仍然可能比全表扫描更快地返回少量 id(虽然听起来不太可能这么用)。

方案四:创建覆盖索引 (Covering Index)

如果你的主要查询就是 SELECT MAX(name) FROM categories WHERE type=1,并且性能要求高,可以考虑创建一个覆盖索引。

  • 原理: 覆盖索引是指一个索引包含了查询所需的所有列(SELECT 列,WHERE 列,ORDER BY / GROUP BY 列)。这样,MySQL 只需读取索引,无需回表到主数据文件,大大提高效率。
  • 操作:
    创建一个包含 typename 的复合索引。注意列的顺序,把用于 WHERE 条件过滤的 type 放在前面。
    ALTER TABLE categories ADD INDEX idx_type_name (type, name);
    
  • 如何工作:
    • 对于 SELECT MAX(name) FROM categories WHERE type=1; 这个查询:
      • MySQL 可以使用 idx_type_name 索引。
      • 它首先通过索引快速定位到 type=1 的索引条目范围(虽然还是所有条目)。
      • 关键在于,这些索引条目里已经包含了 name 的值!
      • MySQL 可以直接在索引内部扫描 type=1 的部分,找到 name 的最大值,根本不用访问主表数据。EXPLAINExtra 列会显示 Using index
  • 优点:
    • 可能极大提升特定查询的性能,尤其是当需要访问的列都在索引中时。
  • 缺点:
    • 增加了索引维护的成本(写操作变慢,占用更多存储空间)。
    • 只对特定的查询有效,如果查询变化(比如要 SELECT MAX(id)),这个索引可能就不再是覆盖索引了。
  • 安全建议/进阶使用:
    • 仅在特定查询是性能瓶颈,且该查询非常重要和频繁时才考虑创建覆盖索引。
    • 复合索引的列顺序很重要。通常把等值查询条件的列放在前面,范围查询或排序的列放后面。对于 WHERE type=1 ... MAX(name)(type, name) 是合理的顺序。
    • 注意索引长度,namevarchar(255),这会让索引变得比较大。

方案五:调整或重构查询(可能不适用于本例)

有时候问题不在索引,而在于查询本身写得不够优化。

  • 原理: 换种写法可能让优化器更容易理解意图,或者能利用到不同的优化策略。
  • 操作: 这得看具体场景。对于咱们这个简单的 MAX() 查询,似乎没有太多重构空间。但对于更复杂的查询,比如多表 JOIN、子查询等,调整写法有时能产生奇效。
  • 优点/缺点/建议: 过于宽泛,不在此详述。但永远保持一个开放的心态:是不是查询可以写得更好?

小结

MySQL 优化器虽然挺智能,但也不是万能的。它基于成本估算做决策,而估算模型在面对像“基数=1的索引 + 需要回表查询其他列”这类组合拳时,就可能算出个看似最优实则很坑的执行计划。

遇到类似“明明用了索引反而更慢”的情况,别慌:

  1. EXPLAIN 看看优化器到底选了哪个索引,访问类型是啥(ref, range, ALL 等),预估扫描行数 rows 多大,Extra 里有啥信息(比如 Using index, Using filesort, Using temporary)。
  2. 检查 SHOW INDEXES 看看索引基数 Cardinality 和表总行数比一比,心里有个谱。
  3. 分析查询语句,看看是不是需要回表才能获取所有需要的列。
  4. 然后就可以尝试上面提到的几种解决方案了:用 Hint 硬控、更新统计信息、删掉废柴索引、建个覆盖索引,或者干脆重新琢磨琢磨查询语句。

处理这类问题,关键在于理解 MySQL 优化器的工作原理和局限性,然后对症下药。