前两天我在公司加班,晚上十一点多,楼下抽烟的时候还跟我们组的小李吐槽过:生产上查一个慢 SQL,真的是要命,日志翻半天,甚至还得开 profiler,搞下来至少一两个小时。后来我就下定决心在 SpringBoot 里自研了一套运行时 SQL 调用树,能三分钟就定位到问题点。今天就把这个经历聊一聊,顺便带点代码,大家能感受到怎么落地。
为什么要搞运行时 SQL 调用树
你想啊,平时咱们用 SpringBoot + MyBatis 或 JPA,SQL 都是藏在 Mapper 或 Repository 里头,慢 SQL 一旦出现,表面上你看到日志里那条执行了 10 秒的 SQL,可它是哪个请求引起的、是哪个调用链触发的,压根不清楚。
举个真实的例子,前段时间有个报表接口,用户一点击就卡住,监控里只知道 MySQL 里有一条 join 查询跑了十几秒。要查它是哪个接口发出来的,真的头大。于是我想,不如在运行时构建一颗 SQL 调用树,把谁调用了谁、耗时多少全挂在树上,一眼就能看出慢 SQL 在哪一层触发。
核心思路
说起来其实不复杂,三步走:
拦截 SQL:在 MyBatis 或 DataSource 层打个代理,把 SQL 和耗时拦下来。
绑定调用上下文:结合 ThreadLocal,把 SQL 执行跟当前的请求上下文绑一块。
构建调用树:用一个树形结构存储调用关系,每个节点一个 SQL 片段或者方法名,最后序列化输出。
拦截 SQL 的实现
我用的是 Spring 提供的DataSourceProxy思路。简单写段代码感受一下:
import java.sql.*;
import javax.sql.DataSource;
publicclass SqlTimingDataSource extends DataSourceWrapper {
public SqlTimingDataSource(DataSource delegate) {
super(delegate);
}
@Override
public Connection getConnection() throws SQLException {
returnnew SqlTimingConnection(super.getConnection());
}
}
class SqlTimingConnection extends ConnectionWrapper {
public SqlTimingConnection(Connection delegate) {
super(delegate);
}
@Override
public PreparedStatement prepareStatement(String sql) throws SQLException {
returnnew SqlTimingPreparedStatement(super.prepareStatement(sql), sql);
}
}
class SqlTimingPreparedStatement extends PreparedStatementWrapper {
privatefinal String sql;
public SqlTimingPreparedStatement(PreparedStatement delegate, String sql) {
super(delegate);
this.sql = sql;
}
@Override
public boolean execute() throws SQLException {
long start = System.currentTimeMillis();
try {
returnsuper.execute();
} finally {
long cost = System.currentTimeMillis() - start;
SqlCallTreeCollector.record(sql, cost);
}
}
}
这里的SqlCallTreeCollector.record就是核心,负责把 SQL 和耗时塞到调用树里。
构建调用树
我设计了一个简单的树节点:
public class SqlNode {
private String sqlOrMethod;
private long cost;
private List<SqlNode> children = new ArrayList<>();
// getter/setter 略
}
调用时我用ThreadLocal<Deque<SqlNode>>来维护调用栈。每当进入一个方法或者 SQL,就 push 一个节点,执行完就 pop,最后一合并,树就出来了。
怎么把方法也纳入树?
光有 SQL 还不够,你得知道它是被哪个 service 调的。我这里用了 Spring 的@Around切面:
@Aspect
@Component
public class CallTreeAspect {
@Around("execution(* com.example..service..*(..))")
public Object recordCall(ProceedingJoinPoint pjp) throws Throwable {
String method = pjp.getSignature().toShortString();
SqlCallTreeCollector.enter(method);
try {
return pjp.proceed();
} finally {
SqlCallTreeCollector.exit();
}
}
}
这样每个 service 方法、DAO 方法的调用,都会被挂在调用树上。SQL 节点是子节点,整个树串起来就有意思了。
三分钟定位慢 SQL
有了调用树,定位就很快。比如一次请求生成的调用树大概长这样(伪输出):
UserReportService.getReport [12050ms]
UserDao.queryUser [50ms]
SELECT * FROM user WHERE id=? [48ms]
ReportDao.queryReport [11980ms]
SELECT * FROM report r JOIN data d ON ... [11980ms] <<--- 慢SQL
一眼就能看出是ReportDao.queryReport里的 join 拖慢了。再结合 SQL 打印,三分钟搞定,完全不用盲人摸象。
实战踩坑
调用树过大:一旦请求里 SQL 特别多,树会膨胀,我加了个阈值,超过 1000 个节点就丢弃。
异步调用:像线程池里的异步任务没办法自动带上 ThreadLocal,我做了个包装,把上下文透传。
SQL 截断:有的 SQL 太长了,日志只保留前 200 字符,避免撑爆磁盘。
跟现有工具的关系
可能有人会说:不都用 Arthas trace 或 SkyWalking 了吗?我也用,但这些要么成本高,要么是线上紧急用。这个自研的调用树非常轻量,嵌在 SpringBoot 里,本地调试、预发环境秒查问题。就像给项目加了个内置的“黑匣子”。
总结
说白了,这套东西就是让 SQL 不再是“孤岛”,而是跟调用链挂钩。拦截 SQL 绑定上下文 构建调用树,这三步下来,慢 SQL 定位效率直接提升一个数量级。
最后再提一句,别迷信工具,关键还是思路:把信息串联起来,让问题能一眼看穿。