☰
MySQL慢查询优化实战:从日志到索引的完整排查指南
2026/10/13 4:18:21 网站建设 项目流程

1. 先捞慢SQL:慢查询日志的正确打开方式

一说到MySQL变慢,很多人的第一反应是"查一下慢查询日志",这个方向没错,但实际干起来会发现,日志没开、阈值不对、分析工具不会用,光这一步就能卡掉一半人。我建议每个在生产环境跑MySQL的团队,都把慢查询日志当成基础监控的一部分,而不是等线上告警了才想起来开。

开启慢查询日志本身很简单,关键是把参数设对。下面这组配置是我在现网环境常用的基线:

slow_query_log = 1 slow_query_log_file = /data/mysql/logs/slow.log long_query_time = 1 log_queries_not_using_indexes = 0 min_examined_row_limit = 100

几个参数分开说。long_query_time是慢查询的判定阈值,单位秒,默认是10秒,这个值在生产环境太宽松了。我把阈值设成1秒,1秒以上的语句先捞出来看一遍,再根据实际情况收紧到500毫秒。需要注意的是,long_query_time对已经开启的会话不生效,改完参数后需要新建连接才会按新阈值记录,调试时要留意这一点。

log_queries_not_using_indexes这个参数我默认关掉,虽然它能捞出不走索引的语句,但实际开启后噪音极大——有些小表本来就该全表扫描,有些查询虽然没走索引但扫描行数很少,全记录下来会把真正有价值的慢SQL淹没掉。与其靠这个参数碰运气,不如把min_examined_row_limit设一个阈值,只记录扫描行数超过一定量的语句,这样日志里的内容更有针对性。

日志拿到了,接下来是分析。MySQL自带的mysqldumpslow工具虽然朴素,但日常够用。我常用的命令是这两个:

# 按平均查询时间排序,取前20条 mysqldumpslow -s at -t 20 /data/mysql/logs/slow.log # 按执行次数排序,看哪些语句出现频率最高 mysqldumpslow -s c -t 20 /data/mysql/logs/slow.log

mysqldumpslow会把结构相似、只有条件值不同的SQL自动归类,比如WHERE id = 1和WHERE id = 2会被合并成一条模板,这样看报告不会被大量重复语句刷屏。不过它只做聚合统计,想看到完整的执行计划、当时的锁等待情况,还得自己动手把原始SQL捞出来单独跑一遍EXPLAIN。

这里想提醒一个容易踩的坑:慢查询日志记录的是语句执行完之后的结论,它包含等待锁的时间、执行时间,但不包含从客户端发送SQL到服务器开始执行之间的网络耗时。所以如果你的慢日志里全是偶发的几百毫秒语句,先别急着优化SQL,检查一下是否存在锁竞争、连接池不够用、网络抖动这类外部因素,方向错了后面全是白忙。

2. 定位慢在哪:EXPLAIN执行计划关键字段的逐列读数

慢SQL捞出来之后,下一步就是在数据库里用EXPLAIN看执行计划。很多初学者看执行计划只看一个type字段,看到ALL就喊全表扫描,看到index就以为没事了,这远远不够。我一般固定看六个字段:type、key、rows、filtered、Extra,以及key_len。逐个说才有意义。

type表示访问类型,从好到差大致是const、eq_ref、ref、range、index、ALL。判断标准可以记一个简化版:

type含义是否健康
const主键或唯一索引等值查询,最多一行健康
eq_ref被驱动表通过主键或唯一索引关联健康
ref通过普通索引等值匹配健康
range索引范围扫描(如>、BETWEEN、IN)可接受
index扫描了整个索引树留意
ALL全表扫描重点排查

index和ALL的区别值得展开说一下。index表示扫描的是索引树而不是数据行,听着比全表扫描好,但如果索引本身很大,或者覆盖字段和数据行一样多,实际开销并不小。判断index是否健康,要结合rows字段一起看——如果rows接近表的总行数,那这个索引扫描的代价也不低。

rows字段是优化器估算的需要扫描的行数,不是精确值。filtered表示经过WHERE条件过滤后剩余的比例,如果rows是10万、filtered是1%,意思是预计要扫描10万行,过滤后只剩1000行。这两个字段合起来看,能帮你判断一个查询的瓶颈是"扫描太多行"还是"过滤条件太弱导致回表太多"。

Extra字段是我每次必看的,三个高频危险信号:Using filesort表示排序没有走索引,需要额外的排序操作;Using temporary表示查询用了临时表,常见于GROUP BY、DISTINCT、多表连接的中间结果;Using join buffer表示连接时用不上索引,驱动表与被驱动表之间全靠内存缓冲硬碰。看到这三个关键词,基本可以确定SQL的优化方向了。

key_len这个字段很多人不看,但它能帮你判断联合索引到底吃满了多少列。举个例子,某个联合索引是(a, b, c),如果key_len只等于a这一列的长度,说明只用到了第一列,后面的b、c可能根本没参与索引匹配。这个信息在排查"明明建了索引却还是慢"的场景时特别有用。

看执行计划有一个习惯很值得培养:拿到一条慢SQL,先不要直接改SQL,而是先看它的执行计划长什么样,把type、rows、Extra这几个值记下来,做完优化后再跑一遍EXPLAIN对比变化。优化有没有效果,执行计划比"看起来快了多少"更说明问题。

3. 索引到底怎么失效的:几个让我排查到想放弃的经典场景

慢查询里最常见的病根不是没建索引,而是建了索引但没走索引。下面这几个场景,我几乎每次接手慢查询优化都能碰到,展开说一下背后的原理和正确写法。

第一个场景是在索引列上做函数操作。比如有个订单表,created_at上有索引,想查某一天的单量,习惯性写成:

SELECT COUNT(*) FROM orders WHERE DATE(created_at) = '2023-05-01';

这个写法对人是直观的,但对MySQL来说,DATE(created_at)已经改变了列本身的值,索引树里存的是原始时间戳,没法直接匹配"加工过后"的结果,于是只能把索引列全部取出来算一遍。改写方法很简单,把条件改成范围判断:

SELECT COUNT(*) FROM orders WHERE created_at >= '2023-05-01 00:00:00' AND created_at < '2023-05-02 00:00:00';

这样created_at保持原样,可以直接走索引的范围扫描,数据量上来之后差别是数量级的。

第二个高频场景是隐式类型转换。手机号、身份证这类字段在设计时常用VARCHAR存储,如果你写查询时忘了加引号,比如:

SELECT * FROM users WHERE phone = 13800138000;

MySQL会把phone列隐式转换为数值类型再比较,这个转换动作发生在索引列上,索引同样失效。正确写法是加引号:

SELECT * FROM users WHERE phone = '13800138000';

顺带提一句,设计表结构时如果字段本身是VARCHAR,应用层传参也要注意类型匹配,很多慢查询其实是在代码里不经意埋下的。

第三个场景是**LIKE以通配符开头**。WHERE name LIKE '张%'能走索引,但WHERE name LIKE '%张%'不能。原因不复杂:索引是按前缀排序的,'张%'可以定位到以"张"开头的区间,而'%张%'不知道目标出现在字符串的哪个位置,索引树帮不上忙。一个常用的缓解方案是改成覆盖索引,让查询所有需要的列都包含在索引里,虽然还是扫描,但扫描的是紧凑的索引页而非散乱的数据页。更彻底的做法是业务上引入全文检索,不过这属于后话了。

第四个场景是**OR条件导致索引失效**。比如:

SELECT * FROM order_detail WHERE order_id = 123 OR user_id = 456;

如果只有order_id上有索引,user_id上没有,MySQL没法用"部分走索引、部分全表扫描"的执行方式,只有两个条件都能走索引时才会用UNION合并,否则干脆全表扫。改写方案是拆成两个查询用UNION ALL拼接,或者给user_id也补上索引。注意UNION会做去重,能确定两个集合不重叠时用UNION ALL更省。

第五个场景是联合索引的最左前缀原则。联合索引(a, b, c)可以匹配(a)、(a,b)、(a,b,c)这几种组合,但直接查(b,c)或者(c)是用不上这个索引的。很多优化建议让"把高频查询列放左边",这个说法对,但前提是高频查询确实把左边的列作为等值条件。如果业务上更常按b查询,那这个联合索引从设计之初就错了。

排查这类问题时有一个好用的辅助手段:把EXPLAIN结果里possible_keys和key对照看。possible_keys显示"优化器认为可能用到的索引",key显示"实际选中的索引",两者不一致,说明优化器权衡后放弃了某个索引。这时候要么是索引选择性太低(比如性别字段),要么是回表代价太大,而不是简单地"加个索引就行"。

4. 分页、排序、多表连接的优化细节

慢查询的分布并不均匀,多数业务场景里,问题集中在三类SQL:深分页、大结果集排序、多表连接。每一类都有比较成熟的优化套路。

先说深分页。LIMIT 100000, 20这种写法在业务后台列表里到处都是,执行计划看起来可能还挺正常——走索引、类型是range——但实际速度就是慢。原因在于LIMIT的偏移量越大,MySQL需要扫描并丢弃的行越多,它不是直接从第10万行开始读,而是从第一行开始数,数到第10万行再往后取20行。数据量大时,这个"无为的扫描"会吃掉大部分时间。

两个常用的优化方案。第一个是延迟关联,先利用索引快速定位到目标行的主键ID,再用主键回表取完整数据:

SELECT * FROM orders JOIN ( SELECT id FROM orders WHERE status = 1 ORDER BY created_at DESC LIMIT 100000, 20 ) t ON orders.id = t.id;

内层子查询只查id列,如果id和排序字段在联合索引里,就用覆盖索引完成扫描,避免了大量无效回表。第二个方案是基于游标的分页,适合App列表这类"只允许下一页"的场景:

SELECT * FROM orders WHERE status = 1 AND id > 100000 ORDER BY id ASC LIMIT 20;

实现方式就是前端把上一页最后一条记录的id传回来,后端用id > 上一页末尾ID代替LIMIT偏移。它不适用于指定页码跳转的场景,但绝大多数移动端列表都够用,而且性能稳定,页码越深优势越明显。

排序优化方面,先给一个底层认知:如果ORDER BY字段上恰好有索引,MySQL可以直接按索引顺序取数,省掉filesort;如果排序字段不在索引里,就需要把结果集放进内存或磁盘临时文件排序。处理方案是尽量让排序字段和WHERE条件的过滤字段组合起来,放到同一个索引里。比如高频查询是"按状态过滤+按创建时间倒序",可以建(status, created_at)联合索引,这样过滤和排序都能交给索引完成,Extra里的Using filesort就能消掉。

多表连接的核心问题则是驱动表与被驱动表的选择。MySQL在执行连接时,会先选一张表作为驱动表,再用驱动表的结果去匹配另一张表。被驱动表的连接字段上必须有索引,否则每一行都要全表扫描,代价极高。优化器一般会倾向于用小表驱动大表,但如果你发现执行计划里的驱动表明显不对,且连接字段的类型不一致(比如一个是VARCHAR一个是BIGINT),索引也可能莫名失效。保持连接字段的类型一致是最容易被忽略的一条规则。

还有一点值得单独说:能不连接就不连接。很多慢查询的根源是业务上为了省一次应用层查询,把本来可以分两步做的操作硬写成一个三表甚至五表连接。MySQL处理数据要基于行,但业务逻辑需要的是"一批ID对应的一批属性"。遇到特别复杂的连接,先在应用层把主表数据查出来,再IN查询关联表,往往反而更快,排查问题也更直观。这不是教条,而是在数据量起来之后反复对比得出的经验。

5. 加了索引还是慢:还有哪些平时容易被漏掉的因素

有时候EXPLAIN看着已经挺健康了,type是ref,Extra干干净净,但线上就是慢。这类问题往往不在SQL本身,而在更外层。

第一个被忽视的因素是缓冲池命中率。InnoDB的默认缓冲池大小和实例内存大小不匹配时,热点数据放不进内存,磁盘IO频繁,再好的索引也要等磁盘。可以先查看两个指标:

SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read_requests'; SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_reads';

Innodb_buffer_pool_read_requests是逻辑读次数,Innodb_buffer_pool_reads是真正落到磁盘的物理读次数。两者比值越大越健康,如果物理读占比明显偏高,优先考虑调大innodb_buffer_pool_size。对于大部分以读为主的业务,这个值可以设到物理内存的60%~70%,但不建议一次调整过大,改完观察一段时间的命中率和内存占用再继续。

第二个因素是临时表和排序缓冲区太小。EXPLAIN里Using temporary和Using filesort双剑合璧时,MySQL会把中间结果写到磁盘临时文件,性能断崖式下跌。可以适当调整tmp_table_size和max_heap_table_size,让更多临时结果集留在内存。注意这两个参数取较小值生效,最好一起调整。排序还用到了sort_buffer_size,但我不建议盲目调大会话级排序缓冲区,过大的排序缓冲会占用连接内存,连接一多内存直接被打满,先确认确实是排序问题再动它。

第三个因素是一个会话一条大SQL吃掉了所有资源。假设有一条SQL要扫描上亿行,只要它一跑,其他本来很快的查询都会跟着变慢——它们不是自己变慢了,是等CPU、等IO等出来的。MySQL 5.7及以上可以通过MAX_EXECUTION_TIME给单条查询设置执行上限:

SELECT /*+ MAX_EXECUTION_TIME(5000) */ * FROM big_table WHERE ...;

超过5秒直接终止,不让某一条烂SQL拖着整个库陪葬。这个手段尤其适合那些"偶尔有人跑一次大查询"的业务场景。

第四个容易被忽略的问题是索引碎片和统计信息过期。一张表频繁INSERT、UPDATE、DELETE,索引页会产生碎片,统计信息也可能滞后,优化器依据不准确的统计信息选出来的执行计划,自然容易走偏。周期性执行OPTIMIZE TABLE(建议在业务低峰期)和ANALYZE TABLE能让执行计划恢复理智。很多"明明什么都没改,突然就慢下来"的案例,最后都归结到统计信息上。

6. 一些从实战里总结出来的优化步骤,直接照着做

理论讲了不少,最后给一套可复制的排查流程,用来接手任何一条慢查询时按顺序执行。

第一步是复现问题,确认"慢"的标准。拿到慢SQL后先在测试环境跑一遍,看执行耗时和EXPLAIN结果。如果测试环境数据和线上差别很大,可以用FORCE INDEX临时指定索引来模拟不同执行计划,但最终评估还是要以线上的数据分布为准。

第二步是检查执行计划里的type和rows。type是ALL就优先考虑加索引;rows远大于实际返回行数就思考过滤条件有没有走索引、有没有不必要的排序。这一步能解决八成的慢查询。

第三步是检查业务逻辑层面。比如同一个时间范围内的订单量,一次查全量再在应用层统计,和按天分批聚合,耗时差很远。有些慢查询不是写出来的,是设计出来的。

第四步是评估加索引的影响。加索引不是免费的,它会拖慢写入,占用额外空间。大表加索引时选择工具也很关键,MySQL 5.6以上支持INPLACE在线DDL,但大表执行期间主从延迟依然可能存在。生产环境建议在低峰期操作,先用ALGORITHM=INPLACE, LOCK=NONE测试,再决定是否执行。

第五步是持续监控。优化完一条SQL只是开始,最怕的是过一阵子数据量涨了,同一个SQL又慢回来。把慢查询日志接入每天的巡检,或者用定时任务聚合分析,比等到告警再被动排查舒服得多。

我个人的体会是,慢查询优化这件事,数据库本身的技巧只是前半程,后半程是对业务的理解——你清楚这条SQL到底在解决什么业务问题,才有可能判断哪些聚合能提前算、哪些连接能拆开、哪些排序能省掉。多花十分钟理解数据模型和数据分布,往往比花一小时抠索引细节更有回报。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询