
平时帮人排查慢SQL、做索引评审我几乎每次都要讲一遍联合索引。这玩意儿说简单也简单——多列索引嘛但说复杂它绝对是MySQL进阶路上绕不过去的一道坎。面试必问、生产必用、踩坑必多。很多人建了联合索引查询还是慢以为是索引没用上其实多半是没搞懂索引的匹配规则和字段顺序怎么排。今天不聊虚的就结合实际场景把联合索引翻个底朝天最左前缀怎么理解、字段顺序怎么定、哪些写法会让索引失效、哪些问题能用覆盖索引直接绕过去最后再分享几个生产环境里价值很高的实践经验。不管你是刚接触索引的初级开发还是已经开始优化慢查询的进阶选手这篇都值得认真过一遍。1. 联合索引的核心机制B树里的“拼接键”1.1 联合索引到底存了什么很多人对联合索引的第一个误解是觉得它像多个单列索引平铺在那里查询时哪个字段匹配就命中哪个。完全不是这么回事。联合索引在物理存储上仍然是一棵B树只不过这棵树里的每个索引节点不是单一字段的值而是多个字段值拼接起来的复合键。MySQL会严格按照你定义索引时的字段顺序在每层比较时先比较第一个字段如果第一个字段相等再比较第二个字段以此类推。你可以把它类比成一本字典的附录索引先按拼音首字母排首字母相同的再按音节排音节还相同的再按声调排。只看得到三级的目录那就只能从首字母开始查直接按声调去翻字典是找不到位置的。具体到一条SQLCREATE TABLE user_order ( id INT NOT NULL AUTO_INCREMENT, user_id INT NOT NULL, order_no VARCHAR(32) NOT NULL, status TINYINT NOT NULL DEFAULT 1, create_time DATETIME NOT NULL, PRIMARY KEY (id), KEY idx_user_created (user_id, create_time) ) ENGINEInnoDB;idx_user_created这个联合索引的内部结构就是先按user_id排序user_id相同的记录内部再按create_time排序。所以查询条件如果同时给出user_id和create_timeMySQL能从索引树里精确锁定一个极小的数据范围但如果只给create_time条件MySQL面对这棵索引树基本是抓瞎的因为create_time在整个索引里是无序的——只有user_id相同时create_time才有序。1.2 最左前缀原则本质是索引树的排列方式决定的理解了这个存储结构最左前缀原则就不需要背了它就是B树复合键排列方式的直接推论。条件命中user_id可以利用联合索引定位。条件同时命中user_id和create_time可以完整利用索引。条件只命中create_time索引无效因为create_time列在索引树中的排序是“局部有序”全局无序。条件命中user_id和status没命中create_time只能用user_id走索引status条件无法通过索引精确定位只能把命中的user_id对应的所有记录回表后再用status过滤。从优化器角度看联合索引本质上是一种前缀索引序列idx(a,b,c)等效于存在idx(a)、idx(a,b)、idx(a,b,c)三棵前缀匹配的索引能力但不像三个单列索引那样有独立的三棵树它的存储开销只有一个。1.3 排序与分组字段是被很多人忽视的“隐式前缀”联合索引除了用来加速查询过滤还顺带解决了ORDER BY和GROUP BY的排序问题。原因还是那句话索引树本身已经按复合键排好序了。如果你的SQL是这样SELECT * FROM user_order WHERE user_id 100 ORDER BY create_time DESC;这条语句既能走联合索引过滤出user_id 100的记录又能直接利用索引里create_time的有序性省去一次额外的文件排序filesort。但如果你把排序字段放反了SELECT * FROM user_order WHERE user_id 100 ORDER BY order_no DESC;order_no没有参与索引MySQL只能先user_id过滤出来一批数据再做内存或磁盘排序。数据量小时无所谓几十万行以上差距就非常明显了。这个特性在实际优化里经常被忽略。很多慢SQL排查到最后问题不在WHERE条件上而在ORDER BY强制触发了 filesort。联合索引里每一层的连续有序性都是可以拿来免排序的资本关键看你会不会用。2. 联合索引的字段顺序设计从区分度到覆盖能力2.1 区分度高的字段该放前面吗网上流传一种说法联合索引要把区分度高的字段放前面。这话对但只说对了一半而且经常被人理解偏——在高区分度字段放前面之前你得先看查询条件里字段的使用频率。举个例子电商订单表user_id的区分度比status高出无数倍但实际业务里查询订单几乎总是先带上user_id很少只查status。那么idx(user_id, status)明显优于idx(status, user_id)。原因很简单索引最左边的字段必须能覆盖绝大多数高频查询的入口条件。如果一个字段区分度再高但查询里老是缺它那就指挥不动索引树。另一个容易踩的坑是前缀区分度的误判。假设你建了idx(a, b)a的区分度确实很高但如果查询条件永远是用a和c组合唯独不用b那么b在索引里其实只承担了排序职责过滤效果都被回表后丢了。这个时候把c和b互换位置配合a形成idx(a, c)让两个过滤条件都能走索引精确定位收益反而更大。我个人的排序逻辑一般是这样优先让“高频等值条件”占最左侧然后看范围字段把范围查询字段放到所有等值字段之后最后才考虑区分度。区分度是用来在多项候选方案里做取舍的附加指标不是第一决策要素。2.2 把范围字段放在等值字段后面范围查询在联合索引里是个分水岭。条件能精确定位到索引树上的一个点而、、BETWEEN、LIKE abc%这类条件定位到的是一段区间区间之后的索引列就无法继续精确定位了。举个例子idx(a, b, c)查询是WHERE a 1 AND b 100 AND c 5a用索引等值定位b用索引范围扫描c完全无法通过索引过滤只能在a1, b100的结果集里回表后过滤。这时候如果把索引调整成idx(a, c, b)WHERE a 1 AND c 5 AND b 100a等值、c等值、b范围三层全用上了索引性能差别很大。这个调整思路在索引评审时必须形成肌肉记忆所有等值条件排前面范围条件尽量往后放。有一个特殊情况要单独说如果范围字段后面还有等值字段而且这个范围条件的区分度极差比如status 1这种几乎覆盖全表的“伪范围条件”把它当等值字段用也不是不行优化器会根据实际估算选择是否继续走索引后续列。但常规经验不变范围字段后面的列索引基本就算“到此为止”了。2.3 覆盖索引联合索引的附加红利联合索引有一种高阶玩法叫覆盖索引Covering Index。如果查询要的列全部包含在某棵索引树里那MySQL连回表都省了直接在索引树上取完数据返回这种执行路径叫“Index Only Scan”。假设一个订单查询SELECT user_id, status, create_time FROM user_order WHERE user_id 100;表上联合索引是idx(user_id, status, create_time)查询涉及的三个字段全在索引树里那么MySQL读到索引叶子节点时发现数据已经齐全不需要拿主键回表查聚簇索引。可以理解为从“查目录还要翻正文”变成了“目录本身就把内容印全了”。这种优化对高并发查询的响应时间提升非常明显因为回表等于额外一次随机I/O能省则省。覆盖索引不能滥用因为索引字段越多写入成本越高。一般适合那些查询极其频繁、字段很少、且字段较短的表。如果你在索引里塞一个几百字节的TEXT前缀进去占用的存储和更新代价会高到不划算。3. 联合索引的失效场景与优化方案3.1 失效为什么总是发生在这些写SQL的细节里先给一份我在实际排查中用得很顺手的联合索引失效速查表方便你对照场景示例是否走索引原因等值匹配WHERE a 1 AND b 2完全走双重等值精确定位连续前缀WHERE a 1 AND b LIKE x%走但深度有限b范围后列失效断列条件WHERE a 1 AND c 3部分走a走索引c回表过滤右侧范围WHERE a 1 AND b 2部分走a范围后b失效列运算WHERE a 1 2失效索引列参与计算隐式转换WHERE a 2a是int可能失效类型转换破坏比较非前缀LIKEWHERE b LIKE %x失效无法利用索引有序性OR条件WHERE a 1 OR b 2大概率失效未分割为索引合并优化器可能全表扫LIKE问题值得展开讲一下。很多人以为LIKE %xxx走不了索引是因为模糊匹配其实根源还是索引树的有序性——%开头意味着目标串的起始位置不确定索引树里没有“前缀锚点”可查。但如果是LIKE xxx%锚点明确是第一列的话就能走索引范围扫描效果等同于 xxx AND xxy。3.2 函数操作与隐式转换防不胜防索引列上套函数比如WHERE DATE(create_time) 2024-01-01索引树里存的是原始的完整DATETIME值但查询却在一个被函数加工后的虚拟列上进行匹配索引自然无法直接寻址。正确写法是把函数挪到参数侧WHERE create_time 2024-01-01 AND create_time 2024-01-02隐式转换更阴险。比如索引字段是字符串类型查询条件却传了数字MySQL通常会把字符串转成数字再比较索引列本身等于做了函数变换破坏了B树的匹配基础。反过来索引字段是整数查询条件传了字符串很多情况反而能走索引因为转换发生在参数那一侧但依赖具体版本和优化器行为不要赌直接保持类型一致最稳妥。KEYidx_mobile(mobile)上的WHERE mobile 13800138000看着没问题但其实如果mobile列是VARCHAR这个查询大概率走不上索引。这种问题是慢SQL日志里最典型的“隐形杀手”排查时看到明明建了索引却不生效优先怀疑类型不一致。3.3 OR条件如何用索引合并救场联合索引遇到OR分裂是另一个大头。WHERE a 1 OR b 2在idx(a, b)上优化器通常不会直接把这个条件当成“两个索引列的条件组合”去走索引因为OR意味着左侧区间和右侧区间是并集B树只擅长范围连续扫描不擅长做两个不相邻区间的拼接。MySQL有下推的优化机制叫Index Merge索引合并它会尝试分别用a 1和b 2各扫一遍索引再取交集或并集。但这个优化能不能触发跟索引类型主键、唯一索引、普通索引、条件形态、优化器成本估算直接相关不可控因素很多。实际项目中我一般不指望Index Merge能用联合索引改写成UNION ALL场景的尽量改写改写不了的直接建好对应的单列索引让优化器自己决定。举一个实战中提到的经典问题——mysql的or能去重吗。答案是如果OR连接的是不同索引列的等值条件Index Merge交集模式Intersect本身会基于主键去重但如果是UNION则需要显式去重或依赖联合索引结构在合并后天然有序。总之从逻辑层面看OR不会因为“联合索引”自动具备去重语义去重靠的是主键唯一性和执行计划里的去重操作不要混为一谈。4. 联合索引与其他机制的互相影响回表、ICP与锁4.1 回表查询的成本到底高在哪InnoDB是聚簇索引表表数据按主键顺序物理存放。每棵二级索引包括联合索引的叶子节点存的是索引字段加主键值。当你用二级索引查数据时先在索引树里定位到符合条件的叶子节点拿到主键再根据主键回聚簇索引里捞整行数据这次回表就是一次新的B树搜索。有两类场景回表成本尤其夸张一是命中的记录本身就很多比如筛选出一个用户的大半年订单记录每条都要回表二是表很大而索引字段较短索引树里的主键值离聚簇索引根节点距离远随机I/O命中率低。缓解思路有三层利用覆盖索引把高频查询里需要的字段全部塞进联合索引直接从根上避开回表利用索引条件下推Index Condition PushdownICP让部分过滤操作在索引层完成减少回表次数实在躲不开就进一步压缩结果集比如分页、限定返回字段、减少不需要的大字段。4.2 ICP索引条件下推是怎么“白嫖”索引的ICP是MySQL 5.6引入的优化但很多人建了联合索引却不知道它的存在或者知道名字但不清楚它到底解决了什么问题。没有ICP时存储引擎用联合索引idx(a, b)定位到满足a的记录后必须把每条记录都回表再在服务层对b条件进行判断。有了ICP如果二级索引树里已经包含b字段那么引擎层在索引扫描过程中就可以先根据b条件再次过滤过滤剩下的记录才回表。说白了就是把一部分WHERE条件下推给存储引擎在索引层面尽量多筛掉点数据减少回表次数和传输量。ICP对联合索引的依赖比很多人想的更强它只有在索引里存在对应条件列的情况下才有发挥空间。平时排查执行计划时可以在Extra列里看到Using index condition这就是ICP生效的标志。如果你看到这个标识说明索引设计得不错能下推的过滤都下推了。4.3 联合索引与锁它还能决定锁的范围联合索引设计不当还会引发锁范围的扩大这一点在RRRepeatable Read可重复读隔离级别下尤其值得警惕。InnoDB的行锁是通过索引项来实现的锁定的是索引记录而不是物理行。一个更新语句如果走了idx(user_id, create_time)锁定目标记录那么锁就精确地落在这些索引项上如果没有合适的联合索引可用优化器可能退化为全表扫描InnoDB为了正确性会对所有扫描过的记录加锁结果就是本该只锁几行实际锁了一大片直接拖垮并发。删除时要确保条件命中联合索引最左前缀避免间隙锁Gap Lock被扩大成Next-Key Lock范围UPDATE的WHERE顺序尽量和联合索引字段顺序对齐减少优化器选择全表扫描锁全表的可能性大批量更新时除了关注索引匹配还要关注批量批次大小长事务加锁范围太大会加大死锁概率。锁问题比慢查询更隐蔽慢查询至少日志里有锁范围扩大往往表现为“时不时卡一下”或者干脆在SHOW ENGINE INNODB STATUS里看到一堆死锁回滚。做联合索引评审时建议同时评估每个DML语句的锁范围而不是只盯着SELECT的命中率。5. 联合索引的生产选型与案例复盘5.1 单列索引 vs 联合索引差的不只是性能很多团队成员习惯“哪个条件慢就加哪个单列索引”结果一张表堆了七八个单列索引。单列索引在B树里是一棵独立的树所以N个单列索引就有N棵索引树写入和更新都要维护。联合索引则是一棵树多列间共享结构存储和更新开销整体更低。更关键的是查询优化器在一个查询里最多只能真正“高效”使用有限数量的索引。如果你把user_id和create_time分别建单列索引一个WHERE user_id 1 AND create_time 2024-01-01查询理论上可以用索引合并但更多时候优化器只选择一个走然后另一个条件靠回表过滤。而联合索引直接让两个条件在索引内部完成协作这才是它的真正价值。对比项单列索引 A 单列索引 B联合索引 (A, B)WHERE A AND B可能选一个走Index MergeA、B同时走索引WHERE A ORDER BY B排序不能利用索引天然有序免filesortWHERE B可走单列索引B联合索引失效额外回表率较高可通过覆盖索引压到最低额外维护成本两棵树一棵树5.2 实操中如何基于慢SQL设计联合索引我在实际项目里总结出一套相对固定的设计流程分享出来直接对标使用第一步从慢查询日志、performance_schema、或者业务方反馈里拎出高频SQL整理成模板不要只看单个实例要看同一组字段在不同SQL里的出现规律。第二步把每个SQL的过滤条件按等值和范围拆开统计每条SQL里最常出现的等值列这些列大概率要进联合索引并且越靠左越好。第三步把ORDER BY和GROUP BY字段纳入排序规划尽量让查询结果按索引序直接输出避免filesort。这里有个技巧如果所有SQL里有一个稳定的排序字段可以把这个字段放在联合索引里所有等值条件的后面范围条件之前或之后要看实际。第四步检查是否可覆盖。高频的单表查询把 SELECT 的字段和 WHERE、ORDER BY 的字段放到一起构成联合索引尽量做到不回表。第五步用EXPLAIN验证观察key、ref、rows、Extra四个字段尤其注意Extra里有没有Using filesort和Using temporary这两个是性能衰减的核心信号。5.3 一个从AB优化到AC的真实案例有一回处理一个订单后台的慢查询SELECT id, order_no, status, create_time FROM order_table WHERE user_id 12345 ORDER BY order_no DESC;表上有索引idx(user_id, create_time)。SQL执行很慢慢在ORDER BY order_no触发了文件排序。因为order_no压根不在索引里MySQL取出user_id 12345的所有订单后在内存里重新排序数据量大时直接落磁盘临时文件那就不是慢几十毫秒的事了。调整方式是把索引从idx(user_id, create_time)改成idx(user_id, order_no, create_time)user_id等值过滤order_no提供排序省掉 filesortcreate_time作为附加字段顺带提高了覆盖能力因为SELECT里有create_time。调完以后慢查询消失Extra列从Using filesort变为Using index condition。有时候一条业务SQL的性能瓶颈并不在过滤条件而在排序联合索引的排列顺序就是为此服务的。排序字段往往被忽略因为它不会出现在WHERE里可它恰恰是拖慢性能的元凶之一。6. 联合索引的常见误区和排查手段6.1 这些劝退级误区别再犯误区一联合索引建得越多越好。真实情况是过量的联合索引会显著拖慢写入。因为每一棵树都要在插入时重新排序维护索引越多写放大越严重。生产环境的经验法则是单表单列索引加联合索引总数尽量控制在5个以内超出后要反复确认收益。误区二把最常用的查询字段都塞进联合索引就完事了。排列顺序不对收益打折甚至直接失效。比如idx(status, user_id)业务高频是WHERE user_id ... AND status ...这种组合虽然两个字段都在索引里但由于user_id不在最左user_id条件完全没法参与索引定位等于白建。误区三EXPLAIN显示用了索引就以为一切OK。实际上Extra里的Using where如果配合key出现往往意味着索引只过滤了一部分条件剩下的在回表时过滤。真正常见的典型是Using index condition这是ICP生效的积极信号但如果你看不到它反而总看到Using where就要反思索引字段的排列是否满足了所有过滤条件的可下推性。误区四只要是等值条件就随便摆。这里说的是范围等值的问题比如IN。IN在优化器看来是多个等值条件的集合某些场景下可以继续支持后续索引列的匹配但它跟纯还是有区别涉及大量IN列表值时优化器可能放弃索引后续列的精确匹配转而回到回表过滤。这个要靠EXPLAIN逐条验证不能一概而论。6.2 快速定位联合索引问题的方法清单先打开慢查询日志slow_query_log ONlong_query_time 1收集真实的慢SQL样本。对目标SQL执行EXPLAIN主要看type至少要达到ref或range、key_len、rows、Extra。key_len是判断联合索引到底用了几层字段的核心手段。比如idx(user_id, create_time)user_id是INT占4字节create_time是DATETIME占5字节MySQL 5.6行格式下如果key_len只有4说明只用了第一个字段。观察Extra里的Using filesort结合ORDER BY字段调整联合索引排列。用optimizer_trace查看优化器决策确定为什么没选更优的索引组合条件允许时可以直接在本地复现压测。6.3 线上索引变更的正确姿态联合索引设计的再好线上变更如果翻车一样欲哭无泪。大表加索引MySQL 8.0 虽然有在线DDL支持不像老版本动不动锁表但建索引过程依然会产生额外的IO负载和主从复制延迟。常规做法是选择业务低峰期变更并且在变更前后关注主从延迟监控。尤其要注意不要在已经有大索引的基础上反复叠加联合索引。太多时候线上已经存在一个相似的索引再加一个新的实际只是增加维护成本收益却约等于零。做变更前先用SHOW INDEX FROM 表名对照现有索引结构必要的时候直接删掉旧的冗余索引再建新索引。mysql 5.7之后的版本里ALTER TABLE ... ADD INDEX 多数情况可以在线完成但像是覆盖3-4个字段且表行数过亿时即使在低峰期也要有预案比如控制并发、关注磁盘IO、准备回滚SQL。索引设计这个东西快是快在索引命中坑是坑在变更流程两头都得拿捏住。7. 联合索引没有银弹结合数据特征做平衡聊到最后回到开头那句话联合索引既简单又复杂。简单在它的复合键结构本质上就是“拼接键 前缀匹配”复杂在各种查询模式、数据分布、优化器行为交织在一起很难有一套放之四海皆准的公式。有句话说得好索引是给SQL量身定制的不是一张表一种固定搭配。联合索引的设计必须围绕业务SQL模式展开脱离SQL模式的索引设计就算字段选得再对也是自嗨。最后分享一个我习惯用的检查思路每设计完一组联合索引我一定会拿着它跑两遍——第一遍是当前SQL模式的正向验证确认EXPLAIN各项指标符合预期第二遍是反向推演把自己当成那棵B树从最左字段开始逐个假设“如果我跳过这一列后面还能继续匹配吗”凡是推演不过去的场景要么修改索引排列要么给那个场景单独准备备选索引。实践出真知索引这件事尤其如此。你踩过的坑越多对“为什么联合索引最左是等值过滤、范围要后置、排序字段参与前缀、覆盖索引多一层保障”的理解就越深。真把这些问号都拉直了面试题也好线上慢查询也好处理起来心里就有底了。