
索引不是银弹但你永远绕不开它在你把SQL写到第200行的时候数据库突然卡住了。监控面板上慢查询数量飙升CPU负载打到80%DBA的钉钉消息弹了三次。你盯着那个跑了三秒的SELECT意识到问题出在WHERE条件里那个没有索引的字段。绝大多数后端开发者在职业生涯中都会撞上这堵墙区别只在于是主动撞上去的还是被产品经理逼着撞上去的。索引的本质是一种空间换时间的预排序结构。它把磁盘上的数据按照特定字段的排列顺序存成B树或哈希表让查询不用再全表扫描。但很多开发者对索引的理解停留在“加个索引就能快”的层面结果常常是加了索引反而更慢或者索引建了一堆但查询还是走全表。真相是索引不是越多越好索引设计必须和查询模式对齐。我们先从最底层讲起。B树索引之所以成为关系型数据库的默认选择是因为它能把随机I/O变成顺序I/O。当你在字段上建立B树索引时数据库会创建一个排序后的树形结构叶子节点存储指向实际数据的指针或聚簇索引下的实际数据。查询时从根节点开始二分查找经过三层到四层就能定位到目标数据页——这比全表扫描的线性复杂度快了好几个数量级。B树索引的查找复杂度是O(log n)而全表扫描是O(n)数据量越大差距越悬殊。但索引也有代价。每次INSERT、UPDATE、DELETE操作都会触发索引重建因为B树需要保持平衡。索引越多写操作的性能损耗越大。所以后端开发中一个常见的反模式是给每个字段都建上索引幻想所有查询都能一秒完成。结果往往是查询没快多少写入速度反而掉到脚踝。正确做法是只给核心查询路径上的字段建立索引并且监控慢查询日志来反向验证索引的有效性。联合索引最左前缀原则才是真正的关节多字段查询是后端业务里最常见的场景。比如电商系统的订单列表需要按“用户ID 下单时间”来排序和过滤或者内容平台需要“标签 创建时间”来分页。面对这种组合条件单独给每个字段建索引往往没有用因为数据库的查询优化器在每个表上只会选一个索引来走。两个单列索引分别建只能加速其中一个条件另一个条件依然要回表扫描。这时候需要联合索引也称为复合索引。联合索引在B树里先按第一个字段排序第一个字段相同时再按第二个字段排序以此类推。核心规则是最左前缀原则查询条件必须从联合索引的最左边开始匹配跳过或中断某个字段会导致索引失效。例如索引(col1, col2, col3)能支持(col1)、(col1, col2)、(col1, col2, col3)三种查询但无法支持(col2)或(col3)单独查询也无法支持(col1, col3)因为col2被跳过了。最左前缀原则是许多索引失效问题的根源。很多开发者在建联合索引时不注意字段的顺序导致某些高频查询无法使用索引。我的建议是把区分度最高、查询最频繁的字段放在联合索引最左边。比如订单表查询通常先用用户ID过滤再用时间排序那么联合索引应该是(user_id, create_time)。反过来如果先把时间放在左边查询用户订单时时间范围会被全扫索引就没用了。还有一个容易被忽略的点联合索引也能实现覆盖索引。如果查询的字段全部包含在索引的字段列表中数据库就不需要回表去主键索引里取数据直接从索引的叶子节点返回。这能减少一次磁盘I/O。所以在写SQL时尽量让select的列落在联合索引里比如索引(col1,col2)专门支持select col1,col2 ... where col1?的查询。查询优化不是所有索引都适合你的SQL有了索引还不够SQL的写法决定了索引能否被有效利用。我在代码评审中见过大量“有索引但用不上”的场景归类起来无非几种。第一种是对索引列进行了函数操作或隐式类型转换。比如where date(create_time) 2025-01-01数据库无法对create_time这个列进行B树范围查找因为函数改变了原有的顺序。更糟的是如果create_time字段是datetime类型而你传入了2025-01-01这种字符串某些数据库会做类型隐式转换同样导致索引失效。永远不要在索引列上使用函数或类型转换应该把值转换成列的类型比如where create_time 2025-01-01 and create_time 2025-01-02。第二种是LIKE查询以通配符开头。where name like %keyword%无法使用B树索引因为前导百分号导致无法确定起始位置。如果业务上必须支持模糊查询可以考虑全文索引FULLTEXT或外部搜索引擎Elasticsearch。以通配符开头的LIKE查询是索引杀手。第三种是范围查询后的字段顺序错误。联合索引中范围查询、、BETWEEN、LIKE非前导通配符之后的字段索引会失效。假设索引(a, b, c)如果where a 1 and b 10 and c 5那么只有a和b能用到索引c无法用到——因为b是范围匹配到b10的记录中c是无序的。设计联合索引时尽量把等值查询的字段放在前面范围查询的字段放在后面。第四种是OR条件导致索引合并失效。where a 1 or b 2这种写法如果a和b分别有独立索引某些数据库会尝试索引合并INDEX MERGE但性能通常不如用UNION改写。更好的做法是分析业务看能不能用IN或UNION ALL替代OR。OR条件通常意味着全表扫描除非数据库优化器能进行索引合并。回表与覆盖索引一次查询的旅程我们经常听到“避免回表”“用覆盖索引”这样的说法但真正理解回表开销的人不多。当数据库在二级索引非聚簇索引上找到满足条件的记录时二级索引的叶子节点存的是主键或行号。要拿到完整的行数据数据库必须拿着这个主键再去聚簇索引主键索引里取一次这一趟就是回表。回表意味着额外的磁盘I/O。如果二级索引能覆盖查询所需的所有字段数据库就省掉了这一步。所以一个常见的优化技巧是在二级索引中加入需要查询的字段使其变成覆盖索引。比如业务经常需要根据订单号查订单状态和创建时间那可以建一个索引(order_id, status, create_time)查询select status, create_time from orders where order_id ?就不用回表了。但覆盖索引也不是无代价的。索引字段越多占用的磁盘空间越大写入性能越差。需要根据实际查询的频繁程度来权衡。更重要的原则是尽量让查询走索引而非主键。因为主键索引的叶子节点包含整行数据扫描范围太宽。而二级索引的叶子节点只包含键值主键体积小B树层数可能更浅范围扫描更快。还有一个与回表强相关的因素是索引选择性Cardinality。索引选择性指的是索引列中不同值的数量与总行数的比值越高说明索引区分度越好。比如性别字段只有两个值选择性极低用它建索引扫描范围仍然很大半表扫描还不如全表扫带来顺序读的优势。低选择性的字段不适合建独立索引但可以放在联合索引的最左边作为等值过滤条件前提是过滤后数据量能大幅缩小。排序与分组索引如何帮你省掉临时文件ORDER BY和GROUP BY是后端API中常见的操作它们很容易导致额外的排序操作。如果排序字段没有索引数据库需要先把所有满足条件的数据加载到内存或临时磁盘进行排序这就是filesort。当数据量超过sort_buffer_size时还会产生临时文件性能急剧下降。索引天然是有序的。如果你查询的ORDER BY字段恰好是查询使用的索引的结尾字段那么数据库可以直接按索引顺序取数据完全不用额外的排序。这就是用索引消除filesort。例子索引(user_id, create_time)查询select from orders where user_id 1 order by create_time desc limit 20。因为索引已经是按user_id分组后按create_time有序的数据库从索引最后向前扫描20条即可无需排序。但如果查询是order by create_time, status而索引中status不在create_time之后那就无法利用有序性。GROUP BY本质上也是先排序再分组。所以和ORDER BY同理如果GROUP BY的字段恰好匹配某个索引的前缀也能利用索引避免临时表。一个常见优化是建立(GROUP BY字段, 聚合函数相关字段)的联合索引让数据库边扫描边聚合减少内存消耗。此外LIMIT OFFSET分页是大坑。传统分页select from table order by id limit 100000,20会扫到前100020行然后丢弃前10000行。避免的方法是基于游标的分页记住上一页最后一条的ID或其他唯一有序字段下一页用where id last_id order by id limit 20。这种写法能充分利用主键索引的顺序扫描没有offset浪费。慢查询的根因数据量和MySQL的索引选择即使索引都建对了SQL也写对了数据库有时还是会选择错误的执行计划。MySQL的查询优化器基于统计信息行数、选择度、索引深度来决定是走索引还是全表扫描。当优化器认为全表扫描更便宜时它会忽略索引。比如小表几百行全表扫描的随机I/O成本可能小于索引查找回表的成本。或者统计信息陈旧优化器以为全表更优。这时需要用ANALYZE TABLE更新统计信息或者用FORCE INDEX强制走索引。另一个常见场景是索引匹配行数过多。如果where条件返回的记录超过全表的20%~30%全表扫描的顺序I/O确实可能比索引的随机I/O更快。因为回表需要多次随机读取数据页而全表扫描可以预读。所以有时候“索引失效”其实是数据库的理性选择你反而应该放心。针对这种场景可以通过增加查询条件缩小范围或者用覆盖索引降低回表成本让优化器重新选择索引。比如请求改写成select id from table where ... 只返回索引中包含的列数据库发现不需要回表就可能选择索引扫描。还有一个影响索引选择的因素是数据排序与分组。MySQL在需要排序或分组时如果发现索引可以提供有序数据即使过滤条件较弱也可能走索引。这就是为什么有时候你只建了索引但没加到where上数据库依然用了它——它需要索引来避免排序。实战中的索引设计流程写到最后整理一套可以落地的流程。后端开发者在设计新表的索引时不要拍脑袋。先从业务中收集最频繁的查询模式通常是CRUD中最容易成为瓶颈的读操作。针对每个查询模式写出期望的SQL然后分析它的WHERE、JOIN、ORDER BY、GROUP BY、DISTINCT等子句。第二步为每个高频查询模式设计一个初步的联合索引。索引字段的顺序遵循“等值 排序 范围”的优先级。如果查询中既有等值又有范围把等值放前面。如果范围字段不止一个把范围最小的放前面能过滤出的行数少。第三步考虑覆盖索引。检查查询的SELECT列表能否被索引覆盖如果业务允许可以加入一些冗余字段来消除回表。但不要过度加字段控制索引的总字节数。第四步用EXPLAIN验证执行计划。特别关注type字段const eq_ref ref range index ALL。如果是ALL说明全表扫描你需要检查索引是否真的匹配了查询条件。Extra字段中如果有Using filesort、Using temporary、Using index condition等需要根据情况优化。Using index表示使用了覆盖索引这是最佳状态。Using index condition表示使用了索引下推ICP说明部分条件在存储引擎层过滤。第五步上线后开启慢查询日志持续监控。有些慢查询在少量数据时看不出来数据量积累到一定级别索引选择才会变差。定期用SHOW INDEX FROM table检查冗余索引比如存在两个前缀相似的联合索引保留更覆盖的那个。没有一成不变的数据库优化方案因为业务查模和数据分布会变。你昨天的金牌索引明天可能成为累赘。核心保持两个习惯写任何SQL之前先想索引会不会匹配上线前一定跑EXPLAIN。能够做到这两点数据库就很少会在半夜给你打电话。至于那些号称“MySQL调优32条军规”的营销文章看看就好。真正的优化从来不是公式堆砌而是一句朴实的话理解你的数据尊重索引的规则然后让查询尽量沿着索引的路径走。别做那个给100万行数据建了10个索引却写出一个全表扫SQL的后端开发者也别做那个看到慢查询就恐慌地到处加索引的运维逃兵。