作为一个常年跟MySQL打交道的人,我太清楚那种“一条SQL把线上服务拖垮”的体验了。前两天还帮朋友排查一个慢查询,单表才三百万行数据,一个普通的订单查询居然跑了八秒多,直接把接口拖到超时。打开慢日志一看,问题根本不复杂——索引建错了,WHERE条件里还藏着隐式类型转换,典型的低效写法。这种问题在开发环境根本看不出来,数据量一上来就原形毕露。
这也是我为什么一直强调:SQL优化不是DBA的专属工作,而是每个写SQL的人都该掌握的基本功。 你不需要成为执行计划专家,但至少要懂得怎么让一条查询走对索引、减少回表、降低扫描行数。这篇文章不聊虚的,直接从执行原理、索引设计、SQL写法、EXPLAIN解读、慢日志排查这五个维度,把查询效率提升的路径完整梳理一遍。
1. 慢SQL到底慢在哪:从全表扫描到索引选择的底层逻辑
1.1 一条查询的执行路径,卡点通常在三个环节
很多人一谈SQL优化就想到“加索引”,但加索引之前得先搞清楚一条SQL在MySQL里到底是怎么跑的。整个执行链路大致是:客户端发送SQL,解析器做语法解析,优化器生成执行计划,执行器调用存储引擎接口去读数据,最后返回结果。真正耗时的部分集中在两个地方:数据读取和数据排序/聚合。
数据读取的耗时取决于扫描了多少行、每次扫描是内存读还是磁盘读、有没有回表。排序和聚合则取决于临时表的创建、排序算法的选择、内存是否够用。这三个环节任何一个出问题,查询都会慢。举个最简单的例子:
sql复制SELECT * FROM orders WHERE user_id = 1024 ORDER BY create_time DESC LIMIT 20;
如果user_id没有索引,MySQL只能把整张表的每一行都读出来,逐行比对user_id,这个过程叫全表扫描。三百万行数据,哪怕全是内存读,也要耗费不少时间;如果数据量太大放不进innodb_buffer_pool,就会触发磁盘I/O,速度直接掉一个量级。
补齐索引之后,MySQL可以通过B+树结构快速定位到user_id = 1024的记录位置,扫描行数从三百万降到几十行,查询效率自然就上来了。这就是索引的核心价值——减少扫描行数。
1.2 为什么优化器不总走索引:成本估算不只看扫描行数
经常遇到这样的情况:明明建了索引,EXPLAIN一看,type还是ALL,走的是全表扫描。很多人第一反应是“MySQL傻了”,其实不然。优化器在生成执行计划时,依据的是成本模型,它会估算走索引的成本和走全表扫描的成本,最终选一个它认为更低的方案。
成本模型考虑的因素包括:扫描行数、回表次数、是否排序、是否产生临时表、内存排序还是磁盘排序等等。为什么有时候索引查询的估算成本反而更高?关键就在回表上。
二级索引的叶子节点存储的是主键值,不是完整行数据。MySQL通过二级索引找到主键后,还要再拿着主键去聚簇索引(主键索引)里查一次完整记录,这个过程就叫回表。如果查询的字段都在二级索引里(覆盖索引),就不需要回表;如果要查的字段不在索引里,每命中一条记录就要回表一次。回表意味着随机I/O,如果命中的行数非常多,比如占了全表的20%以上,优化器算下来发现回表成本太高,干脆选择全表扫描。
所以,索引不是建了就一定走,关键看优化器觉得划不划算。 理解了这一点,你就明白为什么很多时候“索引 + 覆盖”才能双剑合璧。
1.3 数据类型、字符集和排序规则对索引效率的隐性影响
还有一个容易被忽略的点:字段的数据类型和字符集设置,直接影响索引的比较效率。最常见的是隐式类型转换,比如user_id字段是varchar类型,传参却是整数,MySQL在比较时会把字段值转换成数值类型。字段上套了函数,索引就失效了。这个后面细说。
字符集不一致带来的问题更隐蔽。两张表关联查询时,如果一张表的a字段是utf8mb4,另一张表的a字段是utf8,那么关联条件里的字段比较就需要做字符集转换,索引同样可能失效。这也是为什么我建议全库统一使用utf8mb4,避免这种“跨字符集联表”的隐性坑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 索引优化是撬动查询性能的第一杠杆
2.1 三种索引模型:普通索引、唯一索引、前缀索引怎么选
既然索引这么重要,那到底怎么建索引?首先要区分几种索引类型,别一上来就CREATE INDEX。
- 普通索引(INDEX):最基础的索引,目的是加速查询,允许重复值。业务上多数查询场景用这个就够了。
- 唯一索引(UNIQUE INDEX):在普通索引的基础上,还保证了字段值的唯一性。常用于用户手机号、身份证号、订单号这类业务上必须唯一的字段。它除了加速查询,还能作为约束防止重复数据写入。
- 前缀索引(PREFIX INDEX):针对字符串很长、前缀区分度高的字段,只对前N个字符建索引,减少索引体积,提升写入性能和索引扫描速度。比如邮箱字段
email varchar(255),实际查询时区分度基本集中在前十几个字符,建前缀索引收益远高于完整索引。
以邮箱为例,建前缀索引的写法:
sql复制ALTER TABLE users ADD INDEX idx_email_prefix (email(12));
不过前缀索引的优势是省空间,劣势是会额外增加一次回表。如果你的主要查询是WHERE email = 'xxx',并且表行数不高、区分度足够,前缀索引完全够用;但如果行数很高,还是建议用完整索引配合覆盖索引来优化。
2.2 联合索引的设计顺序:最左前缀法则怎么用
多数业务查询不会只查一个字段,联合索引才是日常优化的大头。联合索引遵循最左前缀法则:MySQL会从联合索引的第一个字段开始,从左到右依次匹配查询条件,一旦遇到范围查询(>、<、BETWEEN、LIKE),范围右边的字段就无法继续走索引了。
举个例子,假设有联合索引(user_id, status, create_time):
sql复制SELECT * FROM orders WHERE user_id = 1024 AND status = 1 AND create_time > '2024-01-01';
这条语句里,user_id等值匹配,status等值匹配,create_time范围查询,索引三段全部生效。但如果改成:
sql复制SELECT * FROM orders WHERE status = 1 AND create_time > '2024-01-01';
缺少了最左边的user_id,联合索引完全用不上,回退到全表扫描。这就是为什么设计联合索引时,一定要把等值查询的字段放在最前面,范围查询的字段放最后。
还有一个经验:把区分度高的字段放前面。比如user_id(区分度高)应该放在status(区分度低)前面,这样索引树整体扫到的行数更少。
2.3 覆盖索引:少一次回表,查询快一倍
覆盖索引是我在日常优化中性价比最高的一招。所谓覆盖,就是查询列都能在二级索引里找到,不需要回表。举个例子:
sql复制SELECT order_id, status, create_time FROM orders WHERE user_id = 1024;
只要建立联合索引(user_id, order_id, status, create_time),这个查询的所有字段都在索引里,执行时直接遍历索引就完成,回表彻底省掉。
覆盖索引的价值在大数据量下尤其明显。一次回表就是一次主键查询,三百万行的表,如果命中5000条记录,覆盖索引能省下5000次主键查询,性能差距可能是几十毫秒和几秒的差别。
不过覆盖索引也不是越多越好。索引占用空间、拖慢写入性能,所以只在高频查询路径上做覆盖索引设计,别为了“可能用得上”盲目建一堆冗余索引。
2.4 索引失效的八种常见场景,踩一个坑慢一分钟
以下是我整理的高频索引失效场景,几乎每个都踩过:
| 场景 | 示例 | 后果 |
|---|---|---|
| 隐式类型转换 | WHERE user_id = 123,但user_id是varchar |
索引失效,全表扫描 |
| 字段上使用函数 | WHERE DATE(create_time) = '2024-01-01' |
索引失效,无法快速定位 |
| 前导模糊查询 | WHERE name LIKE '%张' |
索引失效(后缀匹配还行) |
| 联合索引缺失最左字段 | 索引是(user_id, status),条件只查status |
索引失效 |
| OR连接非索引列 | WHERE user_id = 1 OR name = '张三' |
索引失效(除非两侧都有索引) |
| 字符串与数字拼接 | WHERE mobile = 13800138000,mobile是varchar |
隐式转换,索引失效 |
| NOT IN / <> 操作 | WHERE status <> 1 |
优化器权衡后常走全表扫描 |
| 排序字段顺序不一致 | ORDER BY create_time DESC, id ASC与索引顺序冲突 |
filesort,性能骤降 |
这些场景里,最让我意外的是很多新手对“LIKE '%xxx'不走索引”这个概念理解有偏差。其实LIKE 'xxx%'是能走索引的,只有左边带%才不行。合理利用这一点:
sql复制-- 可以走索引
SELECT * FROM orders WHERE order_no LIKE '2024%';
-- 不能走索引
SELECT * FROM orders WHERE order_no LIKE '%2024%';
3. SQL写法上的常见低效习惯与改写方案
3.1 SELECT * 不只是浪费网络流量,更是索引失效的导火索
SELECT * 被无数文章批判过,但实际项目里依然大量存在。SELECT * 真正的危害在于它让覆盖索引失效。前面说了,覆盖索引的前提是查询列包含在索引列里。一旦拆成*,就必然包含不在索引里的字段,MySQL只能老老实实回表取全行数据。
所以,哪怕只是多出两个不需要的字段,SELECT *就已经把覆盖索引的优化空间给堵死了。习惯性写全字段列表,是有必要的。
sql复制-- 不推荐
SELECT * FROM orders WHERE user_id = 1024;
-- 推荐
SELECT order_id, status, create_time FROM orders WHERE user_id = 1024;
3.2 分页深翻页的痛点:LIMIT 100000, 20为什么慢到爆炸
分页查询在后台系统里太常见了,但大多数人的写法是有性能隐患的。先看一个典型的慢查询:
sql复制SELECT id, order_no, amount FROM orders ORDER BY create_time DESC LIMIT 100000, 20;
这个SQL慢在哪?MySQL需要先找到第100000行的位置,然后继续扫描20行返回。前面的100000行并不是不读,而是读完就丢掉,这个“跳过”的过程消耗了大量I/O,还没法用索引快速跳过。
优化思路有两个方向:
- 延迟关联:先只查主键,拿到主键列表后再关联原表取其他字段。
sql复制SELECT o.id, o.order_no, o.amount
FROM orders o
INNER JOIN (SELECT id FROM orders ORDER BY create_time DESC LIMIT 100000, 20) t
ON o.id = t.id;
- 基于游标的分页:利用上次查询结果里的某个唯一字段(比如
create_time和id)做条件过滤。
sql复制SELECT id, order_no, amount
FROM orders
WHERE (create_time, id) < ('2024-06-01 12:00:00', 10088)
ORDER BY create_time DESC, id DESC
LIMIT 20;
两种方案里,基于游标的分页性能最优,因为它直接把扫描范围限制在目标附近;延迟关联则是兼容原有分页结构的最简改造方案。具体取舍看业务是否方便传游标参数。
3.3 排序和分组优化:filesort和临时表是可以绕开的
ORDER BY走了filesort,GROUP BY建了临时表,这两个都是高性能查询的大敌。filesort指的是MySQL需要对结果集额外排序,可能在内存也可能在磁盘,数据量大时极慢。要绕开,最简单的办法是让排序字段和索引顺序保持一致。
假设有联合索引(user_id, create_time):
sql复制-- 这个查询天然利用索引排序,无需filesort
SELECT * FROM orders WHERE user_id = 1024 ORDER BY create_time DESC;
反过来,如果查询是ORDER BY create_time但条件只用了user_id,那create_time不在索引的最左连续匹配范围,排序就得额外做。这里要注意,ORDER BY同样遵循最左前缀法则,等值条件字段 + 排序字段的组合最理想。
GROUP BY也是同理,它通常先做分组再做聚合。如果分组字段和索引匹配,就能避免临时表。另一个优化方向是使用松散索引扫描,但在MySQL 8.0之前支持并不好,实践中更多靠调整索引顺序。
3.4 子查询与JOIN的取舍:IN改EXISTS不总是对的
老生常谈的“把IN改成EXISTS”在MySQL 5.6之前确实有效,因为早期优化器对IN子查询的处理不够好。但MySQL 5.7之后,优化器做了大量改进,大部分情况下IN和EXISTS会被改写成半连接(semi-join),性能差异已经很小。
真正需要关注的不是IN还是EXISTS,而是驱动表的选择和JOIN顺序。小表驱动大表原则永远不过时:
sql复制-- 假设dept表很小,employees表很大
-- 用小表dept作为驱动表
SELECT e.name FROM employees e INNER JOIN dept d ON e.dept_id = d.id WHERE d.name = '技术部';
优化器一般会自动选择驱动顺序,但如果你在ON或WHERE条件里写了一些限制优化器的逻辑,它可能选错。此时可以用STRAIGHT_JOIN强制指定驱动表顺序,但这是最后的手段,正常情况下不建议干扰优化器。
3.5 用UNION代替OR的前提:索引条件要对称
关于OR和UNION的选择,很多人喜欢一刀切“用UNION更好”,但这是不准确的。OR走索引的前提是OR两侧的条件都能走索引。如果一侧是索引列,另一侧不是,索引就失效。
sql复制-- 两侧都是索引列时,OR可以走索引
SELECT * FROM orders WHERE user_id = 1024 OR order_no = '202401010001';
如果要强制优化,可以改成UNION:
sql复制SELECT * FROM orders WHERE user_id = 1024
UNION
SELECT * FROM orders WHERE order_no = '202401010001';
但UNION会做去重,如果不需要去重,用UNION ALL更好,省掉去重带来的排序和临时表开销。总体上,能不改OR就不改,只有确认慢的时候再调整。
4. 用EXPLAIN做一次完整的慢查询体检
4.1 EXPLAIN输出的关键字段到底怎么读
手工调优SQL,第一件事就是EXPLAIN。这条命令不会真的执行查询,而是展示优化器生成的执行计划。下面这张表是输出结果里最需要关注的字段:
| 字段 | 含义 | 重点关注 |
|---|---|---|
| type | 访问类型 | const > eq_ref > ref > range > index > ALL,从好到坏 |
| key | 实际使用的索引 | NULL说明没用上索引 |
| rows | 预估扫描行数 | 越小越好 |
| filtered | 经过条件过滤后的比例 | 100%说明全部符合 |
| Extra | 附加信息 | Using index、Using filesort、Using temporary是重点 |
type的常见值里,const是主键或唯一索引等值查询,最快;ref是非唯一索引等值查询,正常;range是索引范围扫描,也可以接受;index是扫描整个索引树,虽然没回表但也不快;ALL是全表扫描,要尽量消灭。
4.2 实际案例:一条三百万行订单表的完整降耗时过程
拿前面说的那个订单表来演示完整排查过程。原始SQL:
sql复制SELECT * FROM orders
WHERE user_id = 1024
AND status = 1
ORDER BY create_time DESC;
EXPLAIN结果:type = ALL,rows = 3000000,Extra = Using filesort。三个问题叠加:全表扫描、无索引排序、回表取全字段。
第一步,加联合索引(user_id, status, create_time)。再跑EXPLAIN:
type = refrows = 45(user_id=1024且status=1的记录只有45条)Extra = NULL(索引天然排序,filesort消失)
第二步,把SELECT *改成只查需要的字段,并建立覆盖索引(user_id, status, create_time, order_no, amount)。再跑EXPLAIN:
type = refrows = 45Extra = Using index(覆盖索引,无回表)
整条SQL从八秒降到十几毫秒,优化结束。这个过程给我最大的感受是:EXPLAIN就是SQL优化的仪表盘,每一步改动是否生效,一眼就能看出来。
4.3 从EXPLAIN延伸到Optimizer Trace:为什么优化器最终选择了这个计划
EXPLAIN只展示结果,不展示过程。如果遇到“明明有索引,为什么走了全表扫描”这种反直觉情况,可以用Optimizer Trace看优化器的完整决策路径:
sql复制SET optimizer_trace='enabled=on';
SELECT * FROM orders WHERE user_id = 1024;
SELECT * FROM information_schema.OPTIMIZER_TRACE;
SET optimizer_trace='enabled=off';
OPTIMIZER_TRACE输出里有rows_estimation和considered_execution_plans,能看到优化器对每个访问路径的成本估算。比如它认为全表扫描成本是5000,走索引加回表成本是8000,那它就会选全表扫描。这时候就明白,额外建一个覆盖索引,把回表成本挤掉,优化器自然就会选择索引路径。
5. 让优化从一次救火变成持续机制:慢查询日志与优化复盘
5.1 配置慢查询日志,把病根一个个揪出来
临时排查慢SQL永远是被动的。真正靠谱的做法,是把慢查询日志打开,让数据库主动记录那些超过阈值的SQL。MySQL的配置如下:
ini复制slow_query_log = 1
slow_query_log_file = /var/log/mysql/mysql-slow.log
long_query_time = 1
log_queries_not_using_indexes = 1
long_query_time设置为1秒,意味着任何查询超过1秒都会被记录下来。一开始建议从1秒起,后续根据业务实际情况逐步调低到500毫秒或300毫秒,但要注意,日志越详细,对性能的额外开销也越大。
日志里能看到每一条慢SQL的实际执行耗时、锁等待时间、扫描行数、返回行数。配合mysqldumpslow工具可以快速汇总:
bash复制mysqldumpslow -s t -t 10 /var/log/mysql/mysql-slow.log
这样可以按总耗时取前十条慢SQL,直接定位到最需要优化的目标。这个习惯养成后,相当于把性能问题的发现从“用户投诉”变成了“系统主动报告”。
5.2 大表刷数据后ANALYZE TABLE:统计信息不准导致执行计划跑偏的案例
有一个坑值得一提:大表数据量变化很大时,如果不更新统计信息,优化器可能基于过期统计信息做成本估算,导致执行计划明显偏离最优。
比如某张订单表,平时两百万行数据,某次活动批量导入了五十万行。由于统计信息没更新,优化器以为表还是两百万行,估算的扫描成本偏低,结果全表扫描。这时候执行:
sql复制ANALYZE TABLE orders;
MySQL会重新采样统计信息,优化器再做成本估算时就会更准确。这个操作在表结构不变、数据量剧烈变化之后非常有效。
5.3 我的SQL优化复盘模板:每次排查慢查询都按这个顺序走
最后分享一个我个人在日常工作中反复使用的排查顺序,虽然原始项目不同,但思路是共通的:
- 拿到慢SQL,先在测试环境跑一遍,确认问题规模。
- 执行
EXPLAIN,看type、rows、Extra,定位是全表扫描、文件排序还是临时表。 - 通过
SHOW INDEX FROM table_name检查表的索引情况,判断是缺索引还是索引建得不合理。 - 重新组织SQL,优先考虑改写写法,比如去掉
SELECT *、调整WHERE顺序、优化分页方式。 - 检查统计信息是否过期,必要时
ANALYZE TABLE。 - 上线前用线上环境的一个只读副本压测,对比优化前后的执行计划。
这个顺序能兜住绝大多数场景。MySQL很多执行计划的问题都可以通过“索引 + 覆盖 + 合理写法 + 准确统计信息”这四个抓手解决,剩下的十个里大概有一两个,就可能需要从架构层面拆解,比如读写分离、数据归档、分库分表。但在走到那一步之前,先把SQL本身优化到位,才是成本最低、收益最直接的手段。
对了,还有一点建议:每次优化完,顺手把慢SQL和优化前后对比记录下来。下次再遇到类似问题,翻一下历史记录比重新排查快得多。我的实践中,这部分积累下来的价值不亚于任何一次具体的优化操作。
