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;
}這兩個類很簡單,就是把咱們需要的信息用字段存起來。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接口方法的實現...
}接下來寫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);
}
}
}再往下,就是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);
}
}
}這里有兩個關鍵:一是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]";
}
}
}這里有個小細節: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();
}
}然后寫 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();
}
}這里有個小注意點: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;
}
}這樣一來,不管用戶用的是 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;
}
}然后寫 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" >
<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>這個頁面很簡單:頂部有個輸入框,用來設置慢 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.CallTreeAutoConfiguration然后寫自動配置類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;
}
}這里有個小細節:因為 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>這樣,一個 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>然后啟動項目,訪問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);
}對應的 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>4.3 查看調用樹,定位慢 SQL
- 用 Postman 調用POST /order/create接口,傳入參數:
{
"userId": 1,
"productId": 1001,
"quantity": 2
}- 訪問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)因為咱們設置的慢 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 分析,定制成適合自己項目的工具。






























