接手过一个老项目,线上接口时不时卡个两秒,DBA把慢查询日志拉出来一看,一条SELECT每次都要跑700多毫秒。但是日志里只有一条SQL文本,没有参数,没有调用来源,没有业务ID,前后端对着这条SQL扯了半天,谁也不知道是哪个页面触发的。后来我把这套链路补起来,才意识到慢SQL排查真正的难点从来不是"能不能发现",而是"发现之后能不能定位、能不定量、能不能形成一套自动闭环"。这篇文章就围绕SpringBoot项目里的慢SQL自动捕获、执行计划解读和一键优化这三个环节,把常见问题彻底捋一遍。
如果你正在做接口性能治理、被线上偶发卡顿折腾过,或者刚接手一个查询慢得离谱的老项目,这篇文章可以给你一份可以直接落地抄作业的完整思路。
1. 慢SQL自动捕获的三种主流姿势与选型逻辑
慢SQL捕获这件事,第一反应基本都是开MySQL的slow_query_log。但真正用起来就会发现,这条路的坑比想象中多。先说说为什么我不建议纯靠它。
1.1 为什么慢查询日志不能直接当排查依据
MySQL的慢查询日志记录的是SQL文本、执行耗时、锁等待时间,但它天然缺少业务上下文。你的系统里有几十个接口在调用同一张表,一条慢SQL出来,你根本不知道它来自订单列表、后台报表还是定时任务。更麻烦的是,慢查询日志默认记录的是预编译SQL,参数值不会出现在里面。
还有一个被很多人忽略的点:慢查询日志只能告诉你"这条SQL确实慢",但看不到应用层面的等待。比如连接池满了导致请求排队,获取连接等了两秒,真正执行SQL只有100毫秒。这时候你去优化SQL,效果几乎为零。所以应用层捕获是必须的,而且要在连接获取之后、SQL执行阶段去埋点,才能拿到准确的耗时分布。
1.2 MyBatis拦截器方案的实现细节
对于大部分SpringBoot+MyBatis项目,最直接的做法是自定义一个MyBatis的Interceptor,拦截Executor接口的query和update方法。核心逻辑很简单:在方法执行前记录时间,执行后计算耗时,超过阈值就记录SQL和调用来源。
但这里有个容易踩的细节:如何从拦截器里拿完整的SQL和参数。很多人会在拦截器里拿到MappedStatement之后直接找BoundSql,但MappedStatement里拿到的BoundSql是静态的,参数值不在里面。真正的参数在Executor.query方法传入的parameterObject里,需要结合参数映射去拼。
我用过一个简化版本,大致是这么做的:
java复制@Intercepts({
@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})
})
public class SlowSqlInterceptor implements Interceptor {
private static final long SLOW_THRESHOLD = 500L;
@Override
public Object intercept(Invocation invocation) throws Throwable {
long start = System.nanoTime();
try {
return invocation.proceed();
} finally {
long costMs = (System.nanoTime() - start) / 1_000_000;
if (costMs >= SLOW_THRESHOLD) {
MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
Object parameter = invocation.getArgs()[1];
BoundSql boundSql = ms.getBoundSql(parameter);
String sql = buildSql(boundSql, parameter);
// 这里把sql、costMs、请求来源、TraceId一起发到告警或日志系统
System.out.println("slow sql cost=" + costMs + "ms, sql=" + sql);
}
}
}
private String buildSql(BoundSql boundSql, Object parameter) {
// 遍历boundSql.getParameterMappings(),把parameterObject里的值拼进SQL
// 注意字符串要加引号,null值要特殊处理
return sql;
}
}
注册方式是在配置类里添加到SqlSessionFactory:
java复制@Bean
public ConfigurationCustomizer mybatisCustomizer() {
return configuration -> configuration.addInterceptor(new SlowSqlInterceptor());
}
这个方案的优点是能拿到完整的调用链路上下文,比如在拦截器里读取TraceId、用户ID、请求路径,把慢SQL和具体接口关联起来。缺点是需要自己处理嵌套查询、批量操作等复杂场景,尤其是一对多查询里突然执行了N条SQL,耗时归属要分清楚。
1.3 Druid连接池代理方案:配置量最小的方案
如果项目在用Druid连接池,其实不用写一行拦截器代码就能开启慢SQL记录。Druid的StatFilter自带慢SQL统计和日志输出,配置在application.yml里:
yaml复制spring:
datasource:
druid:
filter:
stat:
enabled: true
log-slow-sql: true
slow-sql-millis: 1000
配好之后,超过1秒的SQL会自动打印到日志里,包括参数和耗时。这个方案接入成本最低,但对于调用来源、业务上下文的关联还是得自己补充。而且Druid在SpringBoot 2.x项目里很常见,功能也成熟,如果是老项目不想大改,用它是比较稳妥的。
1.4 P6Spy的补充视角
P6Spy是另一个思路,它通过代理JDBC驱动来拦截所有SQL执行,不仅能捕获慢SQL,还能把预编译参数完整拼出来。实际项目里我见过不少人用它来替代或者补充Druid的日志。
不过P6Spy有个明显的性能损耗问题,因为每个SQL都要经过它的代理链路,高并发场景下会有额外开销。我的建议是:如果项目规模不大、并发不高,可以用P6Spy快速看SQL全貌;如果是在高并发生产环境,更推荐自己写拦截器或者用Druid原生过滤器,避免为了排查问题引入新的性能隐患。
1.5 三种方案快速对比
| 方案 | 接入成本 | 业务上下文 | 参数还原 | 性能损耗 | 适用场景 |
|---|---|---|---|---|---|
| MySQL慢查询日志 | 低 | 无法关联 | 不完整 | 低 | 粗粒度定位 |
| MyBatis拦截器 | 中 | 可完整关联 | 需自己拼接 | 中 | 需要精确到接口 |
| Druid StatFilter | 低 | 部分关联 | 完整 | 低 | 老项目快速接入 |
| P6Spy | 低 | 部分关联 | 完整 | 偏高 | 开发测试环境 |
实际项目中我通常建议以MyBatis拦截器为主,线上长期开启,Druid作为兜底,两个一起也没问题,注意去重就行。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 阈值怎么定、参数怎么拼,才能抓到真凶
捕获机制的架子搭起来之后,真正的细节才开始。慢SQL的阈值设置和参数拼接,直接决定你每天看到的是有价值的SQL还是海量噪音。
2.1 慢SQL阈值的两层含义
很多人会把MySQL的long_query_time和应用层的慢SQL阈值搞混。MySQL的long_query_time是数据库层面的判定标准,单位是秒,默认10秒。应用层一般用毫秒,根据业务接口的SLA来定。
我的经验是不要全局只定一个阈值。读接口和写接口要分开,简单查询和复杂报表也要分开。比如一个用户中心的基础查询,超过300毫秒就算异常;一个后台报表的汇总查询,3秒以内可能就是正常的。如果全都用同一个500毫秒的阈值,报表接口会天天误报,基础的慢SQL反而被淹没。
具体定法可以参考:线上接口的TP99耗时。如果TP99是800毫秒,慢SQL阈值可以取TP99的50%到80%,也就是400到650毫秒之间。这样既能抓出明显异常的SQL,又不会把正常波动算进去。
2.2 PreparedStatement参数拼接的坑
这是我在实际排查中遇到最多的问题。MyBatis默认输出的日志长这样:
code复制==> Preparing: SELECT * FROM order_info WHERE user_id = ? AND status = ? ORDER BY create_time DESC LIMIT ?
==> Parameters: 1001(String), 1(Integer), 50(Integer)
在慢SQL拦截器里,你可能需要在一条日志里看到完整SQL和参数。但参数拼接没这么简单,常见坑有这么几个:
第一,字符串参数要加引号。我曾经见过团队把字符串参数直接拼进去,跑到数据库执行,结果类型不匹配报错。
第二,null值要特殊处理。null拼成 id = null,这条SQL永远查不到数据,但追踪问题的人会怀疑人生。
第三,时间字段的参数格式化。LocalDateTime、Date这些类型不统一处理,拼出来的是对象toString的结果,根本没法直接执行。
拼接参数的核心逻辑是遍历BoundSql的ParameterMappings,再根据参数类型决定拼接格式。简单的伪代码:
java复制for (ParameterMapping mapping : boundSql.getParameterMappings()) {
String propertyName = mapping.getProperty();
Object value = parameterObject;
if (parameterObject instanceof Map) {
value = ((Map) parameterObject).get(propertyName);
}
// 根据value的类型处理引号、null、日期格式
if (value == null) {
sqlBlock.append("null");
} else if (value instanceof String || value instanceof Date) {
sqlBlock.append("'").append(value).append("'");
} else {
sqlBlock.append(value);
}
}
2.3 按SQL指纹聚合,避免被重复SQL刷屏
还有一个典型问题:一个慢SQL在日志里可能半小时内出现几百次,每次都完整记录,排查的时候根本看不过来。解决思路是按SQL指纹聚合。
SQL指纹的概念很简单:把SQL里的字面量替换成占位符,比如 user_id = 1001 变成 user_id = ?,然后按这条"模板SQL"分组统计。这样可以快速看到某个模板SQL总共出现多少次、平均耗时多少、最大耗时多少。
我之前在拦截器里加了一个简单的内存窗口聚合,滑动窗口60秒,统计每个SQL指纹的出现次数、平均耗时、P99耗时,超过阈值才发告警,避免每一条都往外打。这招在OOM之前特别有用,因为大量重复慢SQL往往是接口被刷或者数据倾斜的前兆。
3. "一键优化"到底优化了什么,执行计划怎么翻译
标题里提到"一键优化",我得先说实话:市面上没有任何工具能真正保证"一键把SQL改好",因为在复杂业务里,SQL改写牵扯到数据分布、索引选择、业务语义,机器不可能完全理解。但工具确实能做到一件事——把EXPLAIN执行计划翻译成人话,把最耗时的瓶颈直接指出来,减少人工排查的时间。这就是"一键优化"真实的价值。
3.1 EXPLAIN关键列解读:type、rows、Extra
拿到一条慢SQL的第一件事不是看SQL本身,而是跑一遍EXPLAIN:
sql复制EXPLAIN SELECT * FROM order_info WHERE user_id = 1001 ORDER BY create_time DESC LIMIT 50, 20;
执行计划里最重要的三列是type、rows和Extra。
type列表示访问类型,从好到差大致是:system、const、eq_ref、ref、range、index、ALL。ref代表用到了非唯一索引,range代表索引范围扫描,index代表全索引扫描,ALL代表全表扫描。看到ALL或者index,基本可以断定这条SQL有索引问题。
rows列是估算的扫描行数,这个数字直接决定SQL快慢。如果user_id = 1001时rows显示30000,意思是要扫描3万行才能算出结果。即使走了索引,rows太大依然会很慢。
Extra列是最容易出信息的地方。看到Using filesort,说明排序用了临时文件;看到Using temporary,说明用了临时表;看到Using index,说明用到了覆盖索引,这是最理想的状态。对于一条ORDER BY + LIMIT的查询,如果Extra里有Using filesort,基本可以判断索引设计有问题。
3.2 索引失效的三种常见操作
很多慢SQL不是没有索引,而是索引没被用上。我遇到最多的三种场景:
第一种是隐式转换。where条件里字段是varchar类型,但参数传的是数字,比如 WHERE phone = 13800001111。这种情况下MySQL会做隐式类型转换,导致索引失效。解决办法很简单,确保参数类型和字段类型一致。
第二种是like左模糊。WHERE name LIKE '%张%' 这种写法用不上索引,因为B+树的索引顺序是从左往右的。如果业务确实需要左模糊,可以考虑全文索引或者改成右模糊。
第三种是函数包裹字段。WHERE DATE(create_time) = '2024-01-01' 在create_time上用了DATE函数,索引直接失效。正确写法是 WHERE create_time >= '2024-01-01' AND create_time < '2024-01-02',把函数作用在参数上而不是字段上。
3.3 深分页的优化思路
还有一类特别典型的问题:翻页越深越慢。LIMIT 100000, 20 这种写法,看起来只取20条,但数据库需要先扫描10万行再丢弃前10万行。我见过最深的分页翻到50万,一个接口超时到30秒。
深分页的优化思路有两个。
第一个是延迟关联。先只查主键,再用主键去关联原表拿完整数据:
sql复制SELECT o.*
FROM order_info o
INNER JOIN (
SELECT id
FROM order_info
WHERE user_id = 1001
ORDER BY create_time DESC
LIMIT 100000, 20
) tmp ON o.id = tmp.id;
第二个是游标分页。用 WHERE id > last_id ORDER BY id LIMIT 20 代替深分页。但这个方案适合按主键顺序翻页的场景,如果要按业务字段排序,还是要配合延迟关联。
3.4 自动优化建议怎么生成:规则引擎的思路
按标题说的"一键优化",在代码层面可以做成一个自动分析器。收集到慢SQL之后,自动做三件事:用SQL解析器拿到表名和字段;自动执行EXPLAIN拿到执行计划;根据预设规则产出建议。
规则可以设计成一组条件判断,比如:
text复制如果 type == ALL 且 where条件的字段上有索引
-> 提示:检查索引是否因隐式转换失效
如果 Extra == Using filesort
-> 提示:排序字段是否包含在联合索引中
如果 rows > 10000 且 存在 LIMIT 深分页
-> 提示:考虑延迟关联或游标分页
如果 查询列远多于实际需要,且未使用覆盖索引
-> 提示:考虑把查询字段收窄
我的实现里会先用Druid的SQL解析器把SQL拆解出核心要素,然后执行EXPLAIN获取执行计划,再走规则引擎。这一步的价值在于:不是所有慢SQL都需要人肉去分析,规则能覆盖掉60%以上的常见问题,剩下20%再人工处理,效率完全不一样。
4. 捕获链路落地后,最容易翻车的三个隐藏坑
前面说的方案看着都很顺,但在真实项目里落地,很容易在几个"隐藏坑"这里翻车。我把踩过的坑和解决思路都整理出来。
4.1 异步线程丢失TraceId,慢SQL变成孤岛数据
如果你在拦截器里从ThreadLocal读TraceId、用户ID,那么遇到异步调用会有个非常隐蔽的问题:Spring的@Async方法会另起一个线程,主线程的ThreadLocal不会自动传过去。慢SQL确实捕获到了,但TraceId是空的,跟具体请求根本关联不上,等于又回到了"有SQL没有来源"的老路。
解决办法是在异步线程池里加一个TaskDecorator,把主线程的上下文拷贝到子线程:
java复制@Bean("asyncExecutor")
public Executor asyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setTaskDecorator(runnable -> {
Map<String, String> context = TraceContext.get();
return () -> {
try {
TraceContext.set(context);
runnable.run();
} finally {
TraceContext.clear();
}
};
});
return executor;
}
这样异步线程里执行的SQL也能带上同一个TraceId,排查链路才能完整串起来。
4.2 事务内多SQL的耗时归属问题
当@Transactional包裹一个方法时,事务可能包含多条SQL。事务相关的时间消耗包括:获取连接的时间、多个SQL的执行时间、锁等待时间、最后提交时间。很多慢SQL排查在事务场景下会犯一个错误:看单条SQL耗时还可以,但接口还是很慢,定位到事务到底卡在哪一步就抓瞎了。
我的做法是在慢SQL拦截器之外,额外做一个事务维度的统计:在事务开始时记录当前时间和已执行SQL数量,事务结束时输出整段事务的耗时和SQL清单。这样能直接看出事务里是不是有一条SQL特别慢,还是所有SQL都不慢但事务整体在等锁。
还有一个常见场景是事务内调用外部接口,比如在一个事务里查了数据库之后调远程服务,再更新数据。这时候事务整个生命周期都被拉长了,但慢SQL分析却看不到外部调用的耗时。这种情况建议把远程调用移出事务,或者至少标注出来,避免误导排查方向。
4.3 连接池参数与慢SQL阈值之间的相互误导
这个坑特别隐蔽。当连接池满了,新请求拿不到连接,会在应用层等待。这个等待时间如果被直接算进SQL的执行耗时,会得出"SQL很慢"的结论,但实际瓶颈是连接池太小。
一个典型表现:Druid的maxActive设置太小,业务量上来之后,慢SQL日志突然出现大量耗时1000毫秒以上的SQL,但每一条SQL实际执行时间只有100毫秒。原因就是大部分时间在等待连接。
排查方法是在连接池监控里看activeCount和waitCount,如果waitCount长期大于0,优先考虑调大maxActive而不是优化SQL。另一个被忽略的点是连接池的探活配置,比如Druid的testOnBorrow,如果每次取连接都做一次探测,高并发下会放大延迟。建议生产环境使用testWhileIdle,配合keepAlive,避免频繁探测。
5. SpringBoot版本升级,排查链路也要跟着改
很多团队的项目升级到SpringBoot 3.x之后,发现之前跑得好好的慢SQL拦截器突然不生效了,或者启动直接报错。这里面的坑主要集中在三个方面。
5.1 javax到jakarta的迁移影响
SpringBoot 3.x把javax.servlet换成了jakarta.servlet。如果你的自定义Filter、拦截器、WebMvcConfigurer还在引javax的包,启动时会直接ClassNotFound或者启动失败。
解决方法是全局替换import路径,把javax.servlet换成jakarta.servlet。如果是MyBatis项目,还要特别注意MyBatis-Spring-Boot-Starter的版本。SpringBoot 3.x必须配套使用mybatis-spring-boot-starter 3.0以上版本,老版本会导致自动配置不生效、Mapper扫描不到,拦截器自然也不会注册。
5.2 MyBatis插件拦截器的执行顺序坑
项目里如果同时用了分页插件、SQL慢日志拦截器、性能分析插件,多个Interceptor同时存在时,它们的执行顺序不是简单的注册顺序。MyBatis的插件是通过代理链嵌套实现的,每个插件用Plugin.wrap包裹下一个,顺序是反的。
这个坑导致的问题很典型:你以为先走到了慢SQL拦截器,但实际上分页插件在最外层,SQL还没执行,慢SQL拦截器还没记录到时耗,就被分页插件包装了一层。在某些版本组合下,慢SQL拦截器会收不到耗时的真实数据。
解决办法是在注册插件时想清楚依赖关系。分页插件需要拿到原始SQL,所以它要在最外层;慢SQL拦截器要统计真实执行耗时,应该放到最内层,离Executor最近。如果插件顺序错了,最好的调试方式是给每个插件加一行首尾日志,看看执行顺序是否符合预期。
5.3 JDK版本变化和反射访问限制
SpringBoot 3.x要求JDK17,而JDK17默认对反射访问做了强封装。之前提到过,有些慢SQL拦截器需要反射访问SqlSource来拼SQL参数,这在JDK8下没问题,但JDK17下可能会抛IllegalAccessException。
这个问题的解决思路是避免反射内部实现。MyBatis实际上在BoundSql里已经暴露了ParameterMappings和ParameterObject,正常通过这两个入口就能拼出完整SQL。如果确实需要更底层的SQL,建议直接用官方API,不要动反射,否则JDK一升级就崩。
6. 一条慢SQL的完整拆解复盘
把前面的原理串起来,用一个我自己实际处理过的案例做完整复盘,这样更能说明问题。
6.1 场景与现象
用户反馈订单查询接口在翻到第50页之后明显卡顿,正常情况下接口响应300毫秒,翻到后面变成了1.2秒。慢SQL拦截器抓到了这条SQL:
sql复制SELECT id, order_no, user_id, amount, status, create_time
FROM order_info
WHERE user_id = 1001
ORDER BY create_time DESC
LIMIT 50, 20;
表结构大致是:
sql复制CREATE TABLE order_info (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
order_no VARCHAR(64),
user_id BIGINT,
amount DECIMAL(10,2),
status TINYINT,
create_time DATETIME,
KEY idx_user_id (user_id)
) ENGINE=InnoDB;
数据量在300万行左右,这个用户ID下有大约3万条订单。慢SQL拦截器里记录的参数是完整的,user_id、LIMIT偏移量都明确,所以能直接定位到是分页场景。
6.2 EXPLAIN结果
执行EXPLAIN之后,关键信息如下:
| 列名 | 值 |
|---|---|
| type | ref |
| key | idx_user_id |
| rows | 30000 |
| Extra | Using filesort |
type=ref说明用到了user_id索引,但因为只用了user_id一个字段,没法覆盖ORDER BY create_time的排序需求,所以MySQL需要把3万条命中记录全部查出来,再在内存里做排序,最后取偏移50之后的20条。rows=30000和Extra=Using filesort就是慢的根本原因。
6.3 根因定位过程
这个案例里有两条优化路径可以走。第一条是把联合索引改成 (user_id, create_time),让B+树的叶子节点天然按create_time排好序,排序这一步就不用做了。第二条是把SELECT *收窄成只查需要的列,减少回表的IO开销。
我先用延迟关联的方式验证,只查询ID子集,再关联原表:
sql复制SELECT o.id, o.order_no, o.user_id, o.amount, o.status, o.create_time
FROM order_info o
INNER JOIN (
SELECT id
FROM order_info
WHERE user_id = 1001
ORDER BY create_time DESC
LIMIT 50, 20
) tmp ON o.id = tmp.id;
执行计划里子查询的rows依然很大,但因为只在索引里扫描ID,不再需要每条都回表,整体耗时降到了400毫秒。不过治本的办法还是加联合索引。
6.4 修复与效果验证
加上联合索引之后:
sql复制ALTER TABLE order_info ADD INDEX idx_user_create (user_id, create_time);
再次执行EXPLAIN,Extra列从Using filesort变成了Using index condition,rows从30000降到了比较低的估算值,接口耗时从1200毫秒降到了150毫秒左右。
这次排查里面有一个值得注意的细节:最开始我拿到那条慢SQL的时候,Excel里看参数是完整的,但就是没看表结构。后来一查才发现order_info表的索引设计极不合理,几十个字段上散落着大量单列索引,就是没有复合索引。慢SQL优化里,索引设计才是大头,SQL改写只是补充。
我个人体会是,慢SQL治理的完整链路可以分成三层来建设:第一层是自动捕获,保证每条慢SQL都有参数、有来源、有TraceId;第二层是自动分析,通过执行计划规则把常见问题筛出来;第三层才是人工介入,处理规则覆盖不到的复杂场景。前两层做得越扎实,人工排查的成本就越低。
最后分享一个小技巧:生产环境的慢SQL日志不要只落在文件里,可以做一个简单的告警转发,超过阈值直接把完整SQL、执行计划、调用链路上报到IM群。这样SQL一慢,大家立刻就知道是哪个接口的问题,省去翻日志的时间。等告警积累一个月,再定期回顾Top N,把索引和SQL分成批优化,慢SQL治理这件事就能形成一个持续运转的正循环。
