做后端这几年,每次线上数据库出问题,我最怕看到的不是报错,而是监控大屏上CPU突然飙到90%、接口超时率直线上升的那个瞬间。一问同事,十有八九又是慢查询在作祟。MySQL慢查询这个东西,说大不大、说小不小,但如果没人管,它是真的能把整个业务拖垮的。
今天分享的这套方法,是我在几次大促和线上高负载场景里慢慢磨出来的:用一套轻量脚本,对MySQL的慢查询做秒级定位和自动化分析,日常排查从原来的人工翻日志变成一行命令搞定。文章会把我踩过的坑、脚本的设计思路、完整实现和后续优化方向都拆开讲清楚,适合正在被线上慢SQL折磨的后端开发、DBA和运维同学参考。
1. 慢查询为什么会拖垮整个业务
1.1 慢SQL引发的连锁反应
很多人对慢查询的第一反应是“不就是查询慢一点吗”,但真实线上场景远没有这么温和。慢查询的杀伤力,往往不是它自己慢,而是它引发的连锁反应。
我举一个实际发生过的例子。某个报表接口每天凌晨批量跑统计,里面有一条多表JOIN的SQL,因为缺索引,单次执行要8秒。平时白天没人调它,大家也没当回事。结果某天大促期间,业务方手动触发了一次报表任务,这8秒的SQL在高并发下瞬间堆积,几十个会话同时卡在这条SQL上,数据库的连接池被打满。后面的正常下单请求拿不到连接,全部排队等待,接口RT从50毫秒一路涨到10秒以上,最后整个订单服务超时雪崩。
这个例子很好解释慢查询的连锁机制:一条慢SQL占用数据库连接和CPU资源,资源被占满后,其他正常查询也要排队,连接池被耗尽后,应用服务器的线程也在阻塞等待,最终导致整个服务链路不可用。很多团队在故障复盘时只盯着“接口超时”,其实根因往往就藏在那几条慢SQL里。
慢查询还有一个容易被忽视的影响:主从延迟。在主从架构里,如果主库上有一条长时间运行的慢SQL,它产生的binlog会延迟传输到从库,从库执行时也要花费同样长的时间,主从延迟会明显拉大。一旦主库发生故障需要切换,从库的数据来不及追平,就会面临数据不一致的风险。
1.2 传统排查方式为什么低效
我刚工作的头两年,排查慢查询基本靠三步:登录服务器、打开慢查询日志、grep关键字。这套方法在小规模场景下勉强能用,但一旦数据量上来,痛点非常明显。
第一个痛点是“来不及”。线上故障都是突发的,等你手动登录服务器去翻日志,业务已经受了影响。而且SHOW PROCESSLIST看到的只是瞬间状态,很多慢SQL执行完就消失了,你根本来不及捕捉。第二个痛点是日志文件太大。慢查询日志在高峰期一天能涨到几个GB,用grep和vim去翻,肉眼根本看不完,更别提手工统计哪些SQL出现次数多、总耗时长。第三个痛点是缺少自动化。每条SQL都靠人肉判断,没有聚合、没有阈值、没有告警,经常是同样的问题隔三差五重新爆发一次。
后来我开始尝试用Percona Toolkit里的pt-query-digest,功能确实强大,能对慢日志做聚合分析,但部署和依赖管理比较重,在一堆机器上推广成本不低。我的需求其实很简单:故障时能快速看到“现在有没有慢SQL、是哪几条”,日常能自动汇总“过去一小时慢SQL的Top列表”,异常时能通知到人。带着这个需求,我决定自己写一套脚本。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 秒级定位的思路设计与工具选型
2.1 四步闭环:采集、解析、定位、告警
整套方案我把它定义为四个环节:采集、解析、定位、告警。采集解决的是“日志从哪来”的问题,解析解决的是“日志怎么变成结构化数据”的问题,定位解决的是“异常SQL在哪”的问题,告警解决的是“怎么让人知道”的问题。四个环节串起来,就是一个完整的自动化闭环。
采集环节的输入是MySQL慢查询日志,或者是performance_schema里的统计表。解析环节用mysqldumpslow或者自己写的SQL聚合逻辑,把原始日志变成按SQL模板聚合的统计信息。定位环节负责在故障发生时快速抓取当前正在执行的慢SQL,输出到标准输出,方便人工马上看到。告警环节则把分析结果推送到工作群或者邮件,让值班人员第一时间感知异常。
这里面的关键点在于:日常分析和故障定位要分开。故障时要的就是“快”,一条命令立刻看到问题;日常分析要的是“全”,把一段时间内的慢SQL都汇总出来,找趋势、找规律。两个场景的需求不一样,脚本设计上也要拆成两条路径,而不是一套逻辑硬套。
2.2 自研脚本和其他方案怎么选
做技术选型的时候,我认真对比过几个方案。Percona Toolkit功能最全,但依赖Perl环境,在最小化安装的服务器上部署要补一堆包,而且它擅长的是“事后分析”,对实时定位帮助有限。mysqldumpslow是MySQL自带工具,轻量但只能处理日志文件,不能直接对接performance_schema,也不能自己发告警。商业监控平台像New Relic、Datadog也有慢查询分析模块,效果确实好,但成本高,而且要接入一堆agent,不是所有团队都能接受。
自研脚本的好处是可控性强,脚本本身不依赖外部服务,服务器上只要有MySQL客户端和crontab就能跑,搞出来的报告还能和团队内部的通知系统对接。而且逻辑完全透明,出问题了自己能改,不需要等厂商支持。
方案对比下来是这样的:
| 方案 | 部署成本 | 实时定位能力 | 自动化告警 | 可定制性 |
|---|---|---|---|---|
| pt-query-digest | 较高 | 弱 | 需二次开发 | 中 |
| mysqldumpslow | 低 | 弱 | 无 | 低 |
| 商业监控平台 | 高 | 强 | 强 | 中 |
| 自研shell脚本 | 低 | 强 | 强 | 高 |
对于大多数中小团队来说,自研脚本是性价比最高的选择。尤其是MySQL已经开启performance_schema的情况下,很多定位工作根本不需要解析日志文件,一条SQL就能从内存表里把慢SQL捞出来,速度是秒级的。
2.3 所谓“一行脚本”是怎么落地的
这里解释一下标题里说的“一行脚本”。我并不是把所有逻辑都塞进一行命令里,那样既不优雅也不便于维护。我的做法是:把一个完整的slow_query_analyze.sh脚本部署到服务器上,日常使用只需要执行一行命令,参数可以灵活传入。比如:
bash复制./slow_query_analyze.sh 10 20
这行的意思是:分析最近10分钟内最慢的20条SQL。脚本内部会做配置检查、采集、聚合、输出报告等一整套操作。对使用的人来说是“一行”,对维护者来说是“一个文件”。这样做的好处是降低了使用门槛,团队里任何人接到告警都能跑,不用记复杂的MySQL命令。
实际在故障处理时,我还会用另一条更轻量的定位命令,直接看当前正在执行的慢SQL,这个放在后面的章节详细讲。
3. 核心脚本实现与自动化分析落地
3.1 第一步:把慢查询日志配置好
脚本再厉害,源数据没配好也是白搭。MySQL慢查询日志的默认配置在实际生产环境中基本不可用,原始默认值是long_query_time=10,也就是只有超过10秒的SQL才会被记录。这个阈值太宽松了,线上很多3秒、5秒的慢SQL完全不会出现在日志里。
我的生产环境推荐配置如下:
ini复制[mysqld]
slow_query_log = ON
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1
log_queries_not_using_indexes = ON
min_examined_row_limit = 100
long_query_time设置为1秒,意味着超过1秒的SQL都会被记录下来。有人觉得1秒太敏感,日志会很大,我的经验是:日志大不是问题,自动化脚本会定期聚合清理,但如果阈值设得太大,很多隐患根本暴露不出来。log_queries_not_using_indexes建议开启,它能记录那些没有走索引的SQL,即使执行时间很短。这类SQL平时看着无害,数据量一旦涨上来,就是第一批挂掉的。
如果不想重启MySQL,也可以在线修改:
sql复制SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
SET GLOBAL log_queries_not_using_indexes = 'ON';
注意long_query_time的在线修改只对新连接生效,已有连接还是会按旧阈值判断,这一点排查时容易踩坑。
3.2 第二步:用一条SQL实现秒级定位
故障发生时,最快的定位方式不是翻日志,而是直接查information_schema.processlist,看当前有哪些SQL正在执行、执行了多久。这条命令我每天都在用,可以说是慢查询定位的第一板斧:
sql复制SELECT id, user, host, db, command, time, state,
LEFT(info, 200) AS sql_text
FROM information_schema.processlist
WHERE command != 'Sleep'
AND time > 3
ORDER BY time DESC;
这条SQL的核心就是:把处于非Sleep状态的连接都捞出来,只保留执行时间超过3秒的,按执行时长倒序排列。info字段是正在执行的SQL文本,用LEFT()截断避免输出太多内容。执行时间超过3秒但还在跑的SQL,基本就是拖慢业务的元凶,可以直接针对它做EXPLAIN分析。
如果需要看更完整的统计信息,可以查performance_schema的聚合表。MySQL 5.7及以上版本里的events_statements_summary_by_digest表非常有用,它把SQL按归一化模板聚合,能直接看出哪些SQL模板累计耗时最高。查询语句我封装成了下面这样:
sql复制SELECT SCHEMA_NAME AS db,
DIGEST_TEXT AS sql_template,
COUNT_STAR AS exec_count,
ROUND(SUM_TIMER_WAIT/1000000000, 2) AS total_ms,
ROUND(AVG_TIMER_WAIT/1000000000, 2) AS avg_ms,
ROUND(MAX_TIMER_WAIT/1000000000, 2) AS max_ms,
ROUND(SUM_ROWS_EXAMINED/COUNT_STAR, 0) AS avg_rows_examined
FROM performance_schema.events_statements_summary_by_digest
WHERE DIGEST_TEXT IS NOT NULL
AND LAST_SEEN >= NOW() - INTERVAL 10 MINUTE
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 20;
这里的时间单位要注意,performance_schema里的TIMER_WAIT单位是皮秒(1秒=10^12皮秒),所以要用/1000000000转成毫秒。这个查询能秒级返回最近10分钟内最耗时的20个SQL模板,基本就是“秒级定位”的核心实现。
3.3 第三步:自动化聚合分析与报告生成
日常分析场景里,不能每次都手动敲SQL,我需要一个定时任务,把一段时间内的慢查询自动聚合,生成一份可读的报告。这一块我的脚本主要做了三件事:调用mysqldumpslow处理日志文件、补充performance_schema的统计信息、输出格式化报告。
mysqldumpslow是MySQL自带的日志分析工具,参数设计得很实用。-s at表示按平均耗时排序,-t 20表示只要前20条,-a表示不打散SQL里的具体参数值。用法如下:
bash复制mysqldumpslow -s at -t 20 -a /var/log/mysql/slow.log
输出结果会展示每条SQL模板的执行次数、平均耗时、总耗时等信息。不过有个小坑:MySQL 5.6及更早版本的mysqldumpslow可能不支持-a参数,跑的时候会报错,老版本环境里去掉就行。
为了把日志分析和实时统计结合起来,我的脚本核心逻辑是这样写的:
bash复制#!/bin/bash
# 用法: ./slow_query_analyze.sh [分钟窗口] [TopN]
MIN_WINDOW=${1:-10}
TOP_N=${2:-20}
LOG_FILE=/var/log/mysql/slow.log
MYSQL_CMD="mysql -u监控账号 -p密码 --batch --skip-column-names"
echo "===== 当前正在执行的慢SQL(超过3秒) ====="
$MYSQL_CMD -e "
SELECT id, user, db, time, state, LEFT(info, 150)
FROM information_schema.processlist
WHERE command != 'Sleep' AND time > 3
ORDER BY time DESC;"
echo ""
echo "===== 最近 ${MIN_WINDOW} 分钟内耗时的SQL模板 Top ${TOP_N} ====="
$MYSQL_CMD -e "
SELECT SCHEMA_NAME, DIGEST_TEXT, COUNT_STAR,
ROUND(SUM_TIMER_WAIT/1000000000,2) AS total_ms,
ROUND(AVG_TIMER_WAIT/1000000000,2) AS avg_ms
FROM performance_schema.events_statements_summary_by_digest
WHERE LAST_SEEN >= NOW() - INTERVAL ${MIN_WINDOW} MINUTE
AND AVG_TIMER_WAIT/1000000000 > 1000
ORDER BY SUM_TIMER_WAIT DESC
LIMIT ${TOP_N};"
echo ""
echo "===== 慢查询日志聚合(最近${MIN_WINDOW}分钟内新写入的日志) ====="
tail -n 10000 "$LOG_FILE" | mysqldumpslow -s at -t "${TOP_N}" -a 2>/dev/null || echo "日志文件为空或无权限"
脚本里的AVG_TIMER_WAIT/1000000000 > 1000这个条件很关键,它把平均耗时超过1秒的SQL模板过滤出来了,这样聚合结果聚焦在真正的慢查询上,不会把普通SQL都列出来干扰判断。实际跑出来的报告大概长这样:
text复制===== 最近 10 分钟内耗时的SQL模板 Top 10 =====
sales_report_db SELECT * FROM orders o JOIN order_items i ON o.id=i.order_id WHERE o.status=? ORDER BY i.create_time DESC 执行次数: 120 总耗时: 124800ms 平均耗时: 1040ms
看到这样的输出,问题定位就非常直观了。
3.4 第四步:定时任务与告警联动
报告生成之后,还要解决“无人值守”的问题。我的方案是用crontab挂一个定时任务,每个整点跑一次分析,发现异常就推通知。
定时任务这样配置:
bash复制0 * * * * /opt/scripts/slow_query_analyze.sh 60 20 >> /var/log/slow_analyze.log 2>&1
含义是每小时的第0分钟执行一次分析,窗口是过去60分钟,输出Top 20。结果追加到日志文件,方便追溯历史。
告警部分可以用一个简单的webhook推送。现在企业微信、钉钉、飞书都有自定义机器人,原理都是往一个URL POST一段JSON。脚本里我留了告警函数,当检测到平均耗时超过阈值的SQL时触发推送:
bash复制send_alert() {
local text="$1"
curl -s -X POST "$WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d "{\"msg_type\":\"text\",\"content\":{\"text\":\"$text\"}}" >/dev/null 2>&1
}
触发条件可以灵活设置,我的经验是看两个指标:平均耗时是否超过阈值、执行次数是否超过某个量级。单独一条慢SQL可能只是偶发,比如大事务、锁等待,但如果某条SQL模板在1小时内执行了几百次且每次都慢,这就是稳定的性能缺陷,必须立刻处理。
4. 实战中的典型坑位与排查技巧
4.1 慢查询日志“没生效”到底卡在哪
这个坑我踩过不止一次。明明在MySQL里执行了SET GLOBAL slow_query_log = 'ON',但show variables like 'slow_query_log'查出来也是ON,日志文件就是一直不更新。后来排查发现,log_output参数默认是FILE,日志确实写文件,但slow_query_log_file指向的目录对mysql用户没有写权限,MySQL只能静默放弃写入。
另外还有一个隐蔽问题:如果之前用SET GLOBAL改过参数,但没写进配置文件,重启之后参数会全部还原。我现在的习惯是:全局参数和配置文件同步修改,先改配置再在线调整,双保险。排查日志问题时,可以用下面的命令确认当前实际生效的参数:
sql复制SHOW VARIABLES LIKE 'slow_query_log%';
SHOW VARIABLES LIKE 'long_query_time';
SHOW VARIABLES LIKE 'log_queries_not_using_indexes';
SHOW VARIABLES LIKE 'log_output';
4.2 时间字段和时区:容易被忽略的偏差
performance_schema里很多时间字段是相对时间,比如TIMER_WAIT表示语句执行耗时,和系统时间无关。但events_statements_summary_by_digest表里的LAST_SEEN、FIRST_SEEN字段是绝对时间,它的值受MySQL时区设置影响。
我遇到过一次很诡异的情况:脚本按最近10分钟过滤,跑出来却是空的,但明明刚看过有慢SQL。查了一圈发现是MySQL会话的时区设置和系统时区差了8个小时,NOW()函数返回的时间和LAST_SEEN记录的时区不一致,导致过滤条件把数据都滤掉了。
解决办法是在连接MySQL时显式指定时区:
bash复制mysql -u监控账号 -p密码 --default-time-zone='+8:00' --batch
或者在脚本里用CONVERT_TZ()函数做时间转换。这类问题不踩一次真不容易发现,数据对不上时先查时区,能省不少排查时间。
4.3 误报、噪音与阈值策略
自动化脚本跑起来之后,最大的问题是误报。刚开始我把long_query_time设成1秒,结果告警群一天能收到几十条通知,因为很多慢SQL都是后台批处理任务,它们本来就该在凌晨跑,跑个5秒无可厚非。如果我们一视同仁地告警,值班同学很快就麻木了。
后来我总结出一套“黑名单+分级”策略。先分析一周的慢查询,把那些已知的批处理任务、定时维护任务的SQL模板特征提取出来,加入白名单,不参与告警。剩下的SQL再按耗时和频率分成几个级别:平均耗时1秒到3秒的,只在日报里体现;平均耗时超过3秒或者单次执行超过10秒的,立即告警。
这个策略要用好,重点是要抓“变化”。同一批SQL之前执行都很快,最近突然变慢了,说明要么数据量涨了,要么索引失效了,要么锁竞争严重了,这种变化趋势比单次慢查询更有价值。
4.4 基于历史快照做趋势对比
为了让“变化”可见,我的脚本每天会生成一份完整的快照,存到独立的目录里:
text复制/var/lib/slow_query_reports/
└── 2024-11-20.json
└── 2024-11-21.json
快照内容是把当天的Top SQL模板、执行次数、平均耗时等信息序列化保存。需要对比的时候,写个简单脚本把两天的关键指标拿出来做diff,看哪些SQL的耗时涨了50%以上,这些SQL就是潜在风险点。
趋势对比有点像体检报告,单看某一天的数据很难判断异常,但连续看一个月的趋势,问题就非常明显。我通过这个机制提前发现过好几次隐患:比如某张订单表数据量从500万涨到800万,关联查询的执行时间从800毫秒涨到2.5秒,虽然还没触发告警阈值,但按这个趋势下个月就会出问题。提前优化索引,把风险扼杀在摇篮里。
5. 把脚本进化成业务侧的“慢查询助手”
5.1 结合EXPLAIN和索引建议
定位到慢SQL只是第一步,怎么优化才是重头戏。脚本输出报告之后,我通常会对Top SQL执行EXPLAIN,重点看type字段、key字段、rows字段。type如果出现ALL(全表扫描)或者index(全索引扫描),基本就是索引设计有问题;rows如果比实际返回行数大几个数量级,说明过滤性很差。
我的习惯是把常见的索引建议直接沉淀成checklist写进报告模板里,让不熟悉数据库的业务同学也能看懂:WHERE条件里等值查询的字段优先建联合索引;ORDER BY字段和WHERE字段尽量建在同一个索引里,避免文件排序;LIKE '%xxx%'这种前模糊匹配索引会失效,考虑用全文索引或者搜索引擎。
5.2 主从实例分开监控
还有一个容易忽略的点:慢查询日志是每个MySQL实例独立的,主库的慢SQL不能代表从库的情况。主库的读压力主要来自业务请求,从库的读压力来自主从复制和报表查询,两者的慢查询特征完全不同。主库上要重点监控写入相关的SQL,比如UPDATE和INSERT;从库上要重点监控复制执行线程的耗时,以及大查询带来的CPU压力。
我的脚本做了参数化支持,可以传入实例的连接信息:
bash复制./slow_query_analyze.sh 10 20 --host=10.0.1.5 --port=3306
这样同一套脚本可以管理多套实例,每套实例的报告放在不同目录,告警也由不同的webhook接收,避免业务线和运维线消息互相干扰。
5.3 跑了大半年后我的几点体会
这个方案上线跑了半年多,最直观的改变是:慢查询定位从原来的一小时起步变成了现在的秒级响应。以前凌晨收到告警,要把自己从床上叫起来登录服务器去翻日志,现在只需要打开手机看一眼推送的报告,基本就能判断是哪个SQL、什么类型的问题,到公司直接处理就行。
但我必须说一句实在话:脚本只是帮我们发现问题的工具,真正解决问题还得靠SQL优化和规范落地。我见过不少团队花了大价钱上监控平台,慢查询报告每天几百页,但索引该不建的还是不建,代码里该写的参数化查询还是直接拼字符串,问题反反复复。没有一套制度把这些分析结果转化为优化动作,再好的工具也只是个华丽的告警器。
我的建议很简单:把脚本分析出来的Top SQL,每周挑出前5条,每条花半小时认真做一次索引优化或者SQL改写。坚持一个月,你就能明显感受到线上慢查询数量在下降。如果后续数据量继续涨,可以考虑在代码层面引入更细粒度的SQL耗时监控,把慢查询分析和业务链路结合起来,这是从“发现慢SQL”到“理解为什么慢”的必经之路。
