复合索引最左匹配原则详解:从慢查询到B+树底层原理与实战 上周隔壁组的同事跑过来让我看一条慢查询说表上明明建了复合索引(area_id, device_id, created_at)SQL 也不复杂就是按设备查最近一条记录可explain出来typeALL几百万行的表直接全表扫。我拿过 SQL 扫了一眼where device_id ? and created_at ?当场就告诉他这不是索引建得不对是最左匹配原则根本没对上。今天就把这个原则掰开揉碎讲清楚顺便把我踩过的坑、验证过的场景一起交代。最左匹配原则几乎是每个后端开发都会背的一句话复合索引按列定义顺序从左往右匹配查询条件。但背住和用对之间隔着大量实际场景——范围条件怎么算、排序怎么算、函数包裹怎么算、多个等值条件顺序是否重要。这些细节不搞清楚就会出现同事那种索引建了却没用上的情况而且往往排查很久都找不到原因。1. 从一次慢查询说起索引建对了SQL 却没走对先把同事的场景完整还原一遍。表结构大致长这样CREATE TABLE device_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, area_id INT NOT NULL, device_id INT NOT NULL, created_at DATETIME NOT NULL, value DECIMAL(10, 2), KEY idx_area_device_time (area_id, device_id, created_at) ) ENGINEInnoDB;慢查询长这样SELECT * FROM device_log WHERE device_id 10086 AND created_at 2024-01-01 ORDER BY created_at DESC LIMIT 10;从业务角度看这条 SQL 很标准查某个设备某段时间后的数据。但从索引角度看WHERE条件里出现的两列——device_id和created_at——恰好是复合索引的第二列和第三列最左边的area_id被跳过了。这等于把一本按照地区-设备-时间排序的目录直接翻到设备那一层去查目录本身当然没法用。1.1 一个根深蒂固的误解包含就算匹配很多人以为WHERE条件里写了索引的某些列MySQL 就会尝试用索引。这个理解错得挺离谱。复合索引的物理结构决定了它只能从最左列开始按顺序定位数据条件里有没有中间列、有没有最左列直接决定了索引能不能被完整或部分使用。用一个生活化的类比电话簿按姓氏名字排序。你想在电话簿里查所有叫王芳的人这个需求是成立的——因为电话簿本身按姓氏王排在最左边你翻到王姓区域再筛名字即可。但如果你想查所有名字叫张伟而不限定姓氏的人电话簿帮不了你你必须翻遍整本电话簿因为名字在最左列排序中根本没被当作第一关键字。复合索引的道理完全一样(area_id, device_id, created_at)就意味着数据先按area_id排再按device_id排最后按created_at排。device_id 10086这个条件在整棵索引树上并不连续自然无法用二分定位。1.2 explain 里藏着全部真相排查这类问题第一步永远是看执行计划别靠猜。把同事那条 SQL 换成EXPLAIN跑一遍输出如下id | select_type | table | type | possible_keys | key | key_len | rows 1 | SIMPLE | device_log | ALL | NULL | NULL | NULL | 5231880注意几个关键字段typeALL表示全表扫描possible_keysNULL表示这个条件组合完全没被优化器纳入考虑rows接近全表行数。这就是索引完全失效的铁证。改成WHERE area_id ? AND device_id ? AND created_at ?之后再看type: range key: idx_area_device_time key_len: 14 rows: 187key_len从左往右表示实际用到的索引字节数area_id4 字节device_id4 字节created_at6 字节DATETIME 在 MySQL 5.6 为 5 字节但这里通过 key_len 能精确看出优化器用了三层索引的哪几层14 字节意味着三层都参与了定位。type从ALL变成rangerows降了几个数量级这才是索引真正生效的样子。所以遇到慢查询第一反应不是重建索引而是先看explain里key和key_len推断出索引到底被截断在哪一列然后反推是不是违背了最左匹配原则。2. 复合索引的底层排序逻辑先把 B 树怎么摆搞清楚理解最左匹配原则不能停留在记住规则层面得知道 B 树里复合索引到底是怎么组织的。搞懂底层排列以后遇到任何特殊的 SQL 写法你都能自己推演而不是查一篇篇博客记结论。2.1 索引叶节点其实是一份有序目录InnoDB 的复合索引是一个 B 树叶节点存的是索引键值和主键值而且键值严格按定义列的顺序排序。白话讲所有叶子节点先按第一列排序第一列相同的再按第二列排序第二列仍相同的再按第三列排序以此类推。(area_id, device_id, created_at)这个索引数据物理存储大致等价于下面的伪代码ORDER BY area_id ASC, device_id ASC, created_at ASC这个等价关系非常重要。只要想判断一条 SQL 能否利用索引把查询条件里的等值条件换成匹配某一段把排序/分组字段跟ORDER BY做对照基本就能得出正确答案。2.2 为什么跳过最左列会全军覆没继续用电话簿类比。(area_id, device_id, created_at)是地区、设备、时间三级目录。你要找某个设备在某些时间的数据但不限定地区——相当于直接翻到电话簿里找名字叫张伟的所有人。因为有地区前缀的差异这些device_id10086的行散落在整棵索引树的各个分支里搜索算法无法确定从哪个叶子节点开始也无法确定在哪里结束于是只能放弃索引退化成全表扫描。这一点在做索引设计时尤其扎心很多新人习惯把查询频率最高的列放在复合索引第一位但忽略了一个隐性前提——最高频的列未必是查询里最稳定的列。如果某条核心 SQL 经常不带这个列那它对该 SQL 来说就等于没有索引。2.3 一条 SQL 在索引树上的完整检索路径假设现在查询条件是area_id 1 AND device_id 10086 AND created_at 2024-01-01。MySQL 在 B 树上的动作是根据area_id 1定位到所有地区为 1 的分支这是第一层过滤。在这些分支内根据device_id 10086继续二分定位到设备 10086 对应的叶子区间。在这个叶子区间内部因为最左列和第二列都相同此时叶节点实际上已经按created_at排好序了于是直接定位到 2024-01-01的第一条数据然后顺序向后扫。这三步分别对应 B 树的逐层下钻。每一步都需要前一列的值固定下来才能继续下一列的二分定位。这也解释了为什么范围条件之后再加等值条件会失效一旦某列使用的是范围匹配、、BETWEEN该列定位到的不是一个点而是一个区间区间内部固然有序但下一列无法在这个区间上继续二分只能逐行过滤。3. 最左匹配完整规则拆解等值、范围、排序、函数逐一实测光说原理还不够我把常见写法和它们的执行结果整理成一个对照表这些都是我在实际环境里验证过的。以下均基于(a, b, c)复合索引讨论。类型SQL 写法索引使用情况原因全等值WHERE a1 AND b2 AND c3完全命中每一列都是精确值可以逐层定位前缀等值WHERE a1完全命中只用第一列前缀等值次列等值WHERE a1 AND b2完全命中前两列精确缺失中间列WHERE a1 AND c3部分命中仅 ac 无法从中间跳入只能过滤缺失最左列WHERE b2 AND c3完全不命中无法定位起始分支范围在中间WHERE a1 AND b2 AND c3部分命中a、bb 确定区间c 只能逐行过滤前缀范围WHERE a1 AND b2部分命中仅 aa 是区间b 无法定位前缀模糊WHERE a LIKE abc%部分命中前缀匹配仍有序可定位区间后置模糊WHERE a LIKE %abc完全不命中以任意字符开头无有序起点函数包裹WHERE DATE_FORMAT(a, %Y) 2024完全不命中索引存的是原始值函数改变了比较对象最左缺失 高版本WHERE b2(MySQL 8.0.13 特定场景)可能跳跃扫描索引跳跃扫描但有前提条件3.1 等值条件的顺序自由是怎么来的有一个常见疑问WHERE a1 AND b2 AND c3和WHERE b2 AND c3 AND a1执行效果是否一样答案是一样。MySQL 优化器会做条件重排把等值条件调整为索引要求的从左到右顺序。所以只要三个等都是等值条件写在前写在后都不影响走索引。但要注意这里说的是等值条件。一旦混入范围条件比如WHERE b2 AND a1 AND c3优化器虽然能把a1提到前面但因为b2是范围c3仍然无法参与索引定位只能在a和b定位出的区间上做逐行过滤。所以条件顺序可以随便写这个结论是有边界的只对等值条件成立。3.2 范围条件吃掉后面的列这是最左匹配里最容易引发线上事故的一条。许多人知道范围列之后的列索引失效但不知道具体指什么。以(a, b, c)为例SELECT * FROM t WHERE a 1 AND b BETWEEN 10 AND 20 AND c 5;这条 SQL 里a用于等值定位b用于区间定位c虽然也是等值但它位于范围列b之后无法在b的区间上继续二分。所以key_len只覆盖到a和b两层c变成普通过滤条件。这带来的隐藏影响是如果b BETWEEN命中的区间非常大c5的过滤只能逐行比对性能依然可能很差。我在实际项目中见过不少索引看起来命中了但还是慢的案例根子就在这里——typerange不代表高效rows可能还有几十万。3.3 最左列 IN 和 OR 的坑IN在优化器眼里比较特殊。WHERE a IN (1,2,3) AND b5并不算范围的终结者MySQL 会把a IN (1,2,3)拆成三个等值区间每个区间内继续按b5定位所以索引可以正常用到两层。但OR就是另一回事了。SELECT * FROM t WHERE a1 OR b5;这个 SQL 有两个独立的扫描需求一个是按a1找另一个是在全表范围找b5。即使优化器做了索引合并index_merge也很少能理想地同时利用两个单列索引一旦其中一边无法走索引整个条件往往退化成全表扫描。规则可以简化记OR两侧必须都能走索引否则整体大概率不走。4. 排序、分组与覆盖索引最左匹配原则的其他面孔很多人以为最左匹配只影响WHERE实际上ORDER BY、GROUP BY、DISTINCT、JOIN关联条件、覆盖索引全都在同一套规则之下。把这些都搞明白你对索引的理解才算完整。4.1 ORDER BY 能不能用索引关键在于前缀有序复合索引(a, b, c)在存储上等价于按a, b, c三键排序。因此SELECT * FROM t WHERE a1 ORDER BY b, c;这个能走索引排序因为a1已经固定了第一层剩下的b, c天然有序MySQL 不需要额外的 filesort。同样的道理WHERE a1 AND b2 ORDER BY c也能走因为前两层都被等值固定第三层有序。反过来SELECT * FROM t WHERE a1 ORDER BY c, b;这个就无法利用索引排序。虽然a1确定了最左列但c, b的排序顺序和索引的b, c顺序不一致MySQL 只能把结果捞出来再做一次 filesort。ORDER BY的规则和最左匹配完全一致排序字段必须是索引列并且顺序要与索引定义方向一致、中间不能断。还有个常见衍生问题ORDER BY全部字段都是升序或全部降序时才可能走索引。如果ORDER BY b ASC, c DESC那是混合排序方向MySQL 8.0 之前的版本基本都选择 filesort8.0 之后才引入了降序索引支持而且需要在建索引时显式指定(b ASC, c DESC)这样的方向。4.2 GROUP BY 和 DISTINCT 同理但更隐蔽GROUP BY和DISTINCT本质是去重排序操作。GROUP BY a, b若能用索引可以直接从索引的有序序列中完成分组避免临时表和 filesort。一个容易忽视的场景是GROUP BY b, c但查询条件没有限定a这时分组无法利用(a,b,c)索引只能走临时表。很多报表型慢查询就是这么来的——字段倒是全在索引里但分组顺序没从最左列开始白费一个复合索引。4.3 覆盖索引让最左匹配的价值再翻一倍覆盖索引的含义是查询所需的列全部包含在某个索引中于是不需要回表。这个机制和最左匹配叠加之后可以显著降低 IO。举个例子SELECT device_id, created_at FROM device_log WHERE area_id 1 AND device_id 10086因为(area_id, device_id, created_at)索引里已经包含了device_id, created_at两列MySQL 可以只扫索引叶子节点就返回结果连主表数据都不碰。这也是为什么索引设计时查询用到的返回列会被纳入考虑。但覆盖索引设计要克制。一个常见错误是为了覆盖某条查询往索引里拼命塞列结果索引体积膨胀、写入变慢反而得不偿失。实践里我一般只针对高频查询做覆盖冷查询用普通回表就够了。5. 索引设计实战把列顺序和查询需求对齐而不是凭感觉能走到这一步说明最左匹配的基本原则已经掌握了。但从懂规则到设计出好索引中间还有一段距离。下面聊聊我在索引设计和 SQL 改写上的实战经验。5.1 列顺序的通用准则等值优先范围靠后设计复合索引第一位考虑的永远是把查询中高频出现、且条件为等值的列放在最前面。因为等值条件能够精确定位可以最大限度缩小后续检索范围。范围条件、、BETWEEN放中间偏后避免它吃掉后面的列。举一个实战调整例子。某订单查询场景常出现三种查询WHERE merchant_id ? AND order_status ? AND created_at BETWEEN ? AND ?WHERE merchant_id ? AND created_at BETWEEN ? AND ?WHERE order_status ? AND created_at BETWEEN ? AND ?若按高频优先直觉merchant_id放第一位created_at放第二位order_status放第三位比较合理。因为查询 1 和 2 都能用前两列查询 3 用不了索引但频率低。如果反过来把created_at放第一位等于所有查询都以一个范围条件开头后面的列全废了索引价值大打折扣。5.2 判断冗余索引是最容易忽略的成本最左匹配原则还提供了一个判断冗余索引的视角如果存在索引(a, b, c)那么索引(a, b)基本是冗余的因为任何能用(a, b)的查询(a, b, c)都能覆盖它。同理(a)也是冗余的。我自己见过一个线上表DBA 和开发各建各的索引最后同一张表上出现了(a,b)、(a)、(b,c)三个索引。三条索引合起来占的空间比表数据还要大写入性能直线下降。后来按最左匹配原则一梳理只保留(a,b,c)和(c)效果立竿见影。判断冗余时还要注意单列索引(c)不一定冗余因为当查询条件只有c而没有a、b时(a,b,c)无法命中(c)才是唯一可用索引。这也是最左缺失带来的典型冗余判定难题。5.3 无法改索引结构时改写 SQL 的几条思路有时候索引结构是历史遗留改起来牵扯太多。这时可以从 SQL 层面想办法把跳过最左列的查询改成显式补全最左列的写法。比如查询里可以带area_id的值只是业务代码里没传那就让前端传参或由会话上下文补齐。利用索引跳跃扫描。MySQL 8.0.13 以后对WHERE b2 AND c3这种跳过第一列的查询如果第一列的可区分值很少优化器可能自动做跳跃扫描——本质是把第一列的各个值分别代入组合成多个等值区间。但注意它只在特定条件下触发而且性能未必好不能当作常规依赖。对高频且无法走索引的查询考虑新建针对性索引而不是强行改写 SQL。很多时候要让 SQL 匹配现有索引的代价远高于新建一个专门索引。函数包裹列导致索引失效时尽量改成范围等价写法。比如WHERE DATE(created_at) 2024-01-01改写为WHERE created_at 2024-01-01 AND created_at 2024-01-02就能重新利用索引。5.4 别忘了优化器有自己的判断最后要说一个容易被忽略的事实即使 SQL 写法符合最左匹配优化器也不一定百分百走索引。它有成本估算机制——当索引选择性太差时比如某列 90% 的行都是同一个值优化器可能认为全表扫描比走索引回表更便宜从而放弃索引。这也是为什么explain里出现possible_keysidx_xxx但keyNULL时先别急着骂 MySQL应该看一下数据分布。通常做法是ANALYZE TABLE t更新统计信息再重新explain如果优化器还是不走而你又确信该走可以用FORCE INDEX(idx_xxx)强制指定但务必验证实际效果别盲目加提示。聊到这里我自己的习惯是每条要上线的 SQL 都先写EXPLAIN看一遍重点盯三样东西——type不能是ALL、key_len尽量吃满、rows与实际命中量差距别太大。这三样过关基本就不会和最左匹配原则打架。最后再分享一个小技巧排查索引问题时把key_len的值逐列拆开看能精确知道优化器实际用了几层索引这比紧盯key字段要靠谱得多——key只告诉你用了哪个索引key_len才告诉你用了多少。如果你手头也有那种明明建了复合索引SQL 却一直很慢的案例别再直接加索引了。先打开explain对照这篇文章里的规则逐条过一遍大概率能当场定位问题。等值列往左放、范围列往右放、排序字段别岔序记住这三句话复合索引这关就算真正过了。