1. 为什么索引优化能产生百倍差异
先把结论摆在这儿:索引没有用对,和用对,区别就是这么大。我接手过一套电商订单查询系统,核心订单表数据量在 5000 万行左右,原本根据用户手机号查最近三个月订单要花 2.1 秒,优化完索引结构之后直接掉到 18 毫秒。这中间相差了不止 100 倍,但既没加缓存也没改架构,就只是动了几个索引。很多人一开始不信,等看完 explain 的结果和实际的 trace 数据,才意识到问题从来不在数据库本身,而在索引设计上。
MySQL 的索引底层是 B+ 树,数据在叶子节点上有序排列,这个结构决定了索引能大幅减少扫描行数。但索引不是建得越多越好,也不是只要 WHERE 条件里的字段都建上索引就完事。真正的核心是理解 MySQL 的索引选择逻辑、复合索引的匹配规则、回表开销,以及索引在排序、分组、覆盖查询这些场景里能额外扮演的角色。5000 万行的表,没有索引时数据库只能老老实实地全表扫描,每行都要读一遍并做一次条件判断;而有了合适的索引,走 B+ 树查找一条路径就能定位到目标范围,IO 次数从几十万次降到几次,这个差距自然就有几个数量级。
这篇文章我准备把 5 个真正经过生产环境验证的技巧完整拆开,每一个都配套讲清楚适用场景、底层原理以及实操中的验证方法。适合已经熟悉基础 SQL,但面对慢查询日志和 explain 输出不知如何下手的开发者阅读,也适合那种"索引建了很多但查询还是慢"的情况。我会用一张实际的表结构贯穿全文,把从问题定位、索引设计到最终验证的整个流程走一遍,你照着操作基本就能在自己的库里复现同样的效果。
注意:文中所有 SQL 均在 MySQL 8.0 环境下验证,存储引擎为 InnoDB,部署在标准云主机上。不同版本和配置下执行计划可能有差异,但底层逻辑是一样的。
2. 五个核心技巧逐个拆解
这个部分我会按照从索引设计到查询语句优化的顺序来讲,因为这五个技巧环环相扣。先掌握复合索引的匹配规则,才能理解覆盖索引的价值;理解了覆盖索引,也就知道为什么函数操作会毁掉索引。它们之间的关联非常紧密。
2.1 技巧一:正确构造复合索引,吃透最左前缀原则
我在实际生产环境里遇到最多的问题,不是该不该建索引,而是索引字段怎么排列。
假设有下面这张订单表(经过抽象处理后的结构):
CREATE TABLE `order_info` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(64) NOT NULL COMMENT '订单编号', `user_phone` varchar(20) NOT NULL COMMENT '用户手机号', `order_status` tinyint NOT NULL COMMENT '订单状态', `order_amount` decimal(10,2) NOT NULL COMMENT '订单金额', `channel` varchar(10) DEFAULT NULL COMMENT '渠道来源', `created_at` datetime NOT NULL COMMENT '创建时间', `updated_at` datetime NOT NULL COMMENT '更新时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB;业务上有这样一个高频查询:根据用户手机号查某时间范围内创建的订单,并按时间倒序排列。对应的 SQL 是:
SELECT * FROM order_info WHERE user_phone = '13800001111' AND created_at >= '2024-01-01' AND created_at < '2024-04-01' ORDER BY created_at DESC;第一反应可能是给 user_phone 和 created_at 分别建两个单列索引。这么做不是不行,但 MySQL 一个查询通常只能利用一个索引,优化器会在两个索引里选一个区分度更高的走,另一个字段就只能作为回表后的过滤条件。结果是 user_phone 定位到几千行,再在这几千行里过滤时间并排序,效率差强人意。
正确做法是建一个复合索引,而且字段顺序有讲究:
ALTER TABLE order_info ADD INDEX idx_user_created (user_phone, created_at);为什么要把 user_phone 放前面?因为最左前缀原则。MySQL 的复合索引从最左边的列开始按顺序匹配查询条件,只有匹配到左边字段之后才能利用索引里右侧字段的有序性。user_phone 在这里是等值匹配(=),created_at 是范围匹配,把等值判断的字段放最左边,范围判断的字段放第二位,这样索引既能快速缩小到某个用户的订单集合,又能在创建时间字段上有序,ORDER BY created_at 也能直接用到索引顺序而不用单独 filesort。
如果反过来建(created_at, user_phone),created_at 范围匹配只能用到索引的第一层,user_phone 的过滤效率就体现不出来了。这个顺序问题是我见过最容易被忽略、但影响最大的细节。用 explain 验证一下优化后的执行计划,你会发现 key 字段指向了 idx_user_created,type 是 range,rows 的估算值缩小到几百行以内,Extra 里也没有 Using filesort 了。
提示:复合索引里最左前缀原则意味着索引
(a, b, c)实际上能支持(a)、(a, b)、(a, b, c)三种组合的查询,但(b)或(b, c)就完全用不上这个索引。设计时先列出业务里真实的查询模式,再决定字段顺序,不要贪多。
2.2 技巧二:用覆盖索引消除回表开销
InnoDB 的普通索引(二级索引)叶子节点存的是索引列和主键值。当你执行SELECT *时,通过二级索引找到主键值后,还要再用主键去聚簇索引里查完整行数据,这个动作叫回表。回表次数多了,IO 开销就上来了,尤其是每次都是随机 IO 的时候,性能损耗非常明显。
覆盖索引的意思是:查询所需的全部列都在索引里,不需要回表。MySQL 的 explain 中,如果 Extra 列显示Using index,说明这个查询就是靠覆盖索引完成的,不用再回表拿数据。
接着上面的例子,如果业务上只需要查手机号、时间和订单状态,那可以建一个更紧凑的覆盖索引:
ALTER TABLE order_info ADD INDEX idx_cover (user_phone, created_at, order_status);清空原索引后单独保留这个覆盖索引,再执行:
SELECT user_phone, created_at, order_status FROM order_info WHERE user_phone = '13800001111' AND created_at >= '2024-01-01' AND created_at < '2024-04-01';explain 输出里 Extra 列会显示Using index,说明没有回表操作。
这里有一个容易踩的坑:很多开发者图方便,直接用SELECT *,导致覆盖索引永远无法生效。你换了字段更少的覆盖索引,数据库也识别不出来,还是要老老实实回表取全行数据。处理思路是:先把高频查询里真正需要的字段列出来,针对这组字段建覆盖索引,而不是指望一个索引包打天下。
覆盖索引对分页查询的提升也很明显。一个大表的深度分页,比如LIMIT 10000, 20,如果用普通方式,MySQL 要先扫 10020 行,再把前 10000 行扔掉。利用覆盖索引先查询出主键,再通过主键关联回原表获取完整数据,可以少走很多无效 IO:
SELECT * FROM order_info o INNER JOIN ( SELECT id FROM order_info WHERE user_phone = '13800001111' ORDER BY created_at DESC LIMIT 10000, 20 ) t ON o.id = t.id;子查询里的 id 查询在覆盖索引上完成,外层再按主键取数据,实测下来效率比直接分页高出很多。
注意:覆盖索引的字段一旦多了,索引本身占用的空间也变大,写入时的维护成本增高。所以不要一股脑把所有字段都塞进去,只覆盖那种高频且字段固定的小查询即可。
2.3 技巧三:范围查询之后不要跟其他过滤条件
先说结论:复合索引里范围查询后面的列无法继续用于索引定位,只作为回表后的额外过滤条件。
有一个实际案例特别典型。一张订单表上有索引(user_phone, created_at, order_status),一条 SQL 是:
SELECT * FROM order_info WHERE user_phone = '13800001111' AND created_at >= '2024-01-01' AND order_status = 1;你可能会觉得这索引三个字段都用上了,但 explain 一看,key_len 只用到 user_phone 和 created_at 的长度,order_status 完全没参与索引定位。为什么要这样设计?因为 B+ 树索引的叶子节点上,数据是先按 user_phone 排序,再按 created_at 排序,最后按 order_status 排序。created_at 一旦变成范围查询,order_status 字段在索引中的顺序就已经无法保证对定位有帮助了——你只能从 created_at 范围开始一个个扫描,order_status 的判断只能等回表后拿到的行数据再做。
这种情况下,如果业务经常按(user_phone, created_at, order_status)这个组合查询,就需要重新评估字段顺序——把 order_status 等值查询放到前面,created_at 范围查询放到后面。比如调整成(user_phone, order_status, created_at),这样前两个是等值匹配,定位到非常精确的小范围,created_at 再用索引内的有序性做范围过滤。
这个调整在我们自己的某日志分析系统里效果很明显:同一时间段内,调整索引顺序后扫描行数从 1200 万降到了 400 万。虽然是额外的数据库扫描,但配合后续的覆盖索引优化,整体查询时间减少了 60% 以上。
2.4 技巧四:避开表达式和隐式类型转换
写了索引但不生效,也是排查慢查询时的高频问题。
常见的索引失效场景是条件里套了函数。MySQL 对索引列做函数处理后,B+ 树的有序性就没法直接利用了。比如:
SELECT * FROM order_info WHERE DATE_FORMAT(created_at, '%Y-%m-%d') = '2024-03-15';这相当于在 created_at 上套了函数。索引里的节点存储的是完整的 datetime 值,无法直接按格式化后的字符串做二叉查找,只能把索引列全部取出来转换后再比较——索引就废了。
正确写法是直接给范围条件:
SELECT * FROM order_info WHERE created_at >= '2024-03-15 00:00:00' AND created_at < '2024-03-16 00:00:00';这个写法让索引可以直接用区间定位,不需要对每个值做函数计算。
另一个容易被忽略的是隐式类型转换。比如 user_phone 是 varchar 类型,查询时却写:
SELECT * FROM order_info WHERE user_phone = 13800001111;MySQL 会把字符串列转成数字再比较,这种情况下索引同样失效。正确的做法是查询参数类型统一用字符串。我排查过某订单系统的实际问题,就是接口层把手机号参数解析成 int 后又传给了数据库,导致整个查询变成全表扫描,应用层标称 50ms 响应,实际数据库查询耗时超过 2 秒。接口调用方查了半天都没意识到是类型不匹配造成的索引失效。
平时写完 SQL,最好习惯性 explain 一下,看 type 字段:如果是ALL说明走全表,const、ref、range才是走了索引。一道简单防线就能拦住一大批低级问题。
2.5 技巧五:区分度低的选择性索引该怎么处理
有些开发者在状态字段上建索引,比如 order_status 只有 0、1、2、3 四个值,然后发现查询并没有变快,数据量大时甚至比全表还慢。原因是这字段区分度太低,5000 万行的表,每个状态值对应一千多万行,索引定位命中范围太大,最终大量扫描和回表,效果自然不如全表扫描,优化器可能直接就弃用了这个索引。
真正有效的是建复合索引或前缀索引。如果业务里要根据用户手机号和状态同时查询,把状态往前挪到复合索引里,等值匹配顺着索引走到状态那一步时,范围就缩到某个用户下的几条或几十条记录,这时区分度低不低已经无所谓了。
再补充一个前缀索引的思路。比如某表有 url 字段,字段长度很长,直接建全文索引资源消耗大,区分度也不算高。这时可以统计字段不同值的前缀区分度,取一个安全的前缀长度:
SELECT COUNT(DISTINCT LEFT(url, 10)) AS c10, COUNT(DISTINCT LEFT(url, 15)) AS c15, COUNT(DISTINCT LEFT(url, 20)) AS c20;如果 c15 和原始值的 distinct 数量非常接近,就建(url(15))前缀索引。我在某内容管理系统的链接表上实测,url 字段原始长度 300 字节,前缀 20 字节时区分度已经达到 99% 以上,索引大小瞬间缩水 90% 多,查询性能反而更好了。
3. 实操验证:从定位慢查询到最终优化的完整流程
上面五个技巧分开看都能理解,但实际优化一个查询时,还需要一条完整的执行主线。我以某模拟项目 X 里的一个真实慢查询为例,把从发现问题到最终验证的流程完整演示一遍。这次优化围绕一张虚拟的订单流水表展开,虽然字段经过了简化,但结构贴近常见业务表,方法论可以直接迁移。
3.1 用慢查询日志和 explain 定位问题
生产库开启了慢查询日志,假设日志输出配置为超过 1 秒的记录。某天日志里发现这条 SQL 频繁出现:
SELECT * FROM order_info WHERE user_phone = '13911112222' ORDER BY created_at DESC LIMIT 20;订单表数据量 3000 万行,单个手机号的订单量通常在 500 到 1500 条之间。为了复现问题,先手动跑了一次这条 SQL,耗时约 1.6 秒。接着看 explain 输出:
id: 1 select_type: SIMPLE table: order_info type: ALL possible_keys: NULL key: NULL rows: 30000000 filtered: 0.01 Extra: Using where; Using filesort一眼能看到三个问题:
- type 是 ALL,说明完全没有用索引,全表扫描
- rows 估算值是 3000 万,这意味着每一行都被扫到了
- Extra 里显示 Using filesort,排序也没有走索引
为什么 user_phone 上的单列索引没用上?回查了一下表结构,发现 user_phone 的索引虽然存在,但字段类型是 varchar,而接口层传入的参数是 int,发生了隐式类型转换,索引直接失效。这就是前面说的技巧四的真实复现。
3.2 针对场景做索引结构调整
确认根因后做了两件事:
第一,把查询参数统一成字符串类型,解决了隐式类型转换问题。第二,审视查询场景,发现这个接口经常被点击,查询条件固定为"某用户最近创建的订单",于是把原来的单列索引升级为复合索引:
ALTER TABLE order_info DROP INDEX idx_phone; ALTER TABLE order_info ADD INDEX idx_phone_time (user_phone, created_at);这里排序字段 created_at 放第二位,可以在索引内部直接完成 ORDER BY created_at 的排序,不需要额外的 filesort。
再跑一次 explain:
id: 1 select_type: SIMPLE table: order_info type: ref possible_keys: idx_phone_time key: idx_phone_time ref: const rows: 800 Extra: Using index conditionrows 从 3000 万降到 800,Extra 里也不再出现 Using filesort 了。实际执行耗时从 1.6 秒降到 12 毫秒。这就是一个典型的"看起来只改了一点点,效果差了两个数量级"的案例。
3.3 用 optimizer trace 验证索引选择逻辑
explain 能看出最终执行计划,但有时候你还想知道 MySQL 为什么这么选。这时可以用 optimizer trace:
SET optimizer_trace = 'enabled=on'; SELECT * FROM order_info WHERE user_phone = '13911112222' ORDER BY created_at DESC LIMIT 20; SELECT * FROM INFORMATION_SCHEMA.OPTIMIZER_TRACE;在 trace 输出中,重点看rows_estimation部分,它会列出优化器对每个候选索引的扫描行数和成本估算。我们当时发现优化器在不同候选索引之间的成本对比非常接近,如果哪天数据分布变了,执行计划很可能跳变。为了锁定行为,才决定把多余的单列索引彻底删掉,只留复合索引。
排查慢查询时别只盯着一个执行计划,数据分布一变,优化器可能就改变选择。有条件的话,在生产压力的低峰期做一次 explain 对比,多跑几次看结果是否稳定。
3.4 压测对比:优化前后性能量化
我用一套压力测试工具,模拟了 200 个并发请求连续打 5 分钟,场景就是上面那条 SQL。优化前接口的 TP95 延迟为 3.2 秒,数据库连接池经常被打满,报错率约 4%。优化后同样压力下,TP95 延迟降到 45 毫秒,报错率为 0。数据库侧的 CPU 使用率也明显下降,说明扫描行数减少带来的收益是全方位的。
在本地环境做这个小规模对比也很有意义,不是为了得到绝对数字,而是相对对比时能清楚看出索引结构的影响。
4. 常见问题与排查技巧实录
实际操作中,总会遇到一些翻了文档也不一定能直接找到答案的情况。这里整理几个高频问题,并附上排查思路。
4.1 索引建了但没走,可能是什么原因
这种情况我遇到的理由主要有四种。第一种是字段类型隐式转换,上面已经提过,接口层传参类型和表字段类型不匹配,最常见。第二种是 LIKE 前缀通配,比如LIKE '%abc',这种写法无法用索引做前缀匹配。第三种是 OR 条件关联了非索引字段,比如WHERE user_phone = '...' OR order_status = 1,即使 user_phone 有索引,order_status 没有索引时,优化器也可能选全表扫描。第四种是数据分布极端的时候,优化器认为全表扫描成本反而更低,特别是在区分度低的情况下,它会直接跳过索引。
排查时可以同时看 explain 和 optimizer trace,重点比较全表扫描和索引扫描的成本估算值。有时候索引走了但很慢,也可能是因为选择性低导致回表过多,这会体现在Using index condition和大量回表 IO 上。
注意:不要手贱把
FORCE INDEX当作常规手段。数据分布变化后,强制走索引有可能会让性能更差,只能作为临时手段在确认根因前用一下。
4.2 联合索引字段顺序到底怎么定
核心口诀:等值在前,范围在后,排序字段按需放。先列出所有真实的高频查询模式,统计每个字段在 WHERE 里的出现频率和是否等值匹配,再决定顺序。举例来说:
- 查询条件:user_phone(等值)+ order_status(等值)+ created_at(范围)
- 建议索引:
(user_phone, order_status, created_at) - 如果还有 ORDER BY created_at,那么这个排列同样可以利用索引直接完成排序
字段顺序一旦定下来,尽量早点在测试环境验证,因为数据量不够大的时候 explain 的预估不一定准确,你压测出来的结论也可能有误导性。
4.3 分页查询越来越慢怎么办
深度分页的慢不是因为 LIMIT 本身可怕,而是要扫描和排序的行数太多。覆盖索引优化配合延迟关联是最常用的方案。先用覆盖索引查出目标主键列表,再回原表取数据,而不是直接从大表里 LIMIT 大偏移量。后面如果数据量继续涨,也可以考虑改造成基于游标的翻页,去掉 LIMIT 大偏移量。
我实践中还发现,排序字段没有进索引会造成 filesort,这是分页查询变慢的另一个隐形原因。设计索引时要把排序需求考虑进去,不要只盯着 WHERE 条件。
5. 后续扩展方向
索引优化做完之后,并不代表一劳永逸。生产环境的表持续写入,数据分布会不断变化,索引的使用情况也要定期复盘。
最直接的手段是启用 performance_schema 里的统计信息,定期查看未使用过的索引。那些建了但长期没有命中记录的索引,可以视为无用索引,占用写入空间和维护成本,该删就删。以下是常用的查询语句:
SELECT OBJECT_NAME AS table_name, INDEX_NAME, COUNT_STAR, COUNT_READ, COUNT_WRITE, SUM_TIMER_WAIT / 1000000000 AS total_wait_ms FROM performance_schema.table_io_waits_summary_by_index_usage WHERE OBJECT_SCHEMA = 'your_db' AND INDEX_NAME IS NOT NULL AND INDEX_NAME != 'PRIMARY' ORDER BY COUNT_READ ASC;如果某个索引的 COUNT_READ 长期为 0,就需要考虑是否还在被其他查询使用。确认无用后,在业务低峰期做删除。
另外,如果表的数据量级继续增长,复合索引的字段可能也需要调整。比如从 5000 万行涨到 5 亿行,原来区分度够用的字段可能就变成低区分度了,需要重新审视。通用的做法是建立一个周期性的慢查询巡检任务,实时抓取 new 的慢 SQL 并自动对比 explain。我在某数据平台实践过一套简单方案:每天凌晨把前一天慢查询日志导入分析表,批量跑一轮 explain,把执行计划变更的 SQL 挑出来推送给开发确认。这样等用户反馈慢之前,就已经把问题拦截住了。
最后提醒一点:索引不是银弹,不要以为把所有字段都加上覆盖索引就万事大吉。写入性能、磁盘空间、查询性能之间需要平衡。实际业务里 80% 的查询性能问题往往只是两三个核心高频查询导致的,把这几个查询对应的索引认真设计好,收益远大于追求全字段覆盖。我自己优化过的项目里,最快见效的就是先把慢查询日志按出现频率排序,集中火力解决前几个高频 SQL,比盲目优化边缘查询有效得多。