☰
MySQL索引优化实战:从原理到失效场景与设计
2026/10/6 13:17:42 网站建设 项目流程

索引这事儿,真不是建得越多越好。我见过太多开发同学一口气把 WHERE 条件里的字段全建上索引,结果查询没快多少,写入倒是慢得感人,磁盘也涨了一大截。也有同学遇到慢查询就甩锅给数据库,殊不知 EXPLAIN 一看,索引压根没走对。这篇文章不打算念手册,而是从"索引为什么能快""什么时候会失效""实际该怎么设计"这几个角度,把我这些年调优 MySQL 攒下来的经验一次性倒出来。

1. 索引的本质:为什么它能让查询快几个数量级

很多教材一上来就画 B+ 树,看得人头大。要理解索引,先抛开树结构,想一想 MySQL 最原始的活儿是什么:它把数据一行一行存放在磁盘上,你要找一条满足条件的记录,最笨的办法就是把整张表从头到尾扫一遍。比如一张 2000 万行的订单表,按user_id=10086去查,没索引就得读 2000 万行数据做比对,一次全表扫描的 I/O 能到几百毫秒甚至秒级。

索引的本质,是用空间换时间,搞出一份"精简的目录"。这份目录只存少量关键列(比如 user_id)和一个指向实际数据行的指针(在 InnoDB 里是主键值),按大小排好序。排序的意义在于:查找一条数据从"逐个比对"变成"折半查找",复杂度从 O(n) 直接干到 O(log n)。2000 万行数据,log₂(2000万) 大约是 24 次比较,配合 B+ 树的磁盘读取特性,查询时间往往能从秒级降到毫秒级。

那为什么 B+ 树这么受 InnoDB 偏爱?关键在"矮胖"二字。B+ 树的每个节点可以存很多个索引键,一棵 3 层到 4 层的树就能放下千万级数据。查询时最多读三四次磁盘就能定位到叶子节点的记录,省下了天文数字般的 I/O 开销。而且叶子节点之间通过双向指针串成了有序链表,这让范围查询(比如WHERE create_time BETWEEN ...)和排序遍历变得极其廉价——找到下限后顺着链表往后挪就行,不用反复从根节点重新开始。

索引字面意义上是个"目录",但在 InnoDB 里有个容易忽略的细节:目录和数据行其实是可以分开存的。索引分两大类,一类是聚簇索引(clustered index),就是数据表本身按主键排序组织,索引的叶子节点存的是完整的一行数据;另一类是二级索引(secondary index),叶子节点存的是主键值,拿到主键后再回聚簇索引里去取整行。这就引出一个非常重要的概念——回表。

举个例子:表上有索引idx_user_id,执行SELECT * FROM orders WHERE user_id = 10086。MySQL 先在二级索引里找到所有 user_id 等于 10086 的主键值,再拿着这批主键去聚簇索引里把整行数据捞出来。如果命中了 1000 行,就要回表 1000 次。回表本身不慢,但每回一次都要走一次 B+ 树查找,批量回表的成本就上去了。理解了回表,你才能理解为什么"覆盖索引"(后面会专门讲)能带来那么夸张的性能提升——数据全在索引里,压根不用回表。

还有一个实际点必须提:索引不是你建了就一定生效的。MySQL 的优化器会综合判断表的数据量分布、索引的区分度、当前要查的代价,决定到底走哪个索引还是干脆全表扫。有时候你觉得天经地义的索引,优化器甩都不甩你。所以真正要学的不是"怎么建索引",而是"怎么配合优化器,让索引真正被用上"。

2. WHERE 条件 a AND b 该怎么建索引:联合索引设计的最左前缀原则

热搜里排名靠前的问题是"mysql where条件a and b,应该怎么建索引"——这几乎是索引面试里最经典的一道题。正确做法是建联合索引(a, b)而不是分开建(a)和(b)两个单列索引。为什么?因为 MySQL 一条 SQL 通常只能选一个索引来用,你建了两个单列索引,优化器挑一个走得快的,另一个就凉在那儿白占空间。而联合索引把两个条件合并在一棵 B+ 树里,a 和 b 一起参与检索,效果是 1+1>2。

但联合索引有个核心约束:最左前缀原则。索引(a, b)的排序逻辑是:先按 a 排,a 相同的再按 b 排。这样一来,它能直接支持WHERE a = ?和WHERE a = ? AND b = ?,但不支持单独WHERE b = ?——因为 b 是第二顺序键,在不知道 a 的前提下,b 在整棵树里是乱序的,无法直接定位。这不是 MySQL 的 bug,而是 B+ 树排序结构的必然结果。

最左前缀原则听起来简单,实操里翻车的场景我倒可以举几个。

第一个翻车场景是顺序搞反。有个真实案例是订单表上建了(status, create_time)的联合索引,日常查询条件是WHERE status = 1 AND create_time > '2024-01-01',效果还行。但后来加了个需求,要按create_time做全局范围查询,开发同学天真地以为已经有 create_time 在索引里了就能覆盖到,结果 EXPLAIN 一看,全表扫描。原因就是单独WHERE create_time > ?无法走(status, create_time)索引——status 没给值,最左前缀被拆断了。

第二个翻车场景是范围条件放错位置。索引(a, b)里 a 用了范围查询(>、<、BETWEEN),那 b 还能参与排序定位吗?答案是:不能完全参与。MySQL 定位到 a 的范围区间后,对这个区间内的所有 b 值只能做一遍线性检查,无法利用 b 的有序性进一步跳过。我把一条 SQL 写出来你就明白了:

SELECT * FROM t WHERE a > 100 AND b = 5;

如果索引是(a, b),MySQL 先按 a>100 把区间切出来,b=5 的条件只能在区间内过滤。你可以把 b 的条件提到前面,或者考虑把索引改成(b, a)——让 b 用等值定位,a 做范围过滤,这就能最大化利用两个条件。

再说第三个场景:等值优先原则。联合索引列的顺序设计有一条经验法则——区分度高的放前面,等值条件放前面,范围条件放后面。比如要查WHERE user_id = ? AND status = ? AND create_time > ?,索引建(user_id, status, create_time)。user_id 和 status 都是等值条件,谁在前?看区分度。user_id 区分度通常远大于 status(status 可能就两三个取值),所以把 user_id 放最前面,这样树的第一层就能切掉绝大多数数据;status 接着等值匹配;create_time 放最后做范围过滤。如果反过来,把 status 放最前,就算它命中率只有 50%,也要在这个半区的数据里逐个比对 user_id,白白多走一层。

这里有一个仿真实战的做法值得分享。建索引前,先跑一遍真实业务的所有核心查询,把查询条件里的字段全部抄下来,统计每个字段出现的频率和过滤比例(区分度)。过滤比最高的等值字段放第一位,范围字段永远垫底。这样设计出来的联合索引,通常能满足 80% 以上核心查询的需求,剩下的再用单列索引或冗余索引补充。我自己维护过一个千万级会员表,核心索引就是按这个流程定的,后来半年没动过,慢查询日志几乎清空。

3. 索引失效排查:为什么明明有索引,EXPLAIN 却显示全表扫描

网上关于索引失效的知识零零散散,什么"千万不要在索引列上做函数运算""OR 会失效""LIKE 通配符会失效",但很少有人把背后的原理讲透。失效的本质只有一条:你破坏了索引列值的可比较性,或者你让优化器认为走索引的代价比全表扫更大。想通这一条,就能自己推演各种场景,不用死记硬背。

3.1 索引列用了函数或表达式,袋子里怎么找物品?

第一个高频失效场景:在索引列上做了函数操作或运算。比如WHERE DATE(create_time) = '2024-01-01',或WHERE price * 1.1 > 100。索引树里存的是原始的 create_time 和 price 值,MySQL 没办法直接拿函数处理后的结果去树里匹配。它只能把整个索引列的值全部提取出来,逐个套上函数或表达式算一遍,再跟条件比对。这一过程本质就是全量扫描,索引的排序结构完全派不上用场。

这种场景在日志表、流水表里特别常见。我维护过一个支付流水表,台账 1 亿行,运营同学每天看报表,喜欢写WHERE DATE(create_time) = CURDATE()。第一次见到 EXPLAIN 里 type=ALL 我都懵了,后来才反应过来是函数包了列。解决办法也很简单,把条件改成范围查询:

WHERE create_time >= '2024-01-01 00:00:00' AND create_time < '2024-01-02 00:00:00'

范围查询能直接走索引的 B+ 树搜索,效率立竿见影。如果确实要按日期做复杂处理,在 MySQL 8.0 里可以尝试函数索引(CREATE INDEX idx_date ON t ((DATE(create_time)))),但这算高级玩法了,普通业务尽量先把条件改造成等值或范围。

3.2 隐式类型转换:字符串字段被喂了数字

第二个容易踩坑的是隐式类型转换。字段是 VARCHAR,查询条件却传了数字;或者字段是 INT,查询条件传了字符串。MySQL 会自动做类型转换,但转换的方向很有讲究:它通常会把字符串转成数字来比较。如果索引列本身是 VARCHAR,条件值是 10086(数字),MySQL 会把索引列的所有字符串值都转成数字再比较,等于给索引列套了个隐式函数,索引自然失效。

我在一次生产事故里就经历过这个:用户表phone字段是 VARCHAR(20),SQL 里忘了加引号,写成WHERE phone = 13812345678。这条 SQL 在几百万行数据上硬生生扫了 1.8 秒,接口超时报警。排查时一眼就看到了类型转换,给 phone 补上引号后,查询耗时降到 3 毫秒。那件事之后我定了个纪律:代码评审里凡是看到索引列和条件值类型不一致的,一律打回。这个习惯救了我后来很多次。

3.3 LIKE 前置通配符和 OR 条件:优化器的无奈选择

LIKE 模糊查询分两种。WHERE name LIKE '张%'可以走索引,因为 B+ 树有序,前缀匹配能直接定位到"张"开头的区间。但WHERE name LIKE '%三%'就惨了——以通配符开头,字符串前缀不确定,MySQL 没法用树结构定位,只能把全表(或全部索引)扫一遍逐个匹配。业务上如果确实需要中后位置模糊搜索,又对性能有硬性要求,就该上全文索引或 ES 之类的搜索引擎,这个后面有空单独写一篇。

OR 条件失效也是老生常谈。WHERE a = 1 OR b = 2,如果 a、b 分别在两个索引上,MySQL 7.0 版本之后虽然能用 index merge(合并两个索引的扫描结果),但再早一些的版本会直接放弃索引全表扫描;即使是支持 index merge 的版本,当 OR 涉及三个以上条件或其中一个是非索引列时,性能依然堪忧。最稳妥的做法是把 SQL 改写成UNION ALL:

SELECT * FROM t WHERE a = 1 UNION ALL SELECT * FROM t WHERE b = 2

两个查询各自走各自的索引,然后合并结果。改完之后 EXPLAIN 的 type 会从 ALL 变成 ref 或 range,通常能快一个数量级。

3.4 优化器觉得全表扫更划算:索引基数(Cardinality)的陷阱

最后一种"失效"最有欺骗性——它连 EXPLAIN 都不报错,就是慢,而且看起来索引明明存在。原因在于优化器判断:这个索引区分度太低(比如性别、状态这类字段,总共就两种取值),走索引回表反而比直接全表扫描更贵。你想想,走二级索引要先读索引页找出所有符合条件的主键,再回表读数据页;如果索引命中了一半以上的行,回表的随机 I/O 远高于顺序读全表,优化器算这笔账算得门儿清。

我见过一个管理员把表上的每个字段都建了索引,其中就包括 status 这种只有 0/1 两种取值的字段。结果查询WHERE status = 1时优化器直接选全表扫描,这个索引压根没被用上,还要承受每次写入时的索引维护开销。判断一个索引是否值得留,可以用这条 SQL 看基数:

SHOW INDEX FROM table_name;

主要看 Cardinality 列,它表示索引列的不重复值数量。如果某列总行数是 2000 万,Cardinality 只有 2,这种索引建的毫无意义,建议删掉。索引的设计思路应该是宁缺毋滥,与其堆一堆没用的单列索引,不如把每条 SQL 跑一遍 EXPLAIN,确认哪个索引真的被用了、用得好,再决定要不要留。

4. 主键索引、唯一索引、普通索引:别再傻傻分不清,它们的工作机制完全不同

热搜词里"主键索引和唯一索引的区别"这题几乎是面试必问。很多同学背了"主键索引不能为 NULL,唯一索引可以"这种书皮答案,但真正影响线上性能的差异,远不止这一点。

4.1 聚簇索引与二级索引的底层分歧

主键在 InnoDB 里承载的角色太重了:它不光是一个索引,更是整张表数据的物理存储顺序。InnoDB 规定表必须有一个聚簇索引,你建主键它就拿主键当聚簇索引;你不建主键,它会找第一个非空唯一索引当聚簇索引;两个都没有?它会生成一个隐藏的 6 字节 RowID 当聚簇索引。所以主键索引的叶子节点就是整行数据,没有回表的说法,这是它跟普通二级索引最根本的区别。

唯一索引和普通索引在叶子节点上其实结构一模一样——都是二级索引(除非它碰巧被选为了聚簇索引)。它们的区别在于约束逻辑:唯一索引禁止出现重复值(允许且仅允许一个 NULL),普通索引允许完全相同的值。但这带来一个性能上的差异,值得展开讲。

4.2 唯一索引与普通索引的写入性能差异:Change Buffer 的机制

MySQL 在更新索引时,有个叫 Change Buffer 的机制。当你要修改的索引页不在内存缓冲池里时,InnoDB 不会立刻去磁盘读页更新,而是先把修改操作缓存在 Change Buffer 里,等下次这个页被读进内存了,或者系统空闲了,再把这些操作合并(merge)进去。这个机制对普通索引的随机写入性能帮助巨大——避免了大量的磁盘随机 I/O。

但唯一索引享受不到这个福利。因为唯一性约束要求在插入前就得查明"这个页面里到底有没有重复值",你不能只记一个"待定"的修改,必须立刻把目标索引页读进内存,确认不重复再插入。所以说,唯一索引的写操作是有额外 I/O 代价的。在高并发批量写入场景下,我一再提醒团队:如果业务上并不强制要求全局唯一,就别为了"严谨"加唯一索引,改成普通索引加应用层判断。一条性别混排不改,系统写入吞吐就能差出 15%~30%。

4.3 主键到底该怎么选:自增、UUID 还是业务主键?

这个话题在分布式微服务时代吵得更凶了。核心要考虑的是"写入是否有序"。自增主键的写入是"永远往 B+ 树最右侧追加",新页填满就接着开新页,几乎不会引发索引页分裂,顺序写性能最好。而 UUID 主键是随机分布的,每次插入都可能落在树的中间某个位置,导致索引页分裂、重排,随机 I/O 多,索引膨胀也快。

大厂面对海量写入时,确实会用分布式 ID(雪花 ID 或其他变种)。雪花 ID 虽然带时间戳,大体有序,但仍可能出现乱序。如果业务能接受,我会更推荐"自增主键 + 业务唯一键建唯一索引"的组合:存储层面获得写入性能最优,业务层面靠唯一索引保证约束。但要注意,自增主键由单点生成,分库分表后要改用分段发号或雪花方案,具体就看你的架构能力了。

4.4 覆盖索引:为什么 SELECT 的字段也影响性能?

讲完主键和唯一索引,必须顺带提覆盖索引,因为它直接利用了"二级索引里存主键"这个特性。如果一条查询需要返回的列都包含在某个二级索引里,MySQL 直接遍历索引就能拿到所有数据,完全不用回表。典型写法:

-- 索引 (user_id, status) 覆盖了 user_id、status 两列 SELECT user_id, status FROM orders WHERE user_id = 10086;

这种 SQL 的 EXPLAIN 里 Extra 列会显示Using index,表示走的是覆盖索引,省掉了回表的那几百上千次随机 I/O。这在统计类、报表类查询里非常常用。所以顺手推荐一个设计技巧:核心查询里 SELECT 的列越长,越应该考虑让它"顺手"被索引覆盖,能省掉一个巨大的性能瓶颈。

5. 索引调优的实操方法论:从 EXPLAIN 出发,建立自己的排查链路

前面讲了这么多理论和机制,最后落回实操。作为 DBA 或后端开发,你可能已经有一定的索引基础,但遇到一条慢 SQL 时,怎么把问题追到根上?我给出一套我自己用的排查链路,按这个顺序走,大多数索引问题都能半小时内定位。

5.1 EXPLAIN 关键字段速读手册

EXPLAIN 输出里列很多,但我通常只盯着三个最重要的字段看:type、key、rows。

  • type 是访问类型,性能从好到差大致是:system > const > eq_ref > ref > range > index > ALL。见到 ALL 就要警惕全表扫描。
  • key 是实际用到的索引名。有时候你会发现明明该走 idx_a,结果跑了别的索引,那就说明优化器的选择可能不是最优。
  • rows 是优化器预估的需要读取行数,与实际值差距过大,说明统计信息太久没更新了,或者 SQL 写法诱导了错误评估。

举个例子,一条查询执行计划长这样:

EXPLAIN SELECT * FROM orders WHERE user_id = 10086 AND status = 1;

结果显示 type=ref,key=idx_user_status,rows=2000。type=ref 代表走的是普通等值匹配的非唯一索引,key 正确,预估行数很少,那这条 SQL 的索引利用情况就是健康的。如果看到 type=ALL,key=NULL,rows=20000000,就要立刻回头查是不是索引列被函数包装了、类型不匹配或条件顺序问题。

5.2 覆盖真实业务的索引设计流程

我有一套屡试不爽的流程,分享给团队后大家反馈都很好,核心就四步:

  1. 采集核心 SQL:打开慢查询日志(slow_query_log=ON),跑一两天,把所有执行超过 100ms 的 SQL 拉出来,按执行次数排序。
  2. 提取条件映射表:把每条 SQL 的 WHERE 条件、ORDER BY、GROUP BY 字段全部提取出来,记在表格里,标注每个字段是等值、范围、排序还是分组。
  3. 按频率和区分度设计联合索引:高频等值字段放最前,范围字段放中间或最后,排序字段如果走索引不会干扰前面的等值匹配就一并放进索引。
  4. 逐条验证:重新执行 EXPLAIN,确认 type 达到 ref 或 range 级别,rows 显著下降,再上线观察慢查询日志的变化。

这套流程要求团队在每次上线前做 SQL Review,思想上从"写完能跑就行"切换到"写完必须看执行计划"。我自己统计过,强制走这套流程之后,新上线业务的慢查询数量基本能下降 80% 以上。

5.3 索引数量与冗余控制:别把表建成了索引自助餐

索引不是越多越好,这句话听着像废话,但执行起来很多人忍不住。每多一个索引,INSERT/UPDATE/DELETE 都要额外维护一棵 B+ 树,页分裂、日志写入统统翻倍。我经手过一个商城订单表,上线两年积累了 17 个索引,写入性能下降了 40% 以上,后来清理成 5 个核心联合索引,写入性能几乎翻倍,查询也没受什么影响。

清理冗余索引也有一些成熟的原则。联合索引前缀完全重复的算冗余,比如有了(a, b, c),单独的(a)就是冗余的,因为联合索引的前缀已经覆盖了 a 单列的查询。还有长期没有被优化器选中的索引(观察 performance_schema 的索引使用统计),该删就删。另外,同一列上同时存在单列索引和联合索引,如果单列索引命中率不高,也可以砍掉。

5.4 两个典型误区的纠正

最后说两个容易被带偏的说法。

一是"索引能够极大提升查询速度,所以查询慢就加索引"。这是把索引当万金油了。如果一条 SQL 已经是全表扫,可它本来就得返回 50% 以上的行(比如导出全量报表),那加索引也救不了它,因为走索引回表比直接全表扫还慢。这种场景要么改业务逻辑(分页、增量、列裁剪),要么上真正的分析型基础设施(如 ClickHouse 或 ES),而不是在 MySQL 上硬拗。

二是"覆盖索引是万能的,SELECT 全列都塞进索引里"。确实有这种极端例子,把一行的所有列都建进联合索引,做成所谓的"索引即数据",但这会让索引体积膨胀到接近表本身,写入成本高到不可接受。覆盖索引的价值在于覆盖高频查询的那几个关键列,而不是贪心覆盖所有。我在实际工作中给报表查询建过"宽覆盖索引",效果很好,但同时我也会在代码里规定:报表类查询必须走专用的宽索引,不能全表 SELECT 所有列去碰运气。

结尾:最后再分享一个小技巧

排查索引问题这么多年,我个人体会最深的一点是:永远不要凭感觉判断索引生效了没有,一切以 EXPLAIN 为准。而且 EXPLAIN 里的 ANALYZE 选项(MySQL 8.0.18+ 支持)能直接给出真实的执行耗时和实际读取行数,比预估行数靠谱得多。遇到"索引该走却没走"的情况,先跑ANALYZE TABLE更新一下统计信息,很多问题其实是因为统计信息太陈旧,优化器做了错误判断。

还有个小技巧:在开发环境建索引时,我习惯把区分度预估也纳入 SQL Review,用一条简单的SELECT COUNT(DISTINCT col) / COUNT(*) FROM t算出来,如果比值低于 0.1,这个索引我基本就不会建了。这些细节看起来琐碎,但堆在一起,就是线上数据库稳定运行和三天两头出事故之间的差距。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询