SpringBoot 自研运行时 SQL 调用树,三分钟定位慢 SQL!

小伙伴们,当线上项目突然卡得像老黄牛拉破车,日志刷了几百行,一眼望去全是 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 分析,定制成适合自己项目的工具。

阅读剩余
THE END