MySQL慢查询日志实战:配置、分析与慢SQL优化
2026/9/19 1:31:06 网站建设 项目流程

1. 先把"慢"这个词定义清楚:阈值背后的取舍逻辑

线上告警响起来,第一反应往往是"哪个SQL慢了"。但真打开慢查询日志你会发现,日志里躺着的语句可能几十万条,随便翻一眼都是"慢SQL",反而不知道该动谁。问题不在日志,在于一开始就没搞清楚这份日志的采集口径是怎么定义的——什么算慢、什么会被记下来、什么根本进不来。口径不清,后面所有分析都是瞎猜。

1.1 long_query_time 不是拍脑袋定的数

long_query_time是慢查询日志的第一道门槛,只统计执行时间超过这个值的语句。它默认是 10 秒,这个默认值在今天的业务里基本没有参考价值——10 秒的语句早就把连接池打满了,等它进日志,故障已经发生了。所以我一般会把它压到 1 秒甚至 0.5 秒。

但往下压不是没有代价的。这个参数支持微秒级精度,你可以写成long_query_time = 0.1,日志量会成倍上涨。我见过一个日均两千万次查询的订单库,阈值从 1 秒调到 0.1 秒之后,慢日志从每天 30MB 涨到 2.4GB,单是日志落盘就吃掉了不少 IO 带宽。

比较务实的做法是分层:

场景建议阈值理由
核心交易链路0.1 ~ 0.3 秒用户可感知,且这类接口对延迟最敏感
一般业务查询0.5 ~ 1 秒兼顾排查覆盖面和日志体积
报表/批处理库2 ~ 5 秒本身就是长查询,阈值太低会淹没信号
临时排查某个接口会话级临时设置不影响全局

最后一行是很多人不知道的用法:long_query_timeglobal + session 双作用域的变量。排查某个特定接口时,可以在那个连接上单独设:

SET SESSION long_query_time = 0.05; -- 跑一轮压测或复现操作 -- 用完自动失效,不影响其他连接

这招在复现"偶发慢"的时候特别好使,不用为了抓一条语句去动全局配置。

1.2 还有哪些语句会被"额外"记进来

只靠耗时阈值筛选有个致命盲点:扫了一千万行只返回一行、耗时 0.4 秒的语句,永远进不了日志,但它在高并发下把 buffer pool 冲刷得一团糟,是典型的"隐形杀手"。所以 MySQL 还提供了几个补充开关:

  • log_queries_not_using_indexes = ON:没走索引的语句一律记录。这个开关是把双刃剑,小表全表扫描也记、SHOW类语句也记,很容易把日志冲爆。真要用的话,一定配log_throttle_queries_not_using_indexes,它限制每分钟最多记录多少条同类语句,默认 0 表示不限制,我一般设成 60。
  • min_examined_row_limit = N:扫描行数少于 N 的语句不记录。它是用来"减噪"的,比如设成 100,那些扫几十行就返回的语句就不占日志了。
  • log_slow_admin_statements = ON:把ALTER TABLEANALYZE TABLE这类管理语句也纳入统计。DDL 慢起来能锁表几十分钟,值得记录。
  • log_replica_statements(老版本叫log_slow_slave_statements):主从架构下,从库回放执行的语句默认不记录,需要单独打开。

这几个参数组合起来,才构成一份有分析价值的日志。只调一个long_query_time,你拿到的只是"冰山露出水面的那一角"。

2. 日志往哪写:FILE 和 TABLE 的性能账要算明白

参数调完,下一步是决定日志落到哪里。log_output有三个取值:FILETABLENONE,默认是FILE。很多人看到TABLE能直接SELECT查就改成了它,图省事,结果在高并发下把库拖得更慢了——这个坑值得单独说清楚。

2.1 两种落盘的机制差异

log_output = FILE是把记录追加写到slow_query_log_file指定的文件里,走的是顺序追加写,开销主要在磁盘 IO,而且是顺序 IO,对数据库本身干扰很小。

log_output = TABLE是把记录写进mysql.slow_log这张表,默认引擎是 CSV。CSV 引擎不支持索引、不支持事务、每次写入都要重新打开文件,并发写会互相抢锁。我实测过一个 QPS 三千左右的库,切到TABLE之后,慢日志自身产生的锁等待时间一度占到整体响应的 5% 以上。所以我的建议很明确:

生产环境一律用FILE。需要查询分析,用离线工具解析文件,别让数据库自己扛这个活。

TABLE唯一的合理场景是临时排查——比如你手边没有服务器文件权限,想快速看两眼,那就开个十分钟,看完立刻切回FILE

2.2 生产环境开关日志的正确姿势

慢查询日志的开关和路径改动,建议不要用SET GLOBAL一改了之。因为SET GLOBAL只在运行期生效,重启实例后全部还原,下一次故障排查时你会发现日志又没了。正确的做法是写进配置文件,让它持久化:

[mysqld] slow_query_log = 1 slow_query_log_file = /var/log/mysql/slow.log long_query_time = 0.5 log_queries_not_using_indexes = 1 log_throttle_queries_not_using_indexes = 60 min_examined_row_limit = 100 log_slow_admin_statements = 1 log_output = FILE

改完配置后有两种生效方式:重启实例,或者用SET GLOBAL在线把同样的值设一遍(重启后配置文件的值会接管)。我通常选后者,避免重启带来的业务抖动。

这里有个容易忽略的细节:日志文件所在目录必须对 MySQL 进程的属主可写,而且在部分发行版上还会受安全模块的文件路径限制。如果开完日志发现文件压根没生成,先去看错误日志,大概率是权限或路径策略的问题,而不是参数没生效。

2.3 别忽视日志本身的资源占用

慢查询日志的写盘是"语句执行完之后"发生的,也就是说它不会让语句变慢,但它会持续消耗 IO 和磁盘。上面提到的那类高流量库,日志一天几个 GB,如果没人清理,一个月就能把/var撑爆。磁盘写满之后,MySQL 可能直接进入只读状态,这个后果比慢SQL本身严重得多。

我的固定做法是三步:日志放到独立分区、配logrotate每天切割并保留 7 到 14 天、切割后用mysqladmin flush-logs或 MySQL 8.0 的FLUSH SLOW LOGS让进程重新打开新文件。这三步做完,日志才算是"可持续"的。

一个判断日志是否失控的简易标准:如果slow.log每小时增长超过 200MB,说明阈值或log_queries_not_using_indexes设置过宽,先收紧再分析。

3. 逐字段读懂一条慢日志记录

打开文件看到的第一屏通常长这样:

# Time: 2024-06-11T09:22:41.318521Z # User@Host: app_rw[app_rw] @ 10.0.3.17 [] Id: 882134 # Query_time: 2.417890 Lock_time: 0.000132 Rows_sent: 20 Rows_examined: 1834620 SET timestamp=1718097761; SELECT id, order_no, amount FROM t_order WHERE merchant_id = 9012 AND status = 3 ORDER BY create_time DESC LIMIT 0, 20;

大部分人只会看Query_time,其实这一小段里藏的信息量远超想象,每一项都能指向不同的根因。

3.1 头部三个字段的解读顺序

# User@Host加上Id给出了执行者身份和线程号。这点在做归因时非常关键:同一条SQL,来自定时任务连接和来自线上接口连接,处理优先级完全不同。如果一条慢SQL几乎全部来自某个报表账号,那你优化它的收益就很有限——它不占用户请求的链路。反过来,如果集中在应用连接池,那就是实打实的用户体验问题。

Query_time是语句从开始执行到结束的总耗时,包含等待锁的时间Lock_time是其中花在等锁上的部分。这两个值的关系能直接分流排查方向:

  • Lock_time接近Query_time,比如 Query 2.4 秒、Lock 2.3 秒,那这条语句本身不慢,是它要改的行被别人占着。方向应该是去找持锁的事务,看是不是有大事务、有没有FOR UPDATE长时间不提交。
  • Lock_time接近 0 而Query_time很高,才是真正的"语句本身有毛病",往索引和写法上查。

Rows_sentRows_examined是一对组合。前者是最终返回给客户端的行数,后者是存储引擎实际扫描的行数。上面那条语句扫了 183 万行只返回 20 行,这个比例就是典型的"索引没吃到正确的过滤条件"。

3.2 判断效率的一个经验比值

我给团队定过一条简单的筛选线:Rows_examined / Rows_sent > 1000且扫描行数超过 10 万,就优先看。上面例子是 9 万多倍,必然有问题。

但要注意反例:聚合查询天然就是扫很多行返回少数行。SELECT COUNT(*)GROUP BY这类语句比值天然很高,不能一刀切。判断标准要结合语句语义,看它理论上最少需要扫多少行,再和实际扫描行数对比。

3.3 打开 log_slow_extra 拿到更细的现场

MySQL 8.0.14 之后多了一个log_slow_extra参数(注意它只对log_output = FILE生效)。打开之后,每条记录会追加一大批统计,其中最值钱的是这几项:

字段含义排查用途
Read_rnd_next全表/全索引扫描读到的下一行次数数值巨大基本等于全表扫
Read_key通过索引读行的次数衡量索引回表量
Sort_rows排序处理的行数判断 filesort 规模
Sort_merge_passes排序归并趟数大于 0 说明超出排序缓冲区
Created_tmp_disk_tables落到磁盘的临时表数量大于 0 说明内存临时表不够用
Bytes_sent返回给客户端的数据量揪出"返回巨量数据"的语句

Sort_merge_passes这个指标我特别看重。它是 0,说明排序完全在内存里完成;一旦大于 0,就意味着排序过程在内存和磁盘之间来回倒数据,性能会断崖式下跌。调大sort_buffer_size能缓解,但更根本的解法是让ORDER BY走索引顺序,避免排序发生。

4. 从几十万条记录里挑出真正该改的那几条

日志有了、字段会看了,接下来是最考功力的一步:排序。默认按文件顺序看,等于随机抽奖。

4.1 为什么不能按单次耗时排序

这是最常见的思维误区。一条耗时 30 秒的报表SQL,每天跑一次,总共占用数据库 30 秒;而一条耗时 0.6 秒的语句,每分钟被调用 8000 次,一天累计占用 80 分钟。谁在拖累系统?答案很明确。

所以正确的排序维度不是"单次耗时",而是累计耗时 = 平均耗时 × 执行次数。慢查询日志本身是按事件记录的,不带聚合,所以你必须借助工具做摘要。

4.2 pt-query-digest:聚合画像的首选

Percona Toolkit 里的pt-query-digest是这类工具里最成熟的。一条命令就能拿到按总耗时排序的TOP列表:

pt-query-digest --limit 100% --order-by Query_time:sum \ --report-format profile \ /var/log/mysql/slow.log > /tmp/slow_report.txt

输出分三块:整体概况、按总耗时排序的语句画像、每条语句的详细样本。看第二块的时候我会关注三个数:

  1. Response time 占比:这条语句占总慢查询时间的百分比。占比超过 10% 的,基本都要动手。
  2. Calls:调用次数。次数极高说明它在下游被反复触发,减少调用次数可能比优化语句本身收益更大——比如加缓存。
  3. pct 95 / pct 99 / max:分布尾部。如果平均值不高但 pct 99 很高,说明慢是偶发的,通常和锁、IO 抖动、连接池排队有关,而不是语句写法问题。平均值高、尾部也高,才是稳定的写法问题。

--order-by这个参数非常好用,除了Query_time:sum,还可以换成Query_time:cnt(按次数)、Rows_examined:sum(按扫描量)。我一般会跑三遍,三份TOP列表交叉看。

4.3 官方工具的轻量替代

如果不想装额外组件,MySQL 自带的mysqldumpslow也能用,只是分析维度糙一些:

# 按总耗时排序,取前 10 mysqldumpslow -s t -t 10 /var/log/mysql/slow.log # 按平均耗时排序,只过滤 SELECT mysqldumpslow -s at -t 10 -g "SELECT" /var/log/mysql/slow.log # 按扫描行数排序 mysqldumpslow -s r -t 10 /var/log/mysql/slow.log

它的短板很明显:数字和字符串字面量会被统一成NS,某些情况下会把语义不同的语句合并到一起;也没有 pct 分位数。作为快速摸底够用,深度分析还是得上pt-query-digest

4.4 无索引语句和已聚合视图的互补视角

慢日志是按执行事件记录的,看不到"当前库上最贵的语句模式"这种全局视图。这时候sys库可以补位,它的数据来自performance_schema,是聚合后的结果:

-- 总延迟最高的 10 个语句模式 SELECT query, exec_count, total_latency, avg_latency, rows_examined_avg, rows_sent_avg, full_scan FROM sys.statement_analysis ORDER BY total_latency DESC LIMIT 10; -- 全表扫描的语句 SELECT * FROM sys.statements_with_full_table_scans LIMIT 10; -- 冗余索引和从未被使用的索引 SELECT * FROM sys.schema_redundant_indexes; SELECT * FROM sys.schema_unused_indexes;

sys.statement_analysis里的full_scan字段特别有价值——它直接告诉你这条语句模式是否走了全表扫描,不用自己去EXPLAIN验证。而schema_unused_indexes是反向优化的入口:索引不是越多越好,只写不读的索引会拖慢写操作,该删就得删。

5. 把根因钉死:EXPLAIN、统计信息与优化器决策

从日志里挑出候选语句之后,别急着改SQL。先让数据库自己把执行计划吐出来,看清它到底是怎么执行的。很多优化失误都是因为跳过这一步,凭直觉加索引,结果加了没用甚至更慢。

5.1 EXPLAIN 里我先看哪几列

EXPLAIN FORMAT=TRADITIONAL SELECT id, order_no, amount FROM t_order WHERE merchant_id = 9012 AND status = 3 ORDER BY create_time DESC LIMIT 0, 20;

输出列里,我的扫视顺序是固定的:

  • type:访问类型。ALL是全表扫描,index是全索引扫描,这两个是红线;rangerefeq_refconst依次变好。看到ALL就要往"为什么没用索引"上问。
  • key:实际选择的索引。如果是NULL,说明优化器判定不划算或者没有可用索引。
  • possible_keys:候选索引。如果这一列有值而key是 NULL,那是"有索引但没选",问题在优化器成本估算;如果possible_keys也是空,那就是"根本没建对的索引",问题在索引设计。这两种情况后续处理完全不同。
  • rows:预估要扫描的行数。和慢日志里的Rows_examined对比,差距巨大说明统计信息不准。
  • filtered:按条件过滤后剩多少比例。低值配合大rows,说明过滤条件没走到索引上。
  • Extra:信息最密集的一列。Using filesort(需要额外排序)、Using temporary(用了临时表)、Using index condition(ICP,通常是好事)、Using join buffer(JOIN 没走索引)、Using MRR

MySQL 8.0.18 起还有个EXPLAIN ANALYZE,它会真正执行语句并给出各阶段的实际耗时和实际行数,比预估版的rows靠谱得多。排查复杂 JOIN 时我几乎只用它。但要注意它会真实执行语句,别拿它去跑没加限定的UPDATE

5.2 统计信息不准会让优化器犯傻

rows这一列的估算依据是索引的基数统计,而这个统计是抽样得来的,会随时间漂移。典型的症状是:语句本身没问题,索引也建了,但EXPLAIN显示type = ALL,因为它"算错了"。

处理手段从轻到重:

-- 重新采样索引统计 ANALYZE TABLE t_order; -- 8.0 支持给特定列建直方图,对倾斜严重的列效果显著 ANALYZE TABLE t_order UPDATE HISTOGRAM ON status, merchant_id WITH 128 BUCKETS;

直方图这个功能知道的人不多,但它解决了一类老问题:数据分布严重倾斜时,等值查询的基数估算失准。比如status字段 99% 的行都是 1,只有 1% 是 3,优化器按平均数估就会走错路。建直方图之后,它对 3 这种低频值的估算会准很多。

还有个相关参数eq_range_index_dive_limit,默认 200。等值条件的组合数小于这个值时,优化器会用"索引下探"逐个取值精确计算,更准但更慢;超过之后退回抽样估算,更快但更粗。大表上跑大批量IN查询时,这个值会显著影响计划选择,值得关注。

5.3 优化器为什么不选那个看起来更优的索引

这是我被问得最多的问题。答案是唯一的:优化器比较的是"成本",不是"你想要什么"。成本模型基于 IO 次数和 CPU 代价的加权估算,而估算依赖统计信息。

要看清它的推理过程,打开optimizer_trace

SET optimizer_trace = 'enabled=on'; SET optimizer_trace_max_mem_size = 1048576; -- 在这里执行待分析的语句 SELECT id FROM t_order WHERE merchant_id = 9012 AND status = 3; SELECT * FROM information_schema.OPTIMIZER_TRACE\G SET optimizer_trace = 'enabled=off';

返回的 JSON 里,rows_estimation段会列出每个候选索引的估算行数和评估代价,considered_execution_plans段会给出最终选择及原因。我见过的最常见情形是:走 A 索引回表次数多,回表被认为很贵,于是优化器宁可全表扫——这在merchant_id过滤性差、create_time过滤性好的场景下特别常见。解法不是加更多索引,而是建覆盖索引减少回表,把回表成本降下来。

一个实操提醒:optimizer_trace有内存上限,追踪复杂语句时容易截断。排查单条语句时建议先把它单独跑一遍,不要夹在一批查询里。

6. 几类高频慢SQL的真实改法与原理

前面讲的是"怎么找到问题",这一节讲"找到之后怎么改"。下面这几类是我在实际项目里遇到频率最高的,每一类的改法背后都有明确的原理,理解了原理才不至于照抄。

6.1 隐式类型转换:索引在,但用不上

这是最隐蔽的一类。表里order_novarchar(32),应用传参时拼成了数字:

-- 索引失效 SELECT * FROM t_order WHERE order_no = 20240611000123;

原因是比较双方类型不一致时,MySQL 会做类型转换。字符串和数字比较,规则是把字符串转成数字再比。这就意味着列上套了一层隐式函数,B+ 树的有序性用不上,索引直接失效,退化成全表扫描。

同一类问题还有字符集不一致。两个字符集不同的列做 JOIN,或者在WHERE里混用不同排序规则的字面量,同样会导致转换、索引失效。这类问题在日志上的特征是:Rows_examined接近表总行数,但EXPLAINpossible_keys明明有索引。看到这个组合,第一反应就该去查字段类型和字符集。

6.2 函数包列与前缀通配符

-- 索引失效:列上做了运算 SELECT * FROM t_order WHERE DATE(create_time) = '2024-06-11'; -- 改成可走索引的范围查询 SELECT * FROM t_order WHERE create_time >= '2024-06-11 00:00:00' AND create_time < '2024-06-12 00:00:00';

原理一样:DATE(create_time)是行级函数,每行都要算一次,索引顺序无法利用;改写成常量区间的范围查询,优化器就能用 B+ 树定位到起点顺序扫。这个改写是等价的,不会漏数据,比BETWEEN更稳妥的是左闭右开——BETWEEN两端都闭,遇到带小数秒的时间戳边界容易出偏差。

前缀通配符LIKE '%abc'同理,左模糊必然全扫。这类需求通常要靠搜索引擎或者倒排索引方案解决,不要指望在 MySQL 里靠 SQL 技巧绕过去。

6.3 深分页:越翻越慢的老问题

-- 翻到第 50 万页,慢得离谱 SELECT * FROM t_order ORDER BY id LIMIT 10000000, 20;

LIMIT的语义是"先取出前 1000 万零 20 行,再丢掉前面 1000 万行"。所以页码越深,扫描量线性增长。可行有两种:

-- 方案一:延迟关联,先在索引上分页拿主键,再回表取数据 SELECT o.* FROM t_order o INNER JOIN ( SELECT id FROM t_order ORDER BY id LIMIT 10000000, 20 ) AS t ON o.id = t.id; -- 方案二:书签式翻页,记住上一页最后一条的 id SELECT * FROM t_order WHERE id > 10000000 ORDER BY id LIMIT 20;

方案一的原理是让分页动作只在聚簇索引或者覆盖索引上完成,避免了大量无谓回表;方案二是从根上避开深偏移,代价是不能随机跳页。实际业务里,如果前端是"下一页"的交互,方案二最优;如果需要跳页,方案一更合适。

6.4 JOIN 的驱动顺序和连接字段

JOIN 慢通常有两个来源。一是连接字段没索引,导致被驱动表每次都要全扫,EXPLAIN的 Extra 会显示Using join buffer。给被驱动表的连接列加上索引,这个提示就消失了。

二是驱动表选错。MySQL 的 Nested Loop Join 里,驱动表越小、进内层循环的次数越少、总代价越低。理论上优化器会自己选,但统计信息偏差时可能选错。这时候可以用STRAIGHT_JOIN强制顺序,或者用JOIN的书写顺序配合ANALYZE TABLE修正统计。

需要提醒的是:JOIN 的字段类型和排序规则必须完全一致,否则连接条件里会发生隐式转换,索引直接失效——这一条和 6.1 是同一个根因,只是发生在两表之间,更难察觉。

6.5 子查询与批量更新的写法陷阱

一个经典问题是"更新一张表,条件是它自己在某个子查询里的结果":

-- 会报 ERROR 1093:不能在子查询里引用正在更新的表 UPDATE t_order SET status = 9 WHERE id IN (SELECT id FROM t_order WHERE create_time < '2024-01-01');

MySQL 的限制是"不能在UPDATE/DELETE的子查询里直接引用目标表"。绕过的方法是把子查询包成派生表,让优化器把它物化成临时结果集:

UPDATE t_order SET status = 9 WHERE id IN ( SELECT id FROM ( SELECT id FROM t_order WHERE create_time < '2024-01-01' ) AS tmp );

另一种更值得推荐的做法是用JOIN改写:

UPDATE t_order o INNER JOIN ( SELECT id FROM t_order WHERE create_time < '2024-01-01' LIMIT 5000 ) AS tmp ON o.id = tmp.id SET o.status = 9;

这里多加的LIMIT是关键——大事务是慢SQL之外的另一大杀手。一次性更新几十万行,会持有大量行锁、生成巨大的 undo、主从延迟飙升。拆成多批,每批几千行,业务上完全无感,但对数据库的冲击天差地别。这个习惯我是吃过亏之后才养成。

7. 把慢日志变成可持续运转的分析资产

前面讲的都是一次性的排查动作。但慢SQL不是一次性的问题——只要业务在迭代,新的慢SQL就会源源不断冒出来。所以真正有价值的做法是把它做成一套常态化机制,而不是等出事再翻日志。

7.1 日志切割、保留与归档

第一步是让日志不失控。我惯用的logrotate配置大概是这个结构:

/var/log/mysql/slow.log { daily rotate 14 missingok notifempty compress delaycompress create 640 mysql mysql postrotate mysqladmin --defaults-file=/etc/mysql/rotate.cnf flush-logs endscript }

几个细节值得说。delaycompress让最近一份归档不压缩,方便快速查看;postrotate里的flush-logs必须执行,否则 MySQL 会继续往已经改名的文件句柄里写,新文件永远是空的——这个坑我踩过一次,当时盯着空白的slow.log怀疑了半天参数配置。归档目录放到独立分区,保留 14 天,够回溯一个迭代周期的问题。

7.2 用 Python 把日志汇总成统计口径

pt-query-digest是命令行工具,不方便做长期趋势对比。想每天看一眼"今天TOP10慢SQL和昨天比变化如何",自己写个解析脚本更灵活。核心逻辑就是把日志按块切分,抽出头部字段,然后按归一化后的语句模板聚合。

import re from collections import defaultdict BLOCK = re.compile( r"# Time: (?P<time>[^\n]+)\n" r"# User@Host: (?P<user>[^\[]+)\[[^\]]*\] @ (?P<host>[^\s]+)[^\n]*\n" r"# Query_time: (?P<qt>[\d.]+)\s+Lock_time: (?P<lt>[\d.]+)\s+" r"Rows_sent: (?P<rs>\d+)\s+Rows_examined: (?P<re>\d+)\n" r"(?:#\s.*\n)*" r"SET timestamp=\d+;\n(?P<sql>.*?);\n", re.S, ) # 把字面量统一成占位符,避免同一条语句被拆成成百上千个"不同"语句 LITERAL = [ (re.compile(r"'(?:[^'\\]|\\.)*'"), "?"), (re.compile(r"\b\d+\b"), "N"), (re.compile(r"\s+"), " "), ] def normalize(sql: str) -> str: for pat, rep in LITERAL: sql = pat.sub(rep, sql) return sql.strip().lower() def parse(path): text = open(path, encoding="utf-8", errors="ignore").read() stats = defaultdict(lambda: {"cnt": 0, "sum_qt": 0.0, "max_qt": 0.0, "sum_re": 0, "sum_rs": 0, "sample": ""}) for m in BLOCK.finditer(text): d = m.groupdict() key = normalize(d["sql"]) s = stats[key] qt, re_, rs = float(d["qt"]), int(d["re"]), int(d["rs"]) s["cnt"] += 1 s["sum_qt"] += qt s["max_qt"] = max(s["max_qt"], qt) s["sum_re"] += re_ s["sum_rs"] += rs if not s["sample"]: s["sample"] = d["sql"].strip()[:200] return stats if __name__ == "__main__": stats = parse("/var/log/mysql/slow.log") rows = sorted(stats.items(), key=lambda kv: kv[1]["sum_qt"], reverse=True) for key, s in rows[:10]: print(f"次数={s['cnt']:<6} 总耗时={s['sum_qt']:.1f}s " f"均耗时={s['sum_qt']/s['cnt']:.3f}s " f"扫描/返回={s['sum_re']//max(s['sum_rs'],1)}") print(f" {s['sample']}\n")

这段脚本的关键设计在normalize函数。如果不对字面量做归一化,每条语句都会因为参数不同而成为独立的key,聚合就失去了意义——这正是mysqldumpslow内部做的事。数字替换成N、字符串替换成?之后,同一类语句才会归到一组。

把这套脚本挂到定时任务上,每天生成一份统计,再写进一张记录表,就能画出趋势曲线:某条语句的总耗时是不是在涨、扫描行数有没有恶化。这比"出事了再翻日志"要主动得多。

7.3 每天五分钟的例行巡检

工具之外,习惯更重要。我固定每天花几分钟看几个数,形成肌肉记忆:

  • 昨天慢查询总条数,和前 7 天均值比,涨了 30% 以上就要查原因。
  • 按总耗时排序的前 5 条,有没有新面孔。
  • 有没有Lock_time / Query_time > 0.5的记录,有就去看当前长事务。
  • mysql.slow_log或者日志文件体积有没有异常增长。

这套动作花不了几分钟,但能在故障发生前一两天就发现问题苗头。慢查询治理最大的敌人不是技术难度,而是"没人看"。

8. 几个我实际踩过的坑,供你少走弯路

理论说完了,最后把几个真实的失败经验摊开讲,这些在文档里基本找不到。

第一个坑:开完日志第二天磁盘满了。原因是我把阈值调到 0.1 秒,又同时打开了log_queries_not_using_indexes却没设节流。没走索引的小语句疯狂刷日志,一天 40GB。教训是这两个参数永远不要同时无节制地开log_throttle_queries_not_using_indexes是必须配的保险丝。

第二个坑:慢日志里找不到那条"明明很慢"的语句。排查了很久才发现,那条语句是从库上回放的,而log_replica_statements默认是关的。主从架构下一定要记得单独打开这个开关,否则你会以为慢SQL消失了。

第三个坑:加完索引反而更慢了。现象是写接口的延迟涨了。原因是新加的索引让这条语句有了两个可选路径,优化器在某些参数下选错了,而且多出来的索引还增加了写放大。后来用optimizer_trace确认了选择逻辑,改成覆盖索引并删掉冗余索引才稳定。这件事让我记住一条:加索引之前先看possible_keys,评估会不会改变现有语句的计划选择

第四个坑:改名日志文件后新文件一直是空的。就是前面提到的flush没做。手动mv文件之后必须让 MySQL 重新打开文件句柄,否则它还在往老文件写。

第五个坑:把long_query_time设得太低导致缓冲区溢出。有个很反直觉的点——在极高并发的实例上,把long_query_time设成 0.01 秒这样的极端值,会让海量语句进入慢日志记录流程,虽然单条开销很小,但累加起来会挤占正常的查询处理资源。极端阈值只在短时间排查时用,不要长期挂着。

说到底,慢查询日志是个"事后工具",它只能告诉你哪儿慢过。想更进一步,得配合performance_schema的实时统计、配合代码层的埋点和链路追踪,才能把"慢"精确定位到某行代码、某个接口。但无论上多少高级工具,先把慢查询日志吃透——知道它记录什么、不记录什么、每个字段什么意思、日志怎么聚合怎么比对——这些都是后面一切分析的地基。地基不牢,再花哨的看板也只是把一堆没归类的数据画成图而已。

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

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

立即咨询