小伙伴们,当线上项目突然卡得像老黄牛拉破车,日志刷了几百行,一眼望去全是 SQL 执行记录,你知道是哪个 “捣蛋鬼” 拖慢了整个流程?
上次我同事小王就遇到这糟心情况,用户反馈下单接口要等 5 秒才能出结果,他对着满屏的DEBUG日志翻了俩小时,一会儿查 MyBatis 日志,一会儿看链路追踪,最后才发现是个没加索引的count(*)在搞事。当时他就吐槽:“要是能一眼看出哪个 SQL 在哪个业务步骤里慢了,我至于熬到半夜吗?”
这不,为了解决这个 “慢 SQL 定位难” 的千古难题,我基于 SpringBoot 搞了个 “运行时 SQL 调用树”—— 不管你业务多复杂,多少个 SQL 嵌套调用,它都能像思维导图一样把调用关系理得明明白白,再配上执行时间,慢 SQL 直接原地 “立正挨打”,3 分钟就能精准定位!今天就把这干货手把手教给大家,保证小白也能看懂,看完就能用。
一、先搞懂:为啥咱们需要 “SQL 调用树”?
在聊怎么实现之前,咱得先掰扯清楚:市面上现成的工具不少,为啥还要自研?
你可能会说,我用日志不就行了?确实,MyBatis 能打印 SQL 执行时间,Logback 能输出调用栈,但问题是 —— 日志是 “线性” 的。比如一个下单接口,要查用户余额、扣库存、生成订单、记录日志,4 个步骤对应 4 条 SQL 日志,你得自己对着日志里的时间戳和线程 ID,脑补出 “谁调用了谁”,要是遇到多线程或者嵌套调用,直接就懵圈了。
那用链路追踪工具呢?像 SkyWalking、Pinpoint 这些,确实能看调用链路,但它们有两个小毛病:一是配置复杂,还得搭服务端,小项目用着嫌重;二是侧重点在 “服务间调用”,对应用内部的 SQL 调用细节展示得不够细,有时候你知道是某个接口慢了,但还是得钻到应用日志里找具体 SQL。
还有数据库自带的慢查询日志?比如 MySQL 的 slow_query_log,它能抓到慢 SQL,但问题是 “只知其然,不知其所以然”—— 你知道这条 SQL 慢了,可它是哪个业务接口调的?是在 “创建订单” 还是 “计算优惠” 步骤里执行的?完全不知道,还得回头去代码里搜,效率太低。
所以咱们需要的是一个 “中间件”:既能轻量级集成到 SpringBoot 项目,又能清晰展示 “业务接口→Service→DAO→SQL” 的调用关系,还能把每个 SQL 的执行时间标出来 —— 这就是 “SQL 调用树” 要干的活。简单说,它就像给 SQL 装了个 “导航仪”,哪里慢了,一查就知道。
二、核心思路:怎么让 SQL “自己报家门”?
要做 SQL 调用树,核心就解决两个问题:一是怎么抓 SQL 的执行信息,二是怎么把这些信息按调用关系组织成树。
先想第一个问题:怎么抓 SQL 信息?咱们用 SpringBoot 开发,SQL 大多是通过 MyBatis、JPA 这些 ORM 框架执行的,而这些框架在 Spring 生态里,都绕不开一个东西 ——DataSource。不管你是用HikariCP还是Druid,所有 SQL 最终都要通过DataSource获取连接,然后执行。
所以第一个关键点来了:代理 DataSource。咱们可以自己写一个DataSource的代理类,把原本的DataSource包一层,这样每次执行 SQL 的时候,就能在代理类里 “插一脚”,把 SQL 语句、执行时间、调用栈这些信息抓下来。
再想第二个问题:怎么组织成树?调用关系是有 “父子” 的,比如OrderController.createOrder()调用OrderService.calculatePrice(),calculatePrice()又调用ProductDAO.selectById(),selectById()最终执行了 SQL。这里createOrder是父,calculatePrice是子;calculatePrice是父,selectById是子;selectById是父,SQL 是子。
要记录这种父子关系,最方便的就是用ThreadLocal。因为每个请求都是一个独立的线程,咱们可以在ThreadLocal里存一个 “当前调用节点”,每当进入一个新的方法(比如从 Controller 到 Service),就创建一个新节点,把它挂到当前节点下面,然后更新ThreadLocal里的 “当前节点”;当方法执行完,再把 “当前节点” 切回父节点。这样一来,整个调用过程就像 “搭积木” 一样,自然形成了一棵树。
总结一下核心流程:
代理DataSource,拦截 SQL 执行,采集 “SQL 语句、参数、执行时间、所属方法”;用 AOP 拦截 Controller、Service、DAO 层方法,记录方法调用关系,构建 “方法调用树”;把 SQL 信息挂载到对应的 DAO 方法节点下,形成完整的 “SQL 调用树”;提供一个简单的 Web 页面,展示调用树,支持按执行时间筛选慢 SQL。
是不是听起来不复杂?接下来咱们一步步撸代码,从 0 到 1 实现这个功能。
三、动手实现:核心代码拆解(小白也能看懂)
咱们先定个小目标:实现一个能独立运行的模块,其他 SpringBoot 项目引入依赖就能用,不用改一行业务代码。整体结构分 3 个部分:代理 DataSource 抓 SQL、AOP 构建调用树、Web 展示调用树。
3.1 第一步:准备基础实体类(存数据用)
首先得定义几个 “容器”,用来存调用树的节点信息。咱们先写两个实体类:MethodNode(方法节点)和SqlNode(SQL 节点)。
复制
// 方法节点:存Controller/Service/DAO的方法信息
@Data
public class MethodNode {
// 方法唯一标识(比如com.xxx.OrderService.createOrder)
private String methodId;
// 方法名(比如createOrder)
private String methodName;
// 方法所在类名(比如com.xxx.OrderService)
private String className;
// 开始执行时间(毫秒时间戳)
private long startTime;
// 结束执行时间(毫秒时间戳)
private long endTime;
// 执行耗时(毫秒)
private long costTime;
// 子节点:可能是方法节点,也可能是SQL节点
private List<Object> children = new ArrayList<>();
// 父节点
private MethodNode parent;
}
// SQL节点:存SQL执行信息
@Data
public class SqlNode {
// SQL语句(比如select * from product where id = ?)
private String sql;
// SQL参数(比如[id=123])
private String parameters;
// 执行开始时间
private long startTime;
// 执行结束时间
private long endTime;
// 执行耗时
private long costTime;
// 所属DAO方法(比如com.xxx.ProductDAO.selectById)
private String belongMethodId;
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.
这两个类很简单,就是把咱们需要的信息用字段存起来。MethodNode里有children列表,既可以放子MethodNode(比如 Service 调用 DAO),也可以放SqlNode(比如 DAO 执行 SQL),这样就能形成 “方法→方法→SQL” 的层级关系。
3.2 第二步:代理 DataSource,抓 SQL 信息
咱们要写一个ProxyDataSource,实现DataSource接口,把真实的DataSource作为属性注入进来。这样所有通过这个代理DataSource获取的连接,执行 SQL 时都会被咱们拦截。
首先,先实现DataSource的所有方法(大部分都是直接调用真实DataSource的方法),重点在getConnection()方法 —— 咱们要返回一个代理的Connection,因为 SQL 最终是通过Connection执行的。
复制
// 代理DataSource
public class ProxyDataSource implements DataSource {
// 真实的DataSource(比如HikariDataSource)
private DataSource targetDataSource;
// SQL节点构造器(后面会写,用来处理SQL参数和耗时)
private SqlNodeBuilder sqlNodeBuilder;
// 构造方法,注入真实DataSource和SqlNodeBuilder
public ProxyDataSource(DataSource targetDataSource, SqlNodeBuilder sqlNodeBuilder) {
this.targetDataSource = targetDataSource;
this.sqlNodeBuilder = sqlNodeBuilder;
}
// 重点:返回代理Connection
@Override
public Connection getConnection() throws SQLException {
// 获取真实Connection
Connection targetConn = targetDataSource.getConnection();
// 返回代理Connection
return Proxy.newProxyInstance(
Connection.class.getClassLoader(),
new Class[]{Connection.class},
new ConnectionInvocationHandler(targetConn, sqlNodeBuilder)
);
}
// 下面这些方法都是直接调用真实DataSource的实现,不用改
@Override
public Connection getConnection(String username, String password) throws SQLException {
Connection targetConn = targetDataSource.getConnection(username, password);
return Proxy.newProxyInstance(
Connection.class.getClassLoader(),
new Class[]{Connection.class},
new ConnectionInvocationHandler(targetConn, sqlNodeBuilder)
);
}
@Override
public PrintWriter getLogWriter() throws SQLException {
return targetDataSource.getLogWriter();
}
// 省略其他DataSource接口方法的实现...
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.
接下来写ConnectionInvocationHandler,这是Connection代理的核心,负责拦截createStatement()、prepareStatement()这些方法,因为 SQL 是通过Statement或PreparedStatement执行的。
复制
// Connection代理的调用处理器
public class ConnectionInvocationHandler implements InvocationHandler {
private Connection targetConn;
private SqlNodeBuilder sqlNodeBuilder;
public ConnectionInvocationHandler(Connection targetConn, SqlNodeBuilder sqlNodeBuilder) {
this.targetConn = targetConn;
this.sqlNodeBuilder = sqlNodeBuilder;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 如果是创建Statement或PreparedStatement的方法,返回代理对象
if ("createStatement".equals(method.getName())) {
Statement targetStmt = (Statement) method.invoke(targetConn, args);
return Proxy.newProxyInstance(
Statement.class.getClassLoader(),
new Class[]{Statement.class},
new StatementInvocationHandler(targetStmt, sqlNodeBuilder)
);
} else if ("prepareStatement".equals(method.getName())) {
// 这里args[0]就是SQL语句
String sql = (String) args[0];
PreparedStatement targetPstmt = (PreparedStatement) method.invoke(targetConn, args);
return Proxy.newProxyInstance(
PreparedStatement.class.getClassLoader(),
new Class[]{PreparedStatement.class},
new PreparedStatementInvocationHandler(targetPstmt, sql, sqlNodeBuilder)
);
} else {
// 其他方法直接调用真实Connection的实现
return method.invoke(targetConn, args);
}
}
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.
再往下,就是StatementInvocationHandler和PreparedStatementInvocationHandler,负责拦截execute()、executeQuery()这些执行 SQL 的方法,计算执行时间,采集 SQL 信息。这里重点说PreparedStatementInvocationHandler(因为咱们平时用 MyBatis 大多是预编译 SQL,带参数的):
复制
// PreparedStatement代理的调用处理器(处理带参数的SQL)
public class PreparedStatementInvocationHandler implements InvocationHandler {
private PreparedStatement targetPstmt;
// SQL语句(比如select * from product where id = ?)
private String sql;
private SqlNodeBuilder sqlNodeBuilder;
public PreparedStatementInvocationHandler(PreparedStatement targetPstmt, String sql, SqlNodeBuilder sqlNodeBuilder) {
this.targetPstmt = targetPstmt;
this.sql = sql;
this.sqlNodeBuilder = sqlNodeBuilder;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 只拦截执行SQL的方法(execute、executeQuery、executeUpdate)
String methodName = method.getName();
if (methodName.equals("execute") || methodName.equals("executeQuery") || methodName.equals("executeUpdate")) {
// 记录开始时间
long startTime = System.currentTimeMillis();
try {
// 执行真实的SQL
return method.invoke(targetPstmt, args);
} finally {
// 记录结束时间,计算耗时
long endTime = System.currentTimeMillis();
long costTime = endTime - startTime;
// 构建SQL节点:处理参数,关联所属方法
SqlNode sqlNode = sqlNodeBuilder.build(sql, targetPstmt, startTime, endTime, costTime);
// 把SQL节点添加到当前调用树的方法节点下
CallTreeHolder.addSqlNode(sqlNode);
}
} else {
// 其他方法(比如setInt、setString)直接调用真实实现
return method.invoke(targetPstmt, args);
}
}
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.
这里有两个关键:一是SqlNodeBuilder(用来处理 SQL 参数,把?替换成真实参数值),二是CallTreeHolder(用来把 SQL 节点添加到调用树)。咱们先写SqlNodeBuilder:
复制
// SQL节点构造器:处理参数和SQL节点信息
@Component
public class SqlNodeBuilder {
// 构建SqlNode
public SqlNode build(String sql, PreparedStatement pstmt, long startTime, long endTime, long costTime) {
SqlNode sqlNode = new SqlNode();
sqlNode.setSql(sql);
sqlNode.setStartTime(startTime);
sqlNode.setEndTime(endTime);
sqlNode.setCostTime(costTime);
// 处理SQL参数:把?替换成真实值
sqlNode.setParameters(getParameters(pstmt));
// 获取当前正在执行的DAO方法ID(从CallTreeHolder的ThreadLocal里拿)
sqlNode.setBelongMethodId(CallTreeHolder.getCurrentMethodId());
return sqlNode;
}
// 处理PreparedStatement的参数,比如把?id=123变成[id=123]
private String getParameters(PreparedStatement pstmt) {
try {
// 通过PreparedStatement获取参数元数据
ParameterMetaData metaData = pstmt.getParameterMetaData();
int paramCount = metaData.getParameterCount();
if (paramCount == 0) {
return "[]";
}
StringBuilder params = new StringBuilder("[");
for (int i = 1; i <= paramCount; i++) {
// 这里需要注意:PreparedStatement没有直接获取参数值的方法,咱们得用反射
// 因为不同数据库驱动的PreparedStatement实现不一样,这里以MySQL的为例
Field field = pstmt.getClass().getDeclaredField("parameterValues");
field.setAccessible(true);
Object[] parameterValues = (Object[]) field.get(pstmt);
if (parameterValues[i - 1] != null) {
params.append(metaData.getParameterTypeName(i)).append("=").append(parameterValues[i - 1]);
} else {
params.append("null");
}
if (i < paramCount) {
params.append(", ");
}
}
params.append("]");
return params.toString();
} catch (Exception e) {
// 反射失败也不影响主流程,返回未知参数
return "[unknown parameters]";
}
}
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.
这里有个小细节:PreparedStatement没有提供直接获取参数值的 API,所以咱们用反射取parameterValues字段(这是 MySQL 驱动里的字段,其他数据库可能不一样,比如 Oracle 是bindVars,实际用的时候可以做适配)。如果反射失败,就返回 “unknown parameters”,不影响主流程,毕竟能拿到 SQL 和耗时已经很有用了。
3.3 第三步:用 AOP 构建方法调用树
接下来要解决 “怎么记录方法调用关系” 的问题。咱们用 Spring 的 AOP,拦截 Controller、Service、DAO 层的方法,在方法执行前创建MethodNode,挂到父节点下;方法执行后计算耗时。
首先,得定义一个 AOP 切面,并且用ThreadLocal存储当前调用树的根节点和当前节点 —— 这就是CallTreeHolder:
复制
// 调用树持有者:用ThreadLocal存储每个线程的调用树
@Component
public class CallTreeHolder {
// 每个线程的调用树根节点(一个请求对应一个根节点)
private static final ThreadLocal<MethodNode> ROOT_NODE = new ThreadLocal<>();
// 每个线程的当前方法节点(用来挂子节点)
private static final ThreadLocal<MethodNode> CURRENT_NODE = new ThreadLocal<>();
// 每个线程的当前方法ID(用来关联SQL节点)
private static final ThreadLocal<String> CURRENT_METHOD_ID = new ThreadLocal<>();
// 方法执行前:创建方法节点,加入调用树
public void beforeMethod(String className, String methodName) {
// 创建新的方法节点
MethodNode newNode = new MethodNode();
String methodId = className + "." + methodName;
newNode.setMethodId(methodId);
newNode.setClassName(className);
newNode.setMethodName(methodName);
newNode.setStartTime(System.currentTimeMillis());
// 获取当前节点(父节点)
MethodNode currentNode = CURRENT_NODE.get();
if (currentNode == null) {
// 如果没有当前节点,说明是根节点(比如Controller方法)
ROOT_NODE.set(newNode);
} else {
// 如果有当前节点,就把新节点加到父节点的children里
currentNode.getChildren().add(newNode);
newNode.setParent(currentNode);
}
// 更新当前节点和当前方法ID
CURRENT_NODE.set(newNode);
CURRENT_METHOD_ID.set(methodId);
}
// 方法执行后:计算耗时,切回父节点
public void afterMethod() {
MethodNode currentNode = CURRENT_NODE.get();
if (currentNode == null) {
return;
}
// 计算耗时
long endTime = System.currentTimeMillis();
currentNode.setEndTime(endTime);
currentNode.setCostTime(endTime - currentNode.getStartTime());
// 切回父节点
MethodNode parentNode = currentNode.getParent();
CURRENT_NODE.set(parentNode);
CURRENT_METHOD_ID.set(parentNode != null ? parentNode.getMethodId() : null);
// 如果切回父节点后是null,说明整个调用链结束,清空ThreadLocal(避免内存泄漏)
if (parentNode == null) {
ROOT_NODE.remove();
CURRENT_NODE.remove();
CURRENT_METHOD_ID.remove();
}
}
// 添加SQL节点到当前方法节点下
public void addSqlNode(SqlNode sqlNode) {
MethodNode currentNode = CURRENT_NODE.get();
if (currentNode != null) {
currentNode.getChildren().add(sqlNode);
}
}
// 获取当前调用树的根节点
public MethodNode getRootNode() {
return ROOT_NODE.get();
}
// 获取当前方法ID(给SqlNodeBuilder用)
public String getCurrentMethodId() {
return CURRENT_METHOD_ID.get();
}
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.53.54.55.56.57.58.59.60.61.62.63.64.65.66.67.68.69.
然后写 AOP 切面,拦截指定注解或包下的方法。这里咱们可以定义一个@CallTreeMonitor注解,让用户自己决定哪些方法需要被监控;同时默认拦截@RestController、@Service、@Repository注解的类的方法(这样不用用户手动加注解)。
复制
// 自定义注解:标记需要监控的方法
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CallTreeMonitor {
}
// AOP切面:拦截方法,构建调用树
@Aspect
@Component
@Order(Ordered.HIGHEST_PRECEDENCE) // 确保AOP优先级最高,先于其他切面执行
public class CallTreeAspect {
@Autowired
private CallTreeHolder callTreeHolder;
// 切入点:1.加了@CallTreeMonitor注解的方法;2.@RestController/@Service/@Repository类的方法
@Pointcut("@annotation(com.xxx.CallTreeMonitor) " +
"|| @within(org.springframework.web.bind.annotation.RestController) " +
"|| @within(org.springframework.stereotype.Service) " +
"|| @within(org.springframework.stereotype.Repository)")
public void callTreePointcut() {
}
// 方法执行前:创建方法节点
@Before("callTreePointcut()")
public void before(JoinPoint joinPoint) {
// 获取类名和方法名
String className = joinPoint.getTarget().getClass().getName();
String methodName = joinPoint.getSignature().getName();
// 调用CallTreeHolder创建节点
callTreeHolder.beforeMethod(className, methodName);
}
// 方法执行后:计算耗时,切回父节点
@After("callTreePointcut()")
public void after(JoinPoint joinPoint) {
callTreeHolder.afterMethod();
}
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.
这里有个小注意点:AOP 的Order要设为最高优先级(Ordered.HIGHEST_PRECEDENCE),因为如果有其他 AOP 切面(比如事务切面、日志切面),咱们的调用树切面要先执行,才能正确记录方法调用顺序。
3.4 第四步:把代理 DataSource 注入 Spring 容器
咱们写的ProxyDataSource要替换掉 SpringBoot 默认的DataSource,这样才能生效。怎么替换呢?用BeanPostProcessor,在 Spring 初始化DataSource bean 之后,把它换成咱们的代理对象。
复制
// DataSource后置处理器:把默认DataSource换成代理DataSource
@Component
public class DataSourceProxyBeanPostProcessor implements BeanPostProcessor {
@Autowired
private SqlNodeBuilder sqlNodeBuilder;
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
// 如果bean是DataSource类型,并且不是咱们的ProxyDataSource,就代理它
if (bean instanceof DataSource && !(bean instanceof ProxyDataSource)) {
return new ProxyDataSource((DataSource) bean, sqlNodeBuilder);
}
return bean;
}
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.
这样一来,不管用户用的是 HikariCP 还是 Druid,只要是DataSource类型的 bean,都会被咱们代理。而且这个过程对用户是透明的,不用改任何配置。
3.5 第五步:Web 页面展示调用树
调用树建好了,得有个地方看啊。咱们用 SpringMVC 写两个接口:一个用来获取当前请求的调用树,一个提供一个简单的 HTML 页面展示。
首先写 Controller:
复制
// 调用树展示Controller
@RestController
@RequestMapping("/sql-call-tree")
publicclass CallTreeController {
@Autowired
private CallTreeHolder callTreeHolder;
// 获取当前请求的调用树(JSON格式)
@GetMapping("/current")
public Result<MethodNode> getCurrentCallTree() {
MethodNode rootNode = callTreeHolder.getRootNode();
if (rootNode == null) {
return Result.fail("当前请求没有调用树数据");
}
return Result.success(rootNode);
}
// 展示调用树页面(HTML)
@GetMapping("/view")
public ModelAndView viewCallTree(ModelAndView mav) {
mav.setViewName("call-tree"); // 对应templates目录下的call-tree.html
return mav;
}
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.
然后写 HTML 页面,用 Vue.js+Element UI 来展示树形结构(因为 Element UI 的 Tree 组件很好用,而且不用写太多 JS)。咱们把 HTML 放在resources/templates目录下:
复制
<!-- call-tree.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>SQL调用树</title>
<!-- 引入Vue和Element UI -->
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
<style>
.slow-sql {
color: #F56C6C;
font-weight: bold;
}
.method-node {
color: #409EFF;
}
.tree-node-content {
white-space: nowrap;
}
</style>
</head>
<body>
<div id="app" style="margin: 20px;">
<el-input
v-model="slowThreshold"
placeholder="请输入慢SQL阈值(毫秒),默认500"
style="width: 300px; margin-bottom: 20px;"
type="number"
></el-input>
<el-button type="primary" @click="loadCallTree">加载当前请求调用树</el-button>
<el-tree
:data="treeData"
:props="treeProps"
:render-content="renderContent"
accordion
style="margin-top: 20px; max-height: 800px; overflow-y: auto;"
></el-tree>
</div>
<script>
new Vue({
el: #app,
data() {
return {
slowThreshold: 500, // 默认慢SQL阈值:500毫秒
treeData: [],
treeProps: {
children: children,
label: label// 自定义标签,后面用renderContent渲染
}
};
},
methods: {
// 加载当前请求的调用树
loadCallTree() {
let _this = this;
this.$http.get(/sql-call-tree/current)
.then(response => {
let result = response.data;
if (result.success) {
// 把MethodNode转换成Tree组件需要的格式
_this.treeData = [_this.convertNode(result.data)];
} else {
_this.$message.error(result.msg);
}
})
.catch(error => {
_this.$message.error(加载调用树失败: + error.message);
});
},
// 转换节点:MethodNode和SqlNode统一成Tree组件的格式
convertNode(node) {
let treeNode = {
children: []
};
// 如果是MethodNode(有methodId字段)
if (node.methodId) {
treeNode.label = `${node.className}.${node.methodName}`;
treeNode.type = method;
treeNode.costTime = node.costTime;
// 转换子节点
if (node.children && node.children.length > 0) {
treeNode.children = node.children.map(child =>this.convertNode(child));
}
}
// 如果是SqlNode(有sql字段)
elseif (node.sql) {
treeNode.label = node.sql;
treeNode.type = sql;
treeNode.costTime = node.costTime;
treeNode.parameters = node.parameters;
treeNode.belongMethod = node.belongMethodId;
}
return treeNode;
},
// 自定义渲染节点内容
renderContent(h, {node, data, store}) {
let content = ;
if (data.type === method) {
// 方法节点:显示类名.方法名 + 耗时
content = `<span class="method-node tree-node-content">
${data.label} <span style="color: #666; margin-left: 10px;">耗时:${data.costTime}ms</span>
</span>`;
} elseif (data.type === sql) {
// SQL节点:慢SQL标红,显示参数和耗时
let sqlClass = data.costTime >= this.slowThreshold ? slow-sql : ;
content = `<span class="${sqlClass} tree-node-content">
SQL:${data.label}
<span style="color: #666; margin-left: 10px;">参数:${data.parameters}</span>
<span style="color: #666; margin-left: 10px;">耗时:${data.costTime}ms</span>
</span>`;
}
return h(div, {
domProps: {
innerHTML: content
}
});
}
},
mounted() {
// 页面加载时自动加载调用树
this.loadCallTree();
}
});
</script>
</body>
</html>1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.53.54.55.56.57.58.59.60.61.62.63.64.65.66.67.68.69.70.71.72.73.74.75.76.77.78.79.80.81.82.83.84.85.86.87.88.89.90.91.92.93.94.95.96.97.98.99.100.101.102.103.104.105.106.107.108.109.110.111.112.113.114.115.116.117.118.119.120.121.122.123.124.125.126.127.128.129.
这个页面很简单:顶部有个输入框,用来设置慢 SQL 阈值(默认 500 毫秒),点击按钮加载当前请求的调用树,用不同颜色区分方法节点和 SQL 节点,慢 SQL 标红显示。这样一来,只要访问http://localhost:8080/sql-call-tree/view,就能看到当前请求的 SQL 调用树了。
3.6 第六步:打包成 Starter,方便集成
为了让其他项目能快速集成,咱们把这个功能打包成 SpringBoot Starter。 Starter 的核心是spring.factories文件,用来告诉 Spring 要自动配置哪些类。
首先,在resources/META-INF目录下创建spring.factories:
复制
# Spring自动配置类
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.xxx.sqlcalltree.autoconfigure.CallTreeAutoConfiguration1.2.3.
然后写自动配置类CallTreeAutoConfiguration:
复制
// 自动配置类
@Configuration
@EnableAspectJAutoProxy// 启用AOP
publicclass CallTreeAutoConfiguration {
// 注册SqlNodeBuilder
@Bean
public SqlNodeBuilder sqlNodeBuilder() {
returnnew SqlNodeBuilder();
}
// 注册CallTreeHolder
@Bean
public CallTreeHolder callTreeHolder() {
returnnew CallTreeHolder();
}
// 注册CallTreeAspect
@Bean
public CallTreeAspect callTreeAspect(CallTreeHolder callTreeHolder) {
CallTreeAspect aspect = new CallTreeAspect();
// 手动注入CallTreeHolder(因为@Autowired在切面里可能不生效,需要构造注入)
try {
Field field = CallTreeAspect.class.getDeclaredField("callTreeHolder");
field.setAccessible(true);
field.set(aspect, callTreeHolder);
} catch (Exception e) {
thrownew RuntimeException("注入CallTreeHolder失败", e);
}
return aspect;
}
// 注册DataSourceProxyBeanPostProcessor
@Bean
public DataSourceProxyBeanPostProcessor dataSourceProxyBeanPostProcessor(SqlNodeBuilder sqlNodeBuilder) {
DataSourceProxyBeanPostProcessor postProcessor = new DataSourceProxyBeanPostProcessor();
// 手动注入SqlNodeBuilder
try {
Field field = DataSourceProxyBeanPostProcessor.class.getDeclaredField("sqlNodeBuilder");
field.setAccessible(true);
field.set(postProcessor, sqlNodeBuilder);
} catch (Exception e) {
thrownew RuntimeException("注入SqlNodeBuilder失败", e);
}
return postProcessor;
}
// 注册CallTreeController
@Bean
public CallTreeController callTreeController(CallTreeHolder callTreeHolder) {
CallTreeController controller = new CallTreeController();
// 手动注入CallTreeHolder
try {
Field field = CallTreeController.class.getDeclaredField("callTreeHolder");
field.setAccessible(true);
field.set(controller, callTreeHolder);
} catch (Exception e) {
thrownew RuntimeException("注入CallTreeHolder失败", e);
}
return controller;
}
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.53.54.55.56.57.58.59.60.61.
这里有个小细节:因为 AOP 切面和 BeanPostProcessor 这些类的初始化顺序比较特殊,@Autowired可能不生效,所以咱们用反射手动注入依赖。虽然看起来有点麻烦,但能确保依赖注入成功。最后,在pom.xml里配置打包信息(以 Maven 为例):
复制
<groupId>com.xxx</groupId>
<artifactId>sql-call-tree-spring-boot-starter</artifactId>
<version>1.0.0</version>
<name>SQL Call Tree Starter</name>
<description>SpringBoot 运行时 SQL 调用树 Starter,快速定位慢 SQL</description>
<dependencies>
<!-- SpringBoot核心依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
<scope>provided</scope>
</dependency>
<!-- 工具类 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.24</version>
<scope>provided</scope>
</dependency>
<!-- 数据库驱动(按需引入,这里只做示例) -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.30</version>
<scope>provided</scope>
</dependency>
</dependencies>1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.
这样,一个 SQL 调用树 Starter 就打包好了。其他 SpringBoot 项目只要引入这个依赖,不用改任何代码,就能用这个功能了!
四、实战:3 分钟定位慢 SQL
光说不练假把式,咱们拿一个真实的业务场景来测试一下 —— 用户下单接口。
4.1 集成 Starter
首先,在 SpringBoot 项目的pom.xml里引入依赖:
复制
<dependency>
<groupId>com.xxx</groupId>
<artifactId>sql-call-tree-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>1.2.3.4.5.
然后启动项目,访问http://localhost:8080/sql-call-tree/view,准备看调用树。
4.2 模拟下单接口
咱们写一个简单的下单接口,包含 3 个 Service 方法和 3 个 DAO 方法:
复制
// Controller
@RestController
@RequestMapping("/order")
publicclass OrderController {
@Autowired
private OrderService orderService;
@PostMapping("/create")
public Result<OrderVO> createOrder(@RequestBody OrderDTO orderDTO) {
return Result.success(orderService.createOrder(orderDTO));
}
}
// Service
@Service
publicclass OrderService {
@Autowired
private UserDAO userDAO;
@Autowired
private ProductDAO productDAO;
@Autowired
private OrderDAO orderDAO;
public OrderVO createOrder(OrderDTO dto) {
// 1. 查询用户信息
User user = userDAO.selectById(dto.getUserId());
// 2. 查询商品信息
Product product = productDAO.selectById(dto.getProductId());
// 3. 创建订单
Order order = new Order();
order.setUserId(dto.getUserId());
order.setProductId(dto.getProductId());
order.setAmount(product.getPrice() * dto.getQuantity());
orderDAO.insert(order);
// 4. 组装返回结果
OrderVO vo = new OrderVO();
BeanUtils.copyProperties(order, vo);
vo.setUserName(user.getName());
vo.setProductName(product.getName());
return vo;
}
}
// DAO(MyBatis)
@Repository
publicinterface UserDAO {
User selectById(Long id);
}
@Repository
publicinterface ProductDAO {
Product selectById(Long id);
}
@Repository
publicinterface OrderDAO {
void insert(Order order);
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.53.54.55.56.57.58.
对应的 MyBatis XML(重点看ProductDAO.selectById,咱们故意加个慢查询):
复制
<!-- ProductDAO.xml -->
<select id="selectById" resultType="com.xxx.Product">
<!-- 故意加个sleep(1000),模拟慢SQL -->
select sleep(1) as sleep, id, name, price from product where id = #{id}
</select>1.2.3.4.5.
4.3 查看调用树,定位慢 SQL
用 Postman 调用POST /order/create接口,传入参数:
复制
{
"userId": 1,
"productId": 1001,
"quantity": 2
}1.2.3.4.5.
访问http://localhost:8080/sql-call-tree/view,点击 “加载当前请求调用树”,就能看到这样的树形结构:
复制
com.xxx.OrderController.createOrder(耗时:1050ms)
└── com.xxx.OrderService.createOrder(耗时:1045ms)
├── com.xxx.UserDAO.selectById(耗时:10ms)
│ └── SQL:selectid, namefromuserwhereid = ?(参数:[BIGINT=1],耗时:8ms)
├── com.xxx.ProductDAO.selectById(耗时:1010ms)
│ └── SQL:selectsleep(1) assleep, id, name, price from product whereid = ?(参数:[BIGINT=1001],耗时:1008ms)
└── com.xxx.OrderDAO.insert(耗时:15ms)
└── SQL:insertintoorder (user_id, product_id, amount) values (?, ?, ?)(参数:[BIGINT=1, BIGINT=1001, DECIMAL=200.00],耗时:12ms)1.2.3.4.5.6.7.8.
因为咱们设置的慢 SQL 阈值是 500ms,所以ProductDAO.selectById对应的 SQL 会标红显示。一眼就能看出来:整个下单接口耗时 1.05 秒,主要是ProductDAO.selectById的 SQL 慢了,耗时 1.008 秒。再点进这个 SQL 看一下,发现里面有sleep(1),瞬间就知道问题所在了 —— 这就是 3 分钟定位慢 SQL 的魅力!
五、优化与扩展:让工具更实用
咱们这个基础版本已经能解决大部分问题了,但实际用的时候,还可以做一些优化和扩展,让它更强大。
5.1 性能优化:避免影响业务
有老铁可能会担心:代理DataSource和 AOP 会不会影响业务性能?其实不用太担心,因为咱们的代码都很轻量,主要是记录时间和构建节点,没有复杂的逻辑。但如果是高并发场景,还是可以做一些优化:
开关控制:加一个配置项(比如sql.call.tree.enabled=true/false),让用户可以在生产环境按需开启,非高峰时段排查问题时再打开。采样率控制:加一个采样率配置(比如sql.call.tree.sample.rate=0.1),只采集 10% 的请求,减少性能消耗。异步存储:如果需要持久化调用树数据(比如存到 MySQL 或 Elasticsearch),可以用异步线程池,避免阻塞业务线程。
5.2 功能扩展:满足更多需求
支持 SQL 格式化:在展示 SQL 的时候,用工具类(比如com.alibaba.druid.sql.SQLUtils)把 SQL 格式化,看起来更清晰。SQL 执行计划分析:集成EXPLAIN语句,点击 SQL 节点就能查看执行计划,直接判断是否缺少索引。多请求对比:把调用树数据持久化后,支持对比不同请求的调用树,看哪个 SQL 的耗时突然增加了。告警功能:当出现慢 SQL 时,自动发送告警(比如钉钉、企业微信),不用人工盯着页面。
5.3 适配更多场景
支持 JPA/Hibernate:目前咱们只适配了 MyBatis,其实 JPA/Hibernate 也是通过DataSource执行 SQL 的,只要调整SqlNodeBuilder里获取参数的逻辑,就能支持。支持多数据源:如果项目用了多数据源(比如动态数据源),只要确保每个DataSource都被代理,就能正常采集 SQL 信息。支持分布式链路:如果是分布式项目,可以把调用树的rootNode和分布式链路 ID(比如 Trace ID)关联起来,在 SkyWalking 等工具里也能看到 SQL 调用树。
六、总结:为啥这个工具值得收藏?
咱们花了这么多篇幅,从思路到代码,再到实战,把 SpringBoot 自研 SQL 调用树的整个过程讲透了。这个工具之所以值得收藏,有三个原因:
轻量级:不用搭额外的服务,集成 Starter 就能用,小项目无压力。直观:把 “业务→方法→SQL” 的调用关系可视化,慢 SQL 一目了然,不用再对着日志 “大海捞针”。灵活:可以根据自己的需求扩展功能,比如加告警、加 SQL 分析,定制成适合自己项目的工具。