
先说明一个现象很多开发同学建表时随手定一个自增id当主键遇到慢查询就习惯性加个联合索引但索引加了之后到底是快在哪、慢在哪问起来又含含糊糊。主键索引和联合索引就是MySQL索引体系里最容易被人“会用但说不清”的两个东西。这篇文章我把这两类索引的底层结构、匹配逻辑和实操细节一次讲透适合刚接触索引原理的开发者也适合准备面试、想系统梳理索引知识的人。1. 主键索引B树的叶子节点里到底存了什么1.1 聚簇索引的概念数据就是索引索引就是数据很多人第一次听到“聚簇索引”这个词会觉得抽象其实用一句话就能说明白在InnoDB里整张表的数据就是一棵B树这棵树以主键为排序键树的叶子节点直接存放该行的完整数据记录。也就是说当你通过主键去查询数据时根本不需要“找到索引再去磁盘拿数据”这个过程B树一路走到叶子叶子就是数据页数据本身就是结果。这里的关键在于“聚簇”这两个字。所谓聚簇指的就是数据行和主键索引物理地聚在一起。一张InnoDB的表不管你有没有显式定义主键最终都一定有这棵聚簇索引树。如果表没有主键InnoDB会优先选一个非空的唯一索引作为聚簇索引如果连唯一索引也没有那它就默默生成一个6字节的隐藏Rowid作为聚簇索引键。很多同学以为“没建主键就等于没有主键索引”这是错的——你没建数据库也会强制给你造一个只是它不可见、不可控而已。给一个直观的类比主键索引相当于是整本书页码本身书页上的内容就印在页码旁边你翻到第100页第100页的内容直接就能读而二级索引更像是书最后的“关键词-页码对照表”查关键词只能先得到页码还得再翻到正文那一页才能看到完整内容。这个“再翻一次”的动作就是后面要讲的“回表”。1.2 为什么主键强烈推荐自增整数既然聚簇索引决定了数据物理存放顺序那主键的生成方式就直接影响到数据写入时的性能表现。如果主键是自增整数那么新插入的行在主键序上是递增的B树总是往最右边的叶子节点追加数据已有的叶子节点和数据页基本不需要分裂写入非常顺滑。反过来如果主键是UUID或者业务生成的随机字符串每次插入都有可能落在B树中间某个位置这就会导致叶子节点分裂、数据页重排、部分数据页出现碎片。我在实际项目里做过一个简单的压测对比同样是1000万行数据自增整型主键批量插入耗时约为UUID主键的1/3左右而且UUID主键那张表的物理文件体积大约膨胀了20%——这些都是随机主键带来的存储和写入代价。所以优先选自增整数主键不只是“惯例”背后是B树数据结构特性决定的。如果实在要用UUID建议改成UUID的二进制存储比如把字符串转成BINARY(16)或者用雪花ID这类趋势递增的ID生成方案能在很大程度上缓解随机写入的问题。1.3 主键索引的查找路径一棵树的搜索过程在InnoDB中主键查询的路径非常短。以SELECT * FROM user WHERE id 1024为例过程可以拆解如下从聚簇索引的根节点开始二分查找定位到下一层的页号逐层往下每层都在节点内部的“键值指针”数组里做二分定位最终落到叶子节点页面在页内通过二分查找找到id1024对应的记录直接返回该记录的全部字段无需再访问其他地方。整个过程涉及的磁盘IO次数取决于B树的高度。InnoDB非叶子节点一个页默认16KB假设主键是8字节的bigint加指针约12字节一个页大概能存1000多个键。两层非叶子节点就能支撑大约120万行数据三层就能支撑十几亿行。也就是说一个上亿行的表主键精确查询通常只需要3次左右磁盘IO这也是为什么我会反复强调“能用主键查就别用二级索引查”的原因——二级索引多一次回表等于多一次让磁盘转动的机会。2. 联合索引最左前缀原则是怎么运作的2.1 联合索引内部怎么排的序联合索引本质上就是在多个列上建一棵B树排序规则是先按第一个列排第一列相等时按第二列排第二列也相等时再按第三列排。这跟我们整理Excel表时的“多级排序”一模一样先按部门排部门相同再按入职时间排。比如在(name, age)上建联合索引那索引树的叶子节点大概长这样(Alice, 18)(Alice, 25)(Bob, 22)注意它是“整条记录”作为排序单元而不是把name和age分开各建一棵树。所以如果单独查询age比如WHERE age 22这棵索引树帮不上什么忙——因为age在name之后树的全局顺序是先按name排的age的“局部有序”只有在name确定的前提条件下才有意义。这就是最左前缀原则的根源。它不是一个凭空规定而是B树排序方式自然推导出的结论。只有从联合索引的第一列开始连续匹配索引才可能被高效利用。比如WHERE name Alice可以用索引WHERE name Alice AND age 25也可以用但WHERE age 25就没法用这棵索引树做精确检索。2.2 等值匹配中的“连续匹配”边界很多人把最左前缀理解成“查询条件里必须包含第一个列”这个理解不完整。实际上条件包含第一列是必要条件但不是充分条件——中间不能断。假设索引是(a, b, c)a 1→ 可用索引a 1 AND b 2→ 可用索引a 1 AND b 2 AND c 3→ 可用索引a 1 AND c 3→ 只能用到a这一列c无法走索引因为b断了b 2 AND c 3→ 一列都用不上索引直接失效。这里有个容易被忽略的细节如果是范围匹配比如a 1 AND b 10 AND c 3那么c同样是无法走到索引的。因为b使用了范围匹配b之后的索引序就不再保证对c有序了。范围条件后面的列在过滤时只能作为回表后的普通过滤条件而不能作为索引上的精确定位条件。这是面试里非常高频的考点也是实际慢查询优化中经常踩的坑。2.3 联合索引的列顺序决策区分度优先还是查询频次优先联合索引的列顺序没有绝对公式但有一个核心原则值得记牢把等值匹配、区分度高、查询频繁的列放在前面把范围匹配的列放在后面。原因很简单等值匹配的列放在前面可以最大程度利用索引的有序性来过滤数据减少回表次数区分度高的列放前面可以让B树的每条路径更快收敛到少量记录减小扫描范围范围条件放后面是为了避免范围条件“切断”后面列利用索引的可能性。举个实际例子。订单表里最常用的查询是WHERE status ? AND create_time BETWEEN ? AND ?如果你建的是(create_time, status)联合索引虽然单独查时间范围也可以但status的过滤完全没用到索引而改成(status, create_time)之后先通过status把扫描范围压缩到极小再在索引上直接按时间范围取记录性能往往能有数量级的提升。我在业务上经常看到这类“建了索引但不贴合查询模式”的情况索引建了等于白建。3. 二级索引回表一次“多余”的磁盘IO是怎么省掉的3.1 回表到底做了什么回到最前面那个类比。二级索引的叶子节点存储的是“索引列的值 主键值”。当你执行WHERE name Alice而name上有普通索引时InnoDB先在二级索引B树中找到匹配的叶子节点拿到对应的主键值然后带着这个主键值回头再去聚簇索引里查一次完整数据记录。这个过程就是回表。回表不是每一次都能感知到代价但数据量大时非常明显。比如用户表里有500万行查询WHERE nickname 某用户命中了5000条记录——如果nickname上只有普通索引这5000条记录全部需要回表。假设每条记录的聚簇索引查找都要1次磁盘IO那就是5000次随机IO。磁盘随机IO一次大概是10毫秒左右量级这就意味着仅回表就可能耗费50秒。实际不会每次都真走磁盘因为Buffer Pool会把热页缓存住但规律不变回表次数越多查询越慢。3.2 覆盖索引让查询在索引树上“就地解决”如果能做到“查询所需要的所有列都包含在二级索引里”那么InnoDB查到二级索引叶子节点时就已经拿到了所有需要的数据根本不需要回表。这就是覆盖索引。实际写法是在查询列和索引列之间做匹配。比如索引是(name, age)SELECT name, age FROM user WHERE name Alice;这个查询里需要的就是name和age而索引恰好都有于是执行计划里会出现Using index表示没有回表。但如果你写SELECT * FROM user WHERE name Alice那索引里还缺其他字段比如phone、address就必须回表。所以实际做慢查询优化时经常用“覆盖索引”的思路去消灭回表。比如一个列表页需要查用户ID和昵称那就建(某查询条件列, id, nickname)这样的联合索引让这个高频查询完全在索引层完成不回表。代价是索引体积变大、写入变慢所以覆盖不是越多越好要挑高频且列数可控的查询来做。3.3 普通索引和唯一索引在回表上的区别还有一个很多人没注意到的点普通二级索引允许重复值唯一索引不允许这事对回表扫描范围也有影响。唯一索引因为每一条索引记录都是唯一的查询等值时最多回表1次普通索引可能存在多条重复记录可能要回表多次。但在等值查询的时候MySQL的优化器对普通索引也会做优化。InnoDB在读到第一条满足条件的记录之后并不是立即返回而是继续往后扫描检查下一条记录是否还满足索引条件。对于普通索引如果下一条记录的索引键还是同一个值说明还有重复记录需要回表遇到第一个不同的键值就停止。这个“最后一次判断”在聚集索引上几乎测不出差别但理解原理后你就明白唯一索引在“去重”这个角度上对索引扫描范围是有帮助的建表时如果业务上确实能保证唯一就尽量加唯一约束别省。4. 索引下推MySQL偷偷帮你省掉的回表次数4.1 什么是索引下推它优化了哪个环节索引下推Index Condition PushdownICP是MySQL 5.6引入的优化很多用了多年MySQL的人对这个机制不太了解但它对联合索引的查询性能影响非常大。场景是这样的联合索引是(name, age)执行SELECT * FROM user WHERE name LIKE 张% AND age 25;在ICP之前MySQL的处理方式是在二级索引上只根据name LIKE 张%找到所有名字以“张”开头的记录拿到它们的主键然后一条条回表回表后在聚簇索引的完整数据里再判断age 25是否成立。如果“张”姓用户有1万条其中age等于25的只有100条那你就要为了这100条去回表1万次浪费非常严重。有了ICP之后MySQL在二级索引遍历过程中会先直接判断索引里的age字段是否满足 25不满足的记录直接跳过不进回表流程。最终可能只需要对那100条满足条件的记录回表。这个优化的本质是把“部分查询条件的过滤”从回表之后提前到索引扫描阶段。4.2 什么情况下ICP不生效ICP并不总是生效有几个条件需要确认。第一如果不是二级索引而是聚簇索引当然不存在“回表”的问题也就不需要ICP。第二如果查询用到的条件列中有不在索引里的列ICP无法过滤全部条件但凡是索引里有的列它仍然会尽量去过滤。第三某些特定的SQL写法下优化器可能不走ICP比如在索引列上使用了函数或隐式类型转换导致索引本身无法用于过滤。判断ICP有没有生效很简单EXPLAIN查看执行计划如果Extra列显示Using index condition说明下推已经生效。注意Using index condition和Using index是两回事后者表示完全用覆盖索引免去回表前者表示走了索引但是还需要回表只是回表前多做了一道索引层过滤。我在排查慢查询时看到Using index condition会很清楚这是一个“能吃上索引但没吃满还得部分回表”的状态。4.3 ICP的典型收益场景ICP收益最大的场景就是“联合索引中精确条件列在范围条件列之后”。比如SELECT * FROM t WHERE a 100 AND b 5;索引是(a, b)。如果没有ICPMySQL会先在索引上按a 100锁定一个范围然后对所有范围内的记录回表再在完整数据行上过滤b 5。如果a 100范围很大回表量会非常大。但有ICP之后b 5在索引层就过滤掉了大量不符合条件的记录回表量明显下降。这是我实际优化工作中非常高频的场景。很多同学看到(a, b)索引默认“范围查询a后面的b用不上索引”就直接放弃了。但加上ICP后情况没那么悲观——b确实不能参与索引键的“定位”但能在索引层“过滤”。这两者的差别在于定位是直接跳到目标位置过滤是扫描过程中丢弃不匹配的项。定位效率更高但过滤也远比回表后再过滤要好得多。5. 排序、分组和索引顺序之间那些容易忽略的关系5.1 联合索引能帮ORDER BY省掉一次filesort索引本身就是有序的这一点在排序场景中有天然优势。如果查询条件和排序条件都能和联合索引的顺序匹配上MySQL可以直接按索引序扫描数据得到的结果已经是有序的不需要额外做文件排序。举个例子索引是(category_id, create_time)SELECT * FROM article WHERE category_id 10 ORDER BY create_time DESC;由于二级索引先按category_id排序相同category_id内部再按create_time排序所以查出来天然有序。MySQL只需要倒序扫描这段索引注意B树叶子节点之间是双向链表连接的既可以正向遍历也可以反向遍历不需要filesort。但如果排序条件和索引顺序不匹配比如下面这个查询SELECT * FROM article WHERE category_id 10 ORDER BY title;title不在索引里MySQL必须把符合条件的数据全部取出来放进sort buffer里按title排序。如果数据量大到超过sort buffer阈值默认256KB还会把中间结果写到磁盘临时文件里性能断崖式下降。这个“filesort”是慢查询里最常见的隐患之一。5.2 ORDER BY多个字段时的方向问题联合索引排序还有一个很隐蔽的坑排序方向不一致。比如索引是(a, b)索引默认是a ASC, b ASC。如果你的SQL是SELECT * FROM t WHERE a 1 ORDER BY b DESC;没有问题因为在a确定的前提下b不管正序倒序MySQL都可以通过反向扫描索引来满足。但如果SQL变成SELECT * FROM t ORDER BY a ASC, b DESC;这就麻烦了索引是“a升序、b升序”无法通过直接正序或倒序遍历同时满足“a升序但b降序”的要求MySQL只能先取出数据然后重新排序。遇到这种场景要么调整SQL排序方向要么在建索引时显式指定列的顺序方向。MySQL 8.0支持降序索引可以在建索引时写成(a ASC, b DESC)但这个问题在8.0之前的版本里相当无解只能尽量调整查询方式。5.3 GROUP BY也能吃索引但要小心这个前提GROUP BY本质上也是先做排序再聚合的所以联合索引的有序性同样对GROUP BY有帮助。比如SELECT category_id, COUNT(*) FROM article GROUP BY category_id;如果category_id上有索引MySQL可以直接扫描索引并逐段统计不需要临时表和filesort。但是如果GROUP BY的列和WHERE条件里用的索引列顺序不匹配就可能出现“临时表filesort”的慢查询。我在优化这类SQL时习惯用EXPLAIN看Extra列如果出现Using temporary或者Using filesort就说明GROUP BY相关的列顺序和索引顺序不匹配。调整的方向要么改索引列顺序要么改SQL写法比如先让WHERE条件把数据圈定在一个小范围内再做GROUP BY避免大范围排序聚合。6. 二级索引更新时的锁顺序容易被忽略的交叉风险6.1 先锁二级索引再回表锁主键在InnoDB里一条SQL如果通过二级索引定位到了记录更新时加锁的顺序并不是一句“先锁主键”就能概括的。实际流程是先在二级索引上定位到对应的索引项并给这个索引项加锁然后回表到聚簇索引给对应的主键记录加锁再执行更新。注意二级索引项和聚簇索引记录都需要加锁且加锁顺序是先二级索引、后主键聚簇索引。这在单线程里毫无问题但在并发环境下会引发一个非常隐蔽的问题当多个事务各自通过不同的二级索引更新不同的记录但涉及的主键记录存在交叉关系时就可能出现死锁。6.2 “时间窗口”如何形成交叉死锁举一个具体的例子。假设表结构里联合索引是(a, b)有两行数据行1a1, b1, id100行2a2, b1, id200事务A执行UPDATE t SET ... WHERE a 1 AND b 1;事务B执行UPDATE t SET ... WHERE a 2 AND b 1;看起来它们各更新各的记录因为二级索引键不同B树定位到的是不同的索引项互不冲突。但问题出现在回表之后如果行1和行2位于同一个数据页上或者存在某种间隙锁/插入意向锁的交集两个事务在锁主键记录、锁间隙时可能形成交叉等待。具体到“时间窗口”这个词事务A先锁了二级索引项1,1回表去锁主键id100事务B先锁了二级索引项2,1回表去锁主键id200。如果此时事务A在等事务B持有某个锁资源比如同一个间隙上的锁而事务B又在等事务A持有的锁资源就死锁了。6.3 怎么降低这类死锁概率这是个非常实际的高并发问题我分享几个实测有效的思路尽量让更新通过主键直接定位。UPDATE ... WHERE id ?直接走聚簇索引根本不会经过二级索引也就规避了“先锁二级索引再回表锁主键”的交叉路径。业务上统一加锁顺序。如果需要先通过二级索引查出主键再按主键更新那就在代码里把所有主键排序后再去更新保证加锁顺序一致。缩小事务范围。锁的持有时间越短交叉等待的机会越小。把大事务拆小减少同时持有的锁数量。开启死锁检测并做好重试。InnoDB默认会检测死锁一旦检测到会让其中一个事务回滚并释放锁。应用层要针对Deadlock found错误做好重试逻辑。这个点平时做业务开发的人很少关注但一到秒杀、库存扣减这类高并发更新场景就特别容易遇到。我踩过一次之后每次设计更新链路都会先画一遍“加锁顺序图”很多潜在问题在写SQL阶段就能暴露出来。7. 索引失效现场常见的几个“用不上索引”的写法7.1 隐式类型转换最常见的索引失效原因之一就是字段类型和查询条件的类型不一致。比如手机号字段是varchar类型查询时忘了加引号SELECT * FROM user WHERE phone 13800138000;MySQL会把phone字段隐式转成数值型再比较等于在索引列上做了函数运算索引自然失效。解决办法有两个一是SQL写成字符串字面量WHERE phone 13800138000二是在设计表时合理选择字段类型本身能用bigint存纯数字标识符就别用varchar。这类问题在EXPLAIN里最直接的标志是type列从ref或const退化成ALL同时rows估算值非常大。有时候优化器也会给出typeindex这种“看似用了索引实际是扫全索引”的结果都是需要警惕的信号。7.2 对索引列使用函数或运算在索引列上做计算是另一大类失效场景SELECT * FROM order WHERE DATE(create_time) 2024-01-01;这个SQL没法走create_time上的索引因为每一行都要先执行DATE()函数结果无法直接和B树的有序键值比对。正确写法是把查询条件转化成范围SELECT * FROM order WHERE create_time 2024-01-01 AND create_time 2024-01-02;同样道理WHERE id 1 10这种写法也会让索引失效。虽然优化器在某些特殊场景下可能会做等价改写但在MySQL里总体还是保守的建议开发阶段就养成“不在索引列上做运算”的习惯。7.3 左模糊匹配和OR条件LIKE %关键词和LIKE %关键词%都走不了索引因为B树只能按前缀有序无法利用一个“后缀或中间”的模糊匹配。如果业务确实需要这种搜索建议引入全文索引或外部检索引擎。OR条件也有坑WHERE name Alice OR age 25哪怕name有索引、age有索引MySQL也可能不走索引。因为OR要同时满足两个条件优化器可能选择全表扫描来避免多次索引扫描后合并结果集的成本。一个常见做法是把OR改写成UNION ALL让两个条件各自走索引再合并另一个做法是保证OR涉及的每个条件列上都有合适的索引让优化器有底气走索引合并。7.4 联合索引里的“范围中断”再提醒前面已经讲过范围条件后面的列无法参与索引定位这里再给一个实际SQL帮助记忆。索引是(status, create_time)SELECT * FROM order WHERE status PAID AND create_time 2024-01-01;这个查询里status等值匹配create_time范围匹配索引使用完美。但改成SELECT * FROM order WHERE status 0 AND create_time 2024-01-01;status变成范围匹配后create_time就没法继续在索引上定位了。虽然ICP可能帮助做部分过滤但是从执行计划上看扫过的索引范围明显变大。所以在设计联合索引时第一列尽量选等值查询最高频的字段是有充分理由的。8. 实际优化案例从distinct慢查询到联合索引重建8.1 问题现场有一张订单明细表量级在2000万行左右。业务方反馈一个报表查询特别慢执行时间在4秒左右SQL长这样SELECT store_id, COUNT(DISTINCT user_id) AS uv FROM order_detail WHERE created_at 2024-01-01 AND created_at 2024-02-01 GROUP BY store_id;表上原有的索引是(created_at)单列索引而且统计口径是30天跨度的数据命中的行数有800多万。执行计划显示typerangerows800万Extra列里出现了Using temporary; Using filesort——因为按store_id分组时需要临时表做去重排序。8.2 优化思路和索引调整第一反应是增加覆盖索引让查询完全在索引里完成不回表。但问题在于COUNT(DISTINCT user_id)涉及user_idGROUP BY store_id涉及store_id还有where条件里的created_at。需要让联合索引同时覆盖三个关键列。我最终调整成联合索引(store_id, created_at, user_id)并做了两个层面的验证。第一层WHERE created_at BETWEEN ...是不是会被store_id破坏不联合索引第一列是store_idcreated_at作为第二列因为where里没有store_id条件created_at在索引里不是按全表维度有序的而是按store_id分段后再按created_at有序。所以这段查询确实无法直接利用created_at做范围定位。这里就要小心了。我意识到直接改成(store_id, created_at, user_id)虽然能避免回表但在30天跨度的查询里等于要扫描整个索引树扫描范围不一定比原来小。于是改成第二方案把where条件拆分。查询里created_at是范围条件store_id是高区分度的等值字段。最合理的联合索引应该把store_id放前面created_at放后面但要让30天范围的数据尽早收窄可以再加一个条件把created_at的BETWEEN拆成多个更细的时间段或者按store_id分批查询减少单次扫描的数据量。最终采用的说法是对高频的“某门店某天”查询建立(store_id, created_at)作为核心索引对报表型大跨度查询额外增加覆盖列user_id。实际报表SQL改成按store_id分批跑比如每次只查一个门店再把结果汇总。改造后单次查询耗时从4秒降到200毫秒以内整体报表生成时间降到原来的1/10不到。8.3 这个案例带来的几点启示第一索引设计不能只看一个查询条件要同时考虑where、group by、select列三者的组合。覆盖索引能消灭回表但联合索引的列顺序要真正贴合扫描路径。第二大跨度范围的报表查询单纯靠一个联合索引很难优雅解决业务侧分批查询往往更有效。第三EXPLAIN里看到Using temporary; Using filesort基本都能成为一条索引调整的明确信号不要忽略它对性能的暗示。9. 快速参考索引设计自查清单这里把我平时做索引方案设计时都会过一遍的检查点整理成清单每一步背后都有对应的原理支撑。检查项对应原理经验判断主键是否自增整型聚簇索引的物理顺序是则插入性能最佳业务主键是否可能是UUID随机主键导致页分裂考虑趋势递增或二进制存储联合索引首列是否高频查询条件最左前缀原则首列必须是等值查询频率最高者联合索引是否包含了范围列范围条件中断后续列的定位把范围列放到联合索引最后查询列是否都被索引覆盖覆盖索引免回表高频查询尽量做成覆盖索引WHERE条件是否对索引列做了函数运算索引列运算导致失效改写为范围条件或其他写法字段类型和查询值类型是否一致隐式转换导致索引失效保持类型匹配加引号排序方向和索引顺序是否一致索引有序性和filesort不一致时考虑降序索引或调整SQLGROUP BY列和索引顺序是否匹配临时表filesort成本让索引键顺序贴近group by顺序并发更新是否可能交叉锁二级索引先锁再回表锁主键尽量主键定位统一加锁顺序控制事务大小这套清单我每次做性能优化评审都会对照一遍。大部分慢查询问题其实都可以归因到清单中的某一条或某几条。结语索引优化的本质其实就是搞清楚“数据是怎么组织的查询是怎么走路的”。主键索引决定数据物理排列联合索引决定检索路径和过滤能力二级索引回表和覆盖索引决定一次查询到底要付出多少磁盘IO和锁成本。我个人在工作里体会最深的一点是不要在应用层把索引当成“加了就快”的工具而是要在写SQL的时候就意识到每一个查询条件、排序字段、分组字段都会影响MySQL能不能按住逻辑去利用B树的结构优势。多花几分钟用EXPLAIN确认执行计划比上线后再排查慢查询要省力得多。这套基础原理吃透了不管换什么版本、什么存储引擎排查问题的思路都是通的。