慢SQL自动识别与压测复现,是我在最近几个数据库性能优化项目里反复打磨的一套组合拳。识别出慢SQL只是拿到了问题清单,真正难的是把问题稳定复现出来,用压力测试确认瓶颈在哪、验证优化有没有效果。这篇文章会从采集、归一化、分级,到压测场景设计、回放落地,再到排障经验,完整讲一遍我的做法和踩过的坑。不管你是开发、DBA还是运维,只要线上曾经出现数据库CPU突然打满、接口超时堆积的情况,这套流程都可以直接借鉴。
1. 为什么要把识别和压测复现绑在一起
先说一个很多团队都经历过的场景。某天凌晨,监控平台弹出告警:数据库CPU飙到90%以上,慢查询数量暴涨。值班同学登录数据库,把慢日志里排在前面的几条SQL捞出来,发现有一条查询要跑8秒,扫描行数达到几百万。于是按照经验加了个索引,上线,感觉问题解决了。结果过了两天,同样的告警又来了,而且这次换了另一条SQL。
这就是典型的“重识别、轻验证”。识别出慢SQL只是第一步,线上到底发生了什么、为什么执行计划会选错、在多大并发下会出事,光看慢日志是回答不了的。压测复现的意义就在于把不确定变成确定:用可控的压力把SQL打到出现性能拐点,记录下优化的基线,再在同样的条件下验证改动后的表现。
我在实际项目里总结了一条原则:没有复现过的慢SQL,不要轻易下优化结论。原因很现实,测试环境一条SQL跑了50毫秒,不代表生产环境也能跑50毫秒。数据量差一个数量级,索引选择性完全不同,再加上并发争用、锁等待、内存缓存命中率这些变量,同一个SQL的表现可能天差地别。所以,慢SQL自动识别负责“找出嫌疑人”,压测复现负责“还原现场”,两者合在一起才能形成一个完整的闭环。
1.1 自动识别解决的是“范围”问题
慢SQL自动识别要解决的是效率问题。生产系统每秒可能有成千上万条SQL,人工翻日志不现实。通过阈值采集、SQL指纹聚合、多维分级,自动把海量日志压缩成Top N清单,让DBA和开发在几分钟内就知道该看哪几条。这个环节的核心是减少噪音,宁可多捞一些可疑语句,也不能漏掉真正危险的查询。
1.2 压测复现解决的是“根因”问题
压测复现则是把筛选出来的SQL放到接近生产的环境里,通过构造数据、控制并发、录制回放等方式,让性能问题稳定暴露出来。它不是简单执行一遍SQL就结束,而是要做对照实验。我经常把压测复现比作医疗里的“应激测试”,平时看不出问题,一上强度就原形毕露。只有把问题稳定复现,优化才有方向,验证才有依据。
2. 慢SQL自动识别:从采集到分级
这一节重点讲自动识别链路怎么搭。我的经验是分成四层:采集层、归一化层、分级层、告警层。每一层都有容易踩的坑,我会把关键细节展开说。
2.1 采集层:慢日志与实时指标要同时抓
慢查询日志是最基础的采集来源。拿MySQL举例,核心参数是long_query_time,默认值通常是10秒,这个阈值对于核心业务来说太高了,很多亚健康SQL根本记录不下来。我一般建议核心交易系统设置成0.5秒,激进一点可以到0.1秒,配合log_queries_not_using_indexes=ON把未走索引的查询也记录下来。
需要注意,阈值调低之后慢日志文件增长会非常快。如果QPS比较高,一天的日志可能几个GB到几十GB。解决办法是不要长时间全量保留原始日志,定期归档,或者把慢日志输出到表里,配合定时任务只保留最近N天的数据。
除了静态日志,我还会从实时性能视图中采集快照。MySQL可以用performance_schema,SQL Server有sys.dm_exec_query_stats,PostgreSQL有pg_stat_statements。这些视图能给出累计执行次数、平均耗时、扫描行数等指标,适合用来做实时态的分析。慢日志和实时指标有一个重要差异:慢日志里都是结果,而性能视图能告诉你一条SQL在某个时间窗口内被调用了多少次,这在判断“低频但极慢”和“高频但略慢”哪类更危险时非常关键。
2.2 归一化层:SQL指纹是聚合统计的前提
慢日志里同一个业务查询会以无数个变体出现,区别只在于查询条件中的具体值。例如:
SELECT * FROM orders WHERE user_id = 12345; SELECT * FROM orders WHERE user_id = 67890;如果不做归一化,这两条会被当成两条独立的SQL,无法聚合统计出“这类查询”的整体表现。归一化的标准做法是把字面量替换成占位符,同时保留SQL的主体结构,得到一个“SQL指纹”。比如上面两条都会变成:
SELECT * FROM orders WHERE user_id = ?;这一步做完之后,才能按指纹分组计算执行次数、最大耗时、平均耗时、扫描行数总和。社区常用的慢日志分析工具pt-query-digest内部就做了类似事情。我自己也写过简易的Python脚本,核心就是正则替换数字字面量和字符串字面量。
这里有个细节:归一化时要注意保留关键结构。把IN (...)列表压缩成一个占位符是可以的,但如果把所有等值条件都丢掉,两类不同访问路径的SQL会被错误合并。建议归一化之后保留表名、JOIN顺序、WHERE子句中的运算符和主要过滤列,这样分出来的类更有意义。
2.3 分级层:用多维评分替代单一阈值
单纯用执行时间一个维度来分级不够全面。一条SQL执行1秒但一天只跑一次,和一条SQL执行200毫秒但每秒跑500次,后者对系统的影响反而更大。所以我的分级模型会同时考虑执行耗时、扫描行数、执行频率这三个维度。
我在项目里常用的分级表如下:
| 级别 | 判定条件 | 处理建议 |
|---|---|---|
| P0 致命 | 单次执行超过5秒,或扫描行数超过100万,或高频执行中出现锁等待 | 立即处理,优先优化或下线 |
| P1 严重 | 单次执行1秒到5秒,或扫描行数10万到100万 | 纳入近期优化计划 |
| P2 警告 | 单次执行0.2秒到1秒,执行频率较高 | 持续观察,结合数据增长趋势评估 |
需要注意,这个表里的数值是参考值,不是标准答案。数据量比较小的系统,扫描100万行也许不算什么;核心交易系统,扫描10万行可能已经不可接受了。阈值的设定要根据业务特点来调整,我建议先用一周的慢日志数据做一次分布统计,找到大多数正常SQL的执行耗时区间,再把阈值设定在该区间上方的某个分位点。
2.4 告警层:别让告警变成噪声
识别出的慢SQL如果全部告警,等于没有告警。我的做法是告警只针对P0和持续恶化的P1,同时要求“连续N次采样都超过阈值”才触发。比如连续3次采样(每次间隔5分钟)都出现同一条SQL执行时间超过3秒,这时再发告警,有效过滤掉偶发抖动。
告警内容不要只丢一条SQL原文,要带上执行次数、平均耗时、扫描行数、最近一次出现的节点和数据库实例,这样接收人不用再去查监控就能初步判断严重程度。我习惯把告警推送到即时通讯群的机器人,同时沉淀到工单系统,方便后续跟踪处理。
3. 压测复现:让慢SQL原形毕露
自动识别告诉我们“谁是嫌疑人”,压测复现则要回答三个问题:瓶颈出现在哪个环节、在什么条件下出现、优化后是否改善。下面是我在复现阶段积累的方法。
3.1 为什么慢SQL经常无法复现
很多开发都遇到过这种情况:拿着生产环境的慢SQL回测试环境执行,居然只要几十毫秒,完全复现不出线上的慢。背后原因通常有这几类:
- 数据量不同。生产环境表里几千万行,测试环境只有几十万行,优化器选择的执行路径完全不同。
- 统计信息缺失或过期。没有执行
ANALYZE TABLE,优化器用的行数估算严重偏离真实值。 - 并发度不同。单线程执行一条SQL没有锁竞争,但生产环境同一时刻可能有几十个事务在争抢同一行数据。
- 连接池配置和数据库参数不同。
innodb_buffer_pool_size、连接数上限这些参数会影响缓存命中率和排队行为。 - 硬件差异。磁盘类型、CPU核数、内存大小都会影响最终耗时。
所以压测复现的第一个原则就是:尽量贴近生产。数据量、表结构、索引、数据库参数、并发模型,能一致就一致。
3.2 录制回放:拿真实流量来验证
只靠手工构造SQL往往不够真实,我倾向于从生产环境录制并回放流量。主要做法是分三步走:
- 从慢日志或审计日志中提取SQL文本。
- 做脱敏和归一化,把真实的用户ID、手机号等敏感字段替换成模拟值。
- 在测试环境按时间顺序回放,或者按比例随机回放。
回放要真正有效,需要控制好两个参数:回放并发度和回放速率。并发度参考生产环境的活跃会话数,速率参考生产环境的QPS分布。只是把日志里的SQL按顺序跑一遍,起不到压力测试的作用,因为真实系统里SQL是同时到达的。
实现回放时,可以自己写脚本,也可以基于社区压测工具来扩展。对于需要模拟完整业务调用链的场景,用JMeter这类工具会比较方便;对单纯的数据库负载回放,用sysbench配合自定义Lua脚本也能做到。我个人的习惯是轻量验证用脚本,完整回归才上重型工具。
3.3 建立基线:没有对比就没有优化
压测复现一定要有基线数据。我通常把流程定义为三步:
- 优化前压测:在测试环境执行压测,记录TPS、QPS、响应时间分布(TP50、TP95、TP99)、数据库资源消耗(CPU、IO、锁等待)。
- 执行优化:加索引、改写SQL、调整数据库参数等。
- 优化后压测:在同一环境、同一数据、同一并发模型下再跑一遍,对比指标。
这里有一个很容易被忽略的点:压测顺序会影响结果。如果先压优化后的版本,再压优化前的版本,缓存和数据块的状态可能已经不同。所以要么每次压测前重启数据库实例、清空缓存,要么多轮交替压测并取中间值。我倾向于在每轮压测前执行冷启动,保证公平。
3.4 压测场景设计不能只看单条SQL
慢SQL很少是孤立存在的。一条慢SQL拖慢事务,事务占用连接不释放,连接池被打满,新的请求进不来,最终表现为整个系统卡顿。所以压测场景里除了目标慢SQL,还要混入正常比例的在线请求。
我在设计场景时会给不同SQL分配权重,比如正常查询占70%,目标慢SQL占5%,写操作占25%,模拟真实业务混合负载。这样做的好处是能观察慢SQL对整体系统的影响,而不是只盯着它自己的单次执行时间。
4. 一套可以直接落地执行的具体方案
这一节我会把整个流程串起来,给出可以照着做的步骤,包含配置示例和脚本思路。方案不一定适用于所有团队,但框架是通用的。
4.1 第一步:部署慢SQL自动采集与识别
MySQL环境下的最小配置如下:
slow_query_log = ON slow_query_log_file = /var/log/mysql/mysql-slow.log long_query_time = 0.5 log_queries_not_using_indexes = ON配置完成后,用社区工具定时分析慢日志:
pt-query-digest /var/log/mysql/mysql-slow.log --limit=20 --out=/data/slowlog_report.txt命令执行后会按总耗时排序,把排名靠前的SQL指纹和统计信息输出成报告。可以放到定时任务里,每5分钟跑一次,只对增量日志做解析。
如果希望更灵活,也可以写Python脚本解析慢日志。下面是一个极简示例,思路是按SQL指纹聚合统计:
import re from collections import defaultdict SLOW_LOG = "/var/log/mysql/mysql-slow.log" def fingerprint(sql): sql = re.sub(r"\b\d+\b", "?", sql) sql = re.sub(r"\'[^\']*\'", "?", sql) sql = re.sub(r"\s+", " ", sql.strip()) return sql stats = defaultdict(lambda: {"count": 0, "max_time": 0.0, "total_time": 0.0}) current_sql = [] current_time = 0.0 with open(SLOW_LOG, "r", encoding="utf-8", errors="ignore") as f: for line in f: time_match = re.search(r"Query_time:\s*([\d\.]+)", line) if time_match: current_time = float(time_match.group(1)) elif line.startswith("#") or line.strip() == "": continue else: current_sql.append(line.strip()) if line.rstrip().endswith(";"): sql = " ".join(current_sql) fp = fingerprint(sql) stats[fp]["count"] += 1 stats[fp]["max_time"] = max(stats[fp]["max_time"], current_time) stats[fp]["total_time"] += current_time current_sql = [] current_time = 0.0 for fp, s in sorted(stats.items(), key=lambda x: x[1]["total_time"], reverse=True)[:20]: print(f"count={s['count']}, max={s['max_time']:.2f}s, sql={fp[:120]}")这段代码只是一个演示,实际生产环境还要处理多行SQL、注释、字符串转义等问题,但核心思路已经足够清晰:先做指纹归一化,再做聚合统计,最后排序输出。
4.2 第二步:准备贴近生产的压测环境
压测环境搭建的优先级如下:
- 表结构和索引必须和生产一致,最好直接从生产备份恢复出表结构。
- 数据量要接近生产规模。可以全量恢复,也可以按比例抽样后再放大,但要保证关键查询条件涉及的列有足够的数据分布。
- 数据库版本和参数配置尽量一致,尤其是内存参数、并发参数和优化器开关。
- 测试数据必须先做脱敏,防止敏感信息泄露。
我见过不少团队在测试环境建表后直接跑压测,结果数据量只有生产的百分之一,索引效果完全体现不出来。这种复现从开始就失真了。
4.3 第三步:执行压测并输出对比报告
压测执行阶段,我的建议是先按并发度分档跑:10并发、50并发、100并发,观察目标和系统的变化。每档之间需要等待系统平稳,不要连续加压。
记录指标至少包括这样几项:
- 目标SQL的平均耗时、TP95、TP99
- 数据库CPU、IO吞吐、锁等待时间
- 事务成功率、超时率
- 连接池活跃连接数
优化完成后,用同样的步骤再跑一轮,最后输出一张对比表,类似这样:
| 指标 | 优化前基线 | 优化后结果 | 变化 |
|---|---|---|---|
| 目标SQL平均耗时 | 3200ms | 230ms | -92.8% |
| 数据库CPU使用率 | 85% | 41% | -51.8% |
| TP99响应时间 | 4800ms | 560ms | -88.3% |
| 单轮压测总耗时 | 126s | 82s | -34.9% |
只要有了这张表,优化效果就是可量化、可汇报的,后续评审也有依据。
5. 常见问题与避坑清单
实操中会遇到各种反直觉的问题,下面这些是我反复踩过、也帮朋友排查过的坑,整理成速查表。
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 慢日志里看不到某些耗时的SQL | long_query_time设置过大,或SQL在会话内被整体事务吞没 | 调低阈值,检查间歇性提交的批量任务 |
| 测试环境复现不出线上慢SQL | 数据量、统计信息、数据库参数不一致 | 恢复生产数据规模,执行ANALYZE TABLE,同步参数 |
| 压测结果波动剧烈 | 缓存命中率不同、后台任务干扰、压测线程调度不稳定 | 冷启动统一状态,关闭后台任务,多次压测取中位数 |
| 加索引后反而更慢 | 优化器选错执行计划,索引选择性差 | 查看执行计划,用EXPLAIN ANALYZE对比,必要时使用FORCE INDEX或改写SQL |
| 单并发时正常,高并发时雪崩 | 行锁争用、连接池排队、CPU争抢 | 观察锁等待和活跃会话数,降低事务粒度,增加读写分离 |
| SQL指纹聚合不准 | 归一化规则太激进或太保守 | 保留表名和过滤列,只替换字面量,合并同构SQL |
5.1 阈值不是拍脑袋定的
我在前面提到的0.5秒阈值,有些同事觉得太小,因为他们的业务SQL普遍都在秒级;也有些团队觉得太大。一个比较合理的做法是先把慢日志打开,收集一周的数据,统计所有SQL执行耗时的分布,然后取P95或P99作为初始阈值,再根据业务容忍度调整。目标是把“需要关注的SQL”都能捞出来,而不是被海量日志埋没。
5.2 压测前记得同步统计信息
测试环境的表和索引是从备份恢复的,但优化器的统计信息可能还是旧的。压测结果不准,很大一部分原因就在这里。我在每次压测前都会执行:
ANALYZE TABLE orders;不要小看这条语句,它能让优化器用更准确的行数估算来选择执行计划。很多时候生产环境的一条慢SQL,就是因为统计信息偏差导致优化器选错了索引。
5.3 缓存是压测里的“隐形作弊器”
同样的SQL连着跑两次,第二次可能快得多,因为数据页已经进了内存。如果不控制这个变量,很容易得出“优化有效”的错误结论。我建议每轮压测使用独立的数据集,或者在不同轮次之间重启数据库实例,至少也要执行冷缓存清空。记录结果时同样要注意顺序,最好交替执行A/B两轮再对比。
5.4 锁等待问题一定要靠并发场景来复现
单线程压测永远复现不出锁竞争问题。我曾经遇到过一条UPDATE语句在测试环境跑得飞快,生产环境却频繁堆积,后来才发现是高并发下多个事务同时更新同一批热点行,导致行锁排队。复现这类问题必须在场景中加入多线程事务,并且让不同线程按真实业务比例操作相同的数据。如果压测工具不支持自定义事务比例,就需要写脚本模拟。
5.5 优化后要做回归,别只盯着目标SQL
有时候解决了慢SQL A,却让SQL B变慢了。原因是优化器可能因为新增索引改变了其他查询的执行计划。所以在压测验证阶段,除了关注目标SQL,还要跑一遍原有的核心SQL集合,确认没有引入新的性能回退。我的做法是准备一个回归SQL集,里面包含线上调用量最高的几十条查询,每次优化后全部执行一轮,对比总耗时。
6. 这套机制后续还能怎么扩展
识别和复现的闭环打通之后,可以继续往三个方向扩展。
6.1 与全链路监控集成
慢SQL识别结果如果只看数据库侧,会丢失业务上下文。可以按SQL指纹和上游接口的Trace ID关联,定位到具体业务模块、用户场景和调用来源,判断慢SQL影响了哪些核心链路。有了这个关联,处理优先级会清晰很多,比如“影响支付接口的慢SQL”和“影响后台报表导出的慢SQL”显然不是同一个紧急级别。
6.2 自动生成优化建议
在拿到慢SQL指纹和执行计划之后,可以基于规则自动给出优化建议:缺索引就提示候选索引列,扫描行数过大就建议改写查询或加过滤条件,锁等待严重就建议拆分大事务。这些建议不一定100%正确,但能极大缩短人工分析的时间。
6.3 沉淀为性能回归的常态化机制
把识别和压测复现的流程固化到发布流程里,每次新功能或SQL变更上线前自动跑一轮回归压测,用同一条SQL的耗时对比来判断是否引入了性能退化。这个机制一旦转起来,很多性能问题会在上线前被拦截,而不是等到生产报警。我们现在的做法是把压测报告自动归档到制品库,方便后续追溯。
说回个人体会。慢SQL治理不是一个一次性的项目,更像是一个持续迭代的过程。最怕的不是系统里有慢SQL,而是不知道慢在哪里,改完之后也不知道到底有没有效果。识别加复现这套机制,最核心的价值就是把不确定性一点点消掉。如果你们的团队现在还没有类似流程,不用一上来就建平台,先从最慢的一条SQL开始,把它复现出来,优化完再压一遍,哪怕只是两条曲线对比,也能帮你建立信心。先让流程转起来,再逐步完善,这条路是走得通的。