凌晨两点,手机连着震了十几下。某个核心业务系统的查询接口超时率直接飙到40%,数据库CPU顶着99%不下来。登到生产库上看了一眼,一条SELECT语句堂而皇之挂在processlist里,跑了快30秒还没结束,扫描行数大几百万。这种场景,做过后端、搞过数据库运维的同学应该都不陌生——线上慢SQL引发的故障,来得毫无征兆,处理起来却是一个通宵起步。
我最初的处理方式和大多数人一模一样:开慢日志、捞processlist、把SQL丢给开发同学一个EXPLAIN结果。时间长了发现,这套流程有个巨大的缺口——“知道哪条SQL慢”和“搞清楚它为什么慢、怎么修才真的有效”之间,隔着一整条方法论的距离。这也是我今天想聊的核心:把慢SQL自动识别与压测复现串成一条完整的闭环。文章会覆盖识别方案的选型思路、压测场景怎么设计、复现之后如何定位根因、修复之后用什么标准验收,也穿插一些我自己凌晨踩过的坑和教训。如果你正在被线上慢SQL反复折磨,或者准备搭建一套性能排查体系,这篇文章应该能给你完整的参考。
1. 为什么要把识别和复现做成一套闭环
1.1 识别只是第一步,复现才是拉开差距的地方
先讲一个我身边发生过的真实案例。某开发同学接到一个线上告警,说某查询接口P99耗时从200ms涨到了2秒。他登录数据库,从慢日志里捞出一条SQL,发现where条件里的时间字段有函数运算,看起来像是索引失效。他直接在测试库上执行这条SQL,结果20ms就返回了,于是他得出结论:线上数据量太大,索引没有问题。后来投诉越来越多,最后发现测试库那张表一共只有5万行,而线上有2000万行,分布特征完全不同——同一执行计划在两种数据规模下的表现天差地别。这就是只识别不复现的典型翻车现场。
识别解决的是“有没有问题、问题在哪”,复现解决的是“为什么会有问题、修了之后有没有用”。很多团队把大量精力花在采集和告警上,以为知道哪条SQL慢就完事了。实际上,如果不能把生产环境那种“慢”的状态稳定复现出来,你连问题根因都没法确认,更别提验证优化方案了。我在实践中越来越觉得:复现能力,才是一个团队数据库性能排查能力的分水岭。识别是眼睛,复现是手,两只都得有。
1.2 闭环链路长什么样:采集、建模、压测、定位四段式
我搭的这套闭环,本质上是一条从生产环境到测试环境的反馈链路,拆开来看五个环节:
- 采集与识别:通过慢日志、性能视图、中间件三层来源,把慢SQL抓出来,做指纹归一化和指标记录。
- 场景建模:从识别结果中提取SQL模板、参数分布、并发规模,在测试环境构造贴近生产的数据和负载。
- 压测复现:用可控的压测脚本在测试环境重放场景,观察是否出现同样的慢特征。
- 根因定位:在复现成功的状态下采集执行计划、等待事件、资源指标,定位真正的问题点。
- 修复验证:实施优化后跑同一套压测场景,用数据对比确认修复是否有效,再回归上线。
这五个环节环环相扣。识别结果如果不做结构化沉淀,场景建模就只能拍脑袋;压测如果不判定复现成功,根因定位就是无本之木。很多人觉得慢SQL治理就是“分析+优化”,但少了复现和验证这两环,前面的工作做完也可能只是白忙。
2. 慢SQL自动识别的方案拆解与选型
2.1 三条识别路径:慢日志、性能视图、中间件采集
我见过很多团队做慢SQL识别,方式五花八门,但归纳起来其实就三条路。这三条路各有适用场景,不是互相排斥的关系,实际可以组合使用。
第一条是数据库自带的慢日志。以MySQL为例,开启slow_query_log、设置long_query_time阈值,超过阈值的SQL就会落盘。这条路成本最低,适合抓全量数据做离线分析。我在生产上一般会把long_query_time设为1秒,同时打开log_queries_not_using_indexes,把那些没走索引的查询也记录下来。要特别注意风险:高峰期全量慢日志的写入量可能非常大,对IO有额外压力。建议设置log_output=TABLE,或者定期清理归档,避免日志文件无限膨胀拖垮磁盘。
第二条是性能视图轮询。定时从information_schema.processlist或sys库的statement_analysis里抓快照,记录当前正在执行的SQL及状态。这条路的好处是实时性强、开销小,能够捕捉到慢日志里来不及记录的瞬时问题。我在实践中会每15秒抓一次processlist,连续多次出现、且STATE在Sending data或executing的SQL,优先标记。这条路做不到100%覆盖,但配合指标突增的场景,能快速定位当前正在拖垮数据库的元凶。
第三条是中间件拦截。在应用层的ORM拦截器或者数据库代理层,记录每条SQL的执行时间、调用来源、TraceID。这条路采集最全,能直接关联到业务接口,缺点是要改动应用代码或代理层配置,且对性能有一定损耗。我一般在核心业务系统上用它做全链路追踪,识别出慢SQL后直接下钻到具体接口和用户请求。
三条路的对比如下:
| 识别路径 | 覆盖范围 | 实时性 | 数据库开销 | 适合场景 |
|---|---|---|---|---|
| 慢日志 | 全量超过阈值 | 离线(分钟级) | 中(日志写入) | 日常巡检、历史回溯 |
| 性能视图轮询 | 当前执行中 | 实时 | 低 | 故障定位、瞬时抓取 |
| 中间件拦截 | 全量业务链路 | 实时 | 中(应用侧) | 接口级追踪、根因关联 |
2.2 识别规则怎么定才不误报不漏报
识别路径确定之后,最重要的事情是定规则。阈值设得太严,告警轰炸,值班同学直接麻木;设得太松,真正的慢SQL又漏掉。我在实践中总结了一套组合规则,而不是单一的执行时间阈值。
第一层是单条耗时阈值。不同业务模块要有差异化配置,核心交易链路要求P95小于200ms,慢SQL阈值500ms;报表查询这类非实时场景,阈值可以放宽到3秒。第二层是扫描行数与返回行数比值。利用PERFORMANCE_SCHEMA的events_statements_summary_by_digest,可以计算rows_examined与rows_sent的比例——比值超过1000,基本可以判定索引设计有问题。第三层是频次与趋势。单次慢SQL可能只是偶发抖动,但如果同一个SQL指纹在10分钟内出现超过20次,或者平均耗时持续上升,就必须进入待复现清单。第四层是资源关联。如果SQL出现时伴随着数据库CPU、IO延迟同步上涨,这个SQL就是需要优先处理的“毒瘤”。
我建议把规则配置成可调节的,不要写死在代码里。比如用一个规则配置表,按模块、按时间窗口动态调整阈值,周一大促和平时凌晨的告警阈值就应该完全不一样。
2.3 识别结果的结构化沉淀
识别出来的慢SQL如果只是前端表格里看一眼,价值会大打折扣。我现在的做法是把每次识别结果落成结构化数据,每条记录包含以下字段:
- fingerprint:SQL指纹,把常量替换为占位符的归一化模板,比如where id = 123和where id = 456都归并为where id = ?
- sample_sql:一条典型SQL样本
- app_name、接口路径:关联到的业务模块
- 出现次数、平均耗时、P95耗时
- rows_examined、rows_sent
- 首次出现时间、最后出现时间
这个过程中最关键的步骤是SQL指纹归一化。同一业务SQL会因参数不同产生无数变体,不做指纹归并,统计出来的Top N全是同一条SQL的重复项。我写过一个简单的归一化函数,核心逻辑是把字符串、数字常量替换成占位符,再按空格压缩、把关键字统一大写,最后做hash。这套数据沉淀下来之后,压测场景的建模就有了直接依据,不用再靠猜。
3. 压测复现的完整实操流程
3.1 场景设计:数据规模和参数分布要贴近生产
压测复现要成立,第一件事就是让测试环境长在生产的“样子”上。这个“样子”包括两个方面:数据量和数据分布。
数据量方面,与慢SQL相关的表,至少要有生产数据量的60%以上,最好接近80%。少了这个量级,优化器很可能选择完全不同的执行计划,复现就是空谈。如果没法直接拷贝生产数据,就写存储过程造数,但要注意别只造均匀分布的数据——真实业务里订单表最近一个月的数据可能是三年前的几十倍,这种热度差异直接影响索引选择。
数据分布方面,要从识别结果里提取SQL模板和参数分布。比如一条订单查询SQL经常以status='PENDING'且create_time在一个时间范围作为条件,压测时就必须按生产统计出的枚举分布和范围分布来生成参数。只写死一两个参数跑出来的结果不具备代表性。
我自己踩过一个大坑:刚开始复现慢SQL,为了图方便直接拿生产数据逻辑备份恢复到测试库,以为一模一样的库测起来肯定准。结果发现线上那个让SQL变慢的关键条件是某个特定分区里的数据量占比,恢复后统计信息变了,测试库选择了另一个索引,慢SQL怎么都复现不出来。后来重新analyze table、手工调整统计信息,才终于让执行计划和线上对齐。
3.2 压测脚本与并发模型怎么写
压测目标分为两种级别。第一种是SQL级Solo压测,直接连接数据库执行目标SQL,用于迅速复现慢特征、观察执行计划;第二种是接口级压测,走完整业务链路,用于验证修复后用户的真实体验。我习惯先做SQL级复现,确认问题特征稳定后,再做接口级验收。
SQL级压测的脚本要能控制三个东西:并发数、每次请求的SQL参数、请求间隔。我用Python写过一套简易压测脚本,核心思路是线程池加参数池随机化。伪代码大概是:
import threading import random import mysql.connector CONCURRENCY = 50 # 并发线程数 DURATION = 120 # 压测时长(秒) PARAM_POOL = build_param_pool() # 从识别结果统计的真实参数分布 def worker(): conn = mysql.connector.connect(...) cursor = conn.cursor() end_time = time.time() + DURATION while time.time() < end_time: param = random.choice(PARAM_POOL) cursor.execute(SQL_TEMPLATE, param) print(cursor.rowcount, cursor.last_query_id) time.sleep(random.uniform(0.05, 0.1)) threads = [threading.Thread(target=worker) for _ in range(CONCURRENCY)] ...并发模型的设置要参考生产事实。从监控平台查目标接口高峰期的QPS和活跃连接数,以此为基准设置梯度:50%、100%、150%、200%。比如高峰期接口QPS是100,压测就从50并发起步,每个梯度跑2分钟,观察耗时曲线在哪里出现拐点。千万别一上来就怼1000并发,那是把测试库当牺牲品,自己给自己找麻烦。
接口级压测做法类似,只不过施压对象从数据库换成了应用服务的HTTP入口,用通用压测工具配置请求参数化即可。两种压测要保证参数池一致,这样对比才有意义。
3.3 压测执行时的数据采集与复现判定标准
压测跑起来之后,同步采集以下数据才能支撑后续分析:
- 慢日志状态:压测期间新产生的慢日志,看目标SQL是否稳定出现在里面。
- 数据库性能指标:CPU使用率、磁盘IO延迟、缓冲区命中率、连接数。
- 等待事件:通过性能监控视图抓取锁等待、IO等待、网络等待的分布。
- 执行计划快照:压测前后各执行一次EXPLAIN,对比优化器选择的索引是否一致。
压测结束之后要回答一个问题:到底复现出来没有?我定的复现成功标准有三条,必须同时满足。第一,目标SQL在压测中的P95耗时达到生产观察值的80%以上;第二,rows_examined的数量级与生产一致,比如生产扫描2百万行,压测至少要扫百万级;第三,执行计划与生产观察到的方案一致,包括索引选择、连接方式、排序策略。三条标准都满足,我才会说“可以定位根因了”。如果复现不出来,优先检查数据量、参数分布和数据库参数配置,别急着调压测脚本的并发数,那是本末倒置。
4. 从复现到根因:慢SQL定位的分析路径
4.1 执行计划是第一个突破口
复现成功之后,第一步永远是执行计划。EXPLAIN输出的关键字段:type列如果出现ALL就是全表扫描;key列是NULL代表没有可用索引;rows列是优化器估算的扫描行数,如果与实际严重偏离,通常说明统计信息过期或者SQL写法让优化器产生了错误判断;Extra列出现Using filesort或Using temporary,基本躲不开排序和临时表带来的性能损耗。
我见过的慢SQL很多死在最简单的写法上。一种是函数包住索引列,比如where DATE(create_time) = '2025-01-01',这一下就让索引失效,正确写法是where create_time >= '2025-01-01' and create_time < '2025-01-02'。另一种是隐式类型转换,字符串列和整型比较,或者字符集不一致导致隐式转换,走不了索引还平白增加计算量。还有一种是OR条件跨两个字段,导致优化器只能放弃索引。这些场景在EXPLAIN里一眼就能看出来,是根因定位里最直接的收获。
4.2 并发场景下被放大问题
执行计划正常不代表并发下不慢。很多时候单条SQL单独执行只要20ms,但并发一上来就变成2秒,这种“并发放大效应”是最考验功力的部分。放大的来源通常有三类。
第一类是锁等待。两个事务并发更新同一行或相邻区间,行锁互相阻塞。在压测期间抓取锁等待事件,能看到明显的lock wait持续上升。这种情况即使SQL执行计划完美,并发高了也会慢。解决办法往往是调整事务隔离级别、缩小事务范围、或者优化更新顺序。
第二类是连接池打满。应用侧连接池配置了最大连接数,数据库侧max_connections也有限。慢SQL一旦占住连接不放,后续请求全部排队等连接。这种场景下观察数据库active threads持续在一个高位,但CPU未必跑满,因为大量线程在等待。定位之后要考虑的是加速释放连接、减少占用时间,而不是盲目加大连接数。
第三类是资源争抢。压测跑到后期,磁盘IO延迟明显上升,缓冲区命中率下降,这时候SQL的慢已经不完全是自己造成的,而是整体资源瓶颈的受害者。这种问题要用压测曲线判断拐点:并发数翻倍,TPS却增长缓慢,RT开始陡增,说明系统已经进入过载区。
4.3 把根因写成可回溯的分析记录
定位到根因之后,我习惯把分析过程记录成一个可回溯的文档,内容包括:慢SQL指纹、生产观察指标、压测场景配置、复现成功证据、执行计划、等待事件、定位结论。这份记录的最大价值是后续回看。比如三个月后同样的SQL类型又出现慢查询,直接翻出来对比这次和上次的执行计划差异,往往比从零开始分析快好几倍。我一直把根因记录当成团队知识资产来做,而不是查完就扔的过程垃圾。
5. 修复验证与常态化运营
5.1 修复手段怎么选
修复手段大致三个层次,从上到下按投入产出比排优先级。
第一层是索引优化。给SQL的where条件、join条件、order by字段建立合适的二级索引或覆盖索引。核心目标是把扫描行数降到返回行数同量级。比如某查询扫了200万行返回100行,加一个联合索引之后扫2000行,慢自然就消失了。注意不要为了修一条SQL建一堆冗余索引,写操作和存储成本都在那摆着,一个索引尽量覆盖多个相似SQL。
第二层是SQL改写。把函数去掉、把OR改成UNION ALL、把子查询改成JOIN、分页用延迟关联。这些改法不依赖额外对象,执行计划变化立竿见影。改完后要在同一压测场景下验证,别改完只跑一遍“看起来行了”就完事。
第三层是架构调整。补索引和改SQL都救不了的情况才用这招,比如单表数据量过亿且业务需要全量扫描,那就要考虑归档历史数据、加缓存、读写分离。压测的价值在这个阶段尤其明显——用数据证明“加缓存后P95降到XXXms”,才能说服研发和运维同学投入架构改造的资源。
5.2 验证修复效果的数据对比口径
修复做完不是直接上线就完,得用同一套压测脚本、同一份参数池、同一个数据规模,跑修复前后的对比。我在报告里固定呈现几项数据:
- P50、P95、P99耗时以及最大耗时
- TPS
- rows_examined / rows_sent 的比值
- 数据库CPU、磁盘IO消耗
- 并发拐点变化
同一场景下,P95从2秒降到100ms、扫描行数从200万降到几千,这种量化对比才是让人信服的验收结果。这里有个容易踩的坑:修复后压测通过了,上线又变慢。原因通常是测试环境数据量还是不够、或者统计信息没更新导致优化器在线下选了“好”索引,线上统计信息不同又走了别的计划。所以验证通过后,上线前一定要重新ANALYZE TABLE并对比两边的执行计划。
5.3 沉淀成常态化机制
慢SQL不是修完一条就一劳永逸的事,数据量在涨、业务代码在变,问题永远会以新形式冒出来。我把这套闭环做成了常态化机制:
每天凌晨定时任务从慢日志和性能视图拉数据,做SQL指纹聚合,推送Top 10慢SQL和新增慢SQL到值班群。每周五下午自动跑一轮TOP慢SQL回归压测,数据范围限定在最近一个月新增或变更的SQL模板。每版本发布前,新增和变更的SQL进入Review流程,命中识别规则直接拦截,要求开发同学先提供压测报告再合并。每月的性能复盘,把慢SQL知识库翻一遍,看哪些优化模式在重复出现,反哺开发侧的SQL编写规范。
这套机制跑顺之后,慢SQL的治理从“救火式”变成了“常态化防守”,研发、DBA、运维的时间被大量释放出来。
6. 常见问题速查与避坑记录
6.1 反复出现的典型问题
我整理了实操中反复出现的几种问题,按“现象—原因—排查方法”整理成一张速查表:
| 现象 | 常见原因 | 排查方法 |
|---|---|---|
| 压测复现不出生产慢特征 | 数据量不足、参数分布失真、统计信息不一致 | 对比EXPLAIN和执行计划,重新analyze table |
| 慢日志文件涨得飞快 | 阈值设置过松或采样比例过高 | 调高long_query_time,按天归档清空 |
| 压测时测试库被打挂 | 并发起点太高、缺少限流保护 | 从低并发梯度起步,设置连接上限 |
| 修复后压测通过但上线仍慢 | 测试环境数据量不足、统计信息差异 | 拉大测试数据、上线前重新analyze并对比执行计划 |
| 目标SQL识别到了但定位不了 | 缺少等待事件和执行计划快照 | 压测时同时开慢日志、抓processlist、记录EXPLAIN |
| 同一指纹SQL一会儿慢一会儿快 | 参数嗅探、绑定变量导致执行计划不稳定 | 对比不同参数下的执行计划,评估加index hint |
这里面最值得展开的是“压测复现不出生产慢特征”这个老大难。除了上面提到的数据量问题,还有一个隐蔽原因:测试库的数据库参数和生产不一致。比如join_buffer_size相差几倍、innodb_buffer_pool_size设置不同,直接改变优化器的策略和实际内存操作耗时。我在搭压测环境时会把关键参数统一成生产和测试两套配置清单,确保复现结果不被环境差异污染。
6.2 我踩过的坑与独家小技巧
最后分享几个压箱底的经验。
第一,压测脚本里一定要打印执行计划标签。我早期的脚本只打印耗时,发现慢SQL复现出来后根本不知道线上那个计划到底有没有复现。后来在SQL里加上了plan_hash输出,每条执行的执行计划指纹一目了然。这个改动让复现判定从“凭感觉”变成了“看证据”。
第二,识别规则里的“频次”条件要放在阈值前面。我在做SQL指纹聚合时,先按出现次数排序,再按平均耗时排序,找出“量大且慢”的SQL,这种对系统的影响权重远高于“偶发但很慢”的野SQL。优先治理前者,能更显著地拉高整体服务质量。
第三,慢SQL治理不要只盯数据库本身。曾经遇到一条SQL在测试库压测怎么都复现不出生产那种惨烈耗时,后来才发现问题在应用侧——大量调用走了多次网络往返,每条请求额外付出了几十次RPC时间。那次之后我坚持压测复现时两端数据一起看:数据库端看执行耗时,应用端看从发起到拿结果的总耗时。两边差分之后,根因定位才真正精确。
我个人在实际操作中最大的体会是:慢SQL识别是技术活,压测复现是体力活,把这俩串成闭环才是真功夫。识别给方向,复现给证据,根因定位给答案,压测验证给信心。这套流程跑顺之后,线上再出现慢SQL告警,我的第一反应不再是从头开始查,而是翻历史记录、拉场景、跑复现、做对比,整个流程快了好几倍。