SpringBoot 自研運行時 SQL 調(diào)用樹,三分鐘定位慢 SQL!
小伙伴們,當(dāng)線上項目突然卡得像老黃牛拉破車,日志刷了幾百行,一眼望去全是 SQL 執(zhí)行記錄,你知道是哪個 “搗蛋鬼” 拖慢了整個流程?
上次我同事小王就遇到這糟心情況,用戶反饋下單接口要等 5 秒才能出結(jié)果,他對著滿屏的DEBUG日志翻了倆小時,一會兒查 MyBatis 日志,一會兒看鏈路追蹤,最后才發(fā)現(xiàn)是個沒加索引的count(*)在搞事。當(dāng)時他就吐槽:“要是能一眼看出哪個 SQL 在哪個業(yè)務(wù)步驟里慢了,我至于熬到半夜嗎?”
這不,為了解決這個 “慢 SQL 定位難” 的千古難題,我基于 SpringBoot 搞了個 “運行時 SQL 調(diào)用樹”—— 不管你業(yè)務(wù)多復(fù)雜,多少個 SQL 嵌套調(diào)用,它都能像思維導(dǎo)圖一樣把調(diào)用關(guān)系理得明明白白,再配上執(zhí)行時間,慢 SQL 直接原地 “立正挨打”,3 分鐘就能精準(zhǔn)定位!今天就把這干貨手把手教給大家,保證小白也能看懂,看完就能用。
一、先搞懂:為啥咱們需要 “SQL 調(diào)用樹”?
在聊怎么實現(xiàn)之前,咱得先掰扯清楚:市面上現(xiàn)成的工具不少,為啥還要自研?
你可能會說,我用日志不就行了?確實,MyBatis 能打印 SQL 執(zhí)行時間,Logback 能輸出調(diào)用棧,但問題是 —— 日志是 “線性” 的。比如一個下單接口,要查用戶余額、扣庫存、生成訂單、記錄日志,4 個步驟對應(yīng) 4 條 SQL 日志,你得自己對著日志里的時間戳和線程 ID,腦補出 “誰調(diào)用了誰”,要是遇到多線程或者嵌套調(diào)用,直接就懵圈了。
那用鏈路追蹤工具呢?像 SkyWalking、Pinpoint 這些,確實能看調(diào)用鏈路,但它們有兩個小毛?。阂皇桥渲脧?fù)雜,還得搭服務(wù)端,小項目用著嫌重;二是側(cè)重點在 “服務(wù)間調(diào)用”,對應(yīng)用內(nèi)部的 SQL 調(diào)用細節(jié)展示得不夠細,有時候你知道是某個接口慢了,但還是得鉆到應(yīng)用日志里找具體 SQL。
還有數(shù)據(jù)庫自帶的慢查詢?nèi)罩??比?MySQL 的 slow_query_log,它能抓到慢 SQL,但問題是 “只知其然,不知其所以然”—— 你知道這條 SQL 慢了,可它是哪個業(yè)務(wù)接口調(diào)的?是在 “創(chuàng)建訂單” 還是 “計算優(yōu)惠” 步驟里執(zhí)行的?完全不知道,還得回頭去代碼里搜,效率太低。
所以咱們需要的是一個 “中間件”:既能輕量級集成到 SpringBoot 項目,又能清晰展示 “業(yè)務(wù)接口→Service→DAO→SQL” 的調(diào)用關(guān)系,還能把每個 SQL 的執(zhí)行時間標(biāo)出來 —— 這就是 “SQL 調(diào)用樹” 要干的活。簡單說,它就像給 SQL 裝了個 “導(dǎo)航儀”,哪里慢了,一查就知道。
二、核心思路:怎么讓 SQL “自己報家門”?
要做 SQL 調(diào)用樹,核心就解決兩個問題:一是怎么抓 SQL 的執(zhí)行信息,二是怎么把這些信息按調(diào)用關(guān)系組織成樹。
先想第一個問題:怎么抓 SQL 信息?咱們用 SpringBoot 開發(fā),SQL 大多是通過 MyBatis、JPA 這些 ORM 框架執(zhí)行的,而這些框架在 Spring 生態(tài)里,都繞不開一個東西 ——DataSource。不管你是用HikariCP還是Druid,所有 SQL 最終都要通過DataSource獲取連接,然后執(zhí)行。
所以第一個關(guān)鍵點來了:代理 DataSource。咱們可以自己寫一個DataSource的代理類,把原本的DataSource包一層,這樣每次執(zhí)行 SQL 的時候,就能在代理類里 “插一腳”,把 SQL 語句、執(zhí)行時間、調(diào)用棧這些信息抓下來。
再想第二個問題:怎么組織成樹?調(diào)用關(guān)系是有 “父子” 的,比如OrderController.createOrder()調(diào)用OrderService.calculatePrice(),calculatePrice()又調(diào)用ProductDAO.selectById(),selectById()最終執(zhí)行了 SQL。這里createOrder是父,calculatePrice是子;calculatePrice是父,selectById是子;selectById是父,SQL 是子。
要記錄這種父子關(guān)系,最方便的就是用ThreadLocal。因為每個請求都是一個獨立的線程,咱們可以在ThreadLocal里存一個 “當(dāng)前調(diào)用節(jié)點”,每當(dāng)進入一個新的方法(比如從 Controller 到 Service),就創(chuàng)建一個新節(jié)點,把它掛到當(dāng)前節(jié)點下面,然后更新ThreadLocal里的 “當(dāng)前節(jié)點”;當(dāng)方法執(zhí)行完,再把 “當(dāng)前節(jié)點” 切回父節(jié)點。這樣一來,整個調(diào)用過程就像 “搭積木” 一樣,自然形成了一棵樹。
總結(jié)一下核心流程:
- 代理DataSource,攔截 SQL 執(zhí)行,采集 “SQL 語句、參數(shù)、執(zhí)行時間、所屬方法”;
- 用 AOP 攔截 Controller、Service、DAO 層方法,記錄方法調(diào)用關(guān)系,構(gòu)建 “方法調(diào)用樹”;
- 把 SQL 信息掛載到對應(yīng)的 DAO 方法節(jié)點下,形成完整的 “SQL 調(diào)用樹”;
- 提供一個簡單的 Web 頁面,展示調(diào)用樹,支持按執(zhí)行時間篩選慢 SQL。
是不是聽起來不復(fù)雜?接下來咱們一步步擼代碼,從 0 到 1 實現(xiàn)這個功能。
三、動手實現(xiàn):核心代碼拆解(小白也能看懂)
咱們先定個小目標(biāo):實現(xiàn)一個能獨立運行的模塊,其他 SpringBoot 項目引入依賴就能用,不用改一行業(yè)務(wù)代碼。整體結(jié)構(gòu)分 3 個部分:代理 DataSource 抓 SQL、AOP 構(gòu)建調(diào)用樹、Web 展示調(diào)用樹。
3.1 第一步:準(zhǔn)備基礎(chǔ)實體類(存數(shù)據(jù)用)
首先得定義幾個 “容器”,用來存調(diào)用樹的節(jié)點信息。咱們先寫兩個實體類:MethodNode(方法節(jié)點)和SqlNode(SQL 節(jié)點)。
// 方法節(jié)點:存Controller/Service/DAO的方法信息
@Data
public class MethodNode {
// 方法唯一標(biāo)識(比如com.xxx.OrderService.createOrder)
private String methodId;
// 方法名(比如createOrder)
private String methodName;
// 方法所在類名(比如com.xxx.OrderService)
private String className;
// 開始執(zhí)行時間(毫秒時間戳)
private long startTime;
// 結(jié)束執(zhí)行時間(毫秒時間戳)
private long endTime;
// 執(zhí)行耗時(毫秒)
private long costTime;
// 子節(jié)點:可能是方法節(jié)點,也可能是SQL節(jié)點
private List<Object> children = new ArrayList<>();
// 父節(jié)點
private MethodNode parent;
}
// SQL節(jié)點:存SQL執(zhí)行信息
@Data
public class SqlNode {
// SQL語句(比如select * from product where id = ?)
private String sql;
// SQL參數(shù)(比如[id=123])
private String parameters;
// 執(zhí)行開始時間
private long startTime;
// 執(zhí)行結(jié)束時間
private long endTime;
// 執(zhí)行耗時
private long costTime;
// 所屬DAO方法(比如com.xxx.ProductDAO.selectById)
private String belongMethodId;
}這兩個類很簡單,就是把咱們需要的信息用字段存起來。MethodNode里有children列表,既可以放子MethodNode(比如 Service 調(diào)用 DAO),也可以放SqlNode(比如 DAO 執(zhí)行 SQL),這樣就能形成 “方法→方法→SQL” 的層級關(guān)系。
3.2 第二步:代理 DataSource,抓 SQL 信息
咱們要寫一個ProxyDataSource,實現(xiàn)DataSource接口,把真實的DataSource作為屬性注入進來。這樣所有通過這個代理DataSource獲取的連接,執(zhí)行 SQL 時都會被咱們攔截。
首先,先實現(xiàn)DataSource的所有方法(大部分都是直接調(diào)用真實DataSource的方法),重點在getConnection()方法 —— 咱們要返回一個代理的Connection,因為 SQL 最終是通過Connection執(zhí)行的。
// 代理DataSource
public class ProxyDataSource implements DataSource {
// 真實的DataSource(比如HikariDataSource)
private DataSource targetDataSource;
// SQL節(jié)點構(gòu)造器(后面會寫,用來處理SQL參數(shù)和耗時)
private SqlNodeBuilder sqlNodeBuilder;
// 構(gòu)造方法,注入真實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)
);
}
// 下面這些方法都是直接調(diào)用真實DataSource的實現(xiàn),不用改
@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接口方法的實現(xiàn)...
}接下來寫ConnectionInvocationHandler,這是Connection代理的核心,負責(zé)攔截createStatement()、prepareStatement()這些方法,因為 SQL 是通過Statement或PreparedStatement執(zhí)行的。
// Connection代理的調(diào)用處理器
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 {
// 如果是創(chuàng)建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 {
// 其他方法直接調(diào)用真實Connection的實現(xiàn)
return method.invoke(targetConn, args);
}
}
}再往下,就是StatementInvocationHandler和PreparedStatementInvocationHandler,負責(zé)攔截execute()、executeQuery()這些執(zhí)行 SQL 的方法,計算執(zhí)行時間,采集 SQL 信息。這里重點說PreparedStatementInvocationHandler(因為咱們平時用 MyBatis 大多是預(yù)編譯 SQL,帶參數(shù)的):
// PreparedStatement代理的調(diào)用處理器(處理帶參數(shù)的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 {
// 只攔截執(zhí)行SQL的方法(execute、executeQuery、executeUpdate)
String methodName = method.getName();
if (methodName.equals("execute") || methodName.equals("executeQuery") || methodName.equals("executeUpdate")) {
// 記錄開始時間
long startTime = System.currentTimeMillis();
try {
// 執(zhí)行真實的SQL
return method.invoke(targetPstmt, args);
} finally {
// 記錄結(jié)束時間,計算耗時
long endTime = System.currentTimeMillis();
long costTime = endTime - startTime;
// 構(gòu)建SQL節(jié)點:處理參數(shù),關(guān)聯(lián)所屬方法
SqlNode sqlNode = sqlNodeBuilder.build(sql, targetPstmt, startTime, endTime, costTime);
// 把SQL節(jié)點添加到當(dāng)前調(diào)用樹的方法節(jié)點下
CallTreeHolder.addSqlNode(sqlNode);
}
} else {
// 其他方法(比如setInt、setString)直接調(diào)用真實實現(xiàn)
return method.invoke(targetPstmt, args);
}
}
}這里有兩個關(guān)鍵:一是SqlNodeBuilder(用來處理 SQL 參數(shù),把?替換成真實參數(shù)值),二是CallTreeHolder(用來把 SQL 節(jié)點添加到調(diào)用樹)。咱們先寫SqlNodeBuilder:
// SQL節(jié)點構(gòu)造器:處理參數(shù)和SQL節(jié)點信息
@Component
public class SqlNodeBuilder {
// 構(gòu)建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參數(shù):把?替換成真實值
sqlNode.setParameters(getParameters(pstmt));
// 獲取當(dāng)前正在執(zhí)行的DAO方法ID(從CallTreeHolder的ThreadLocal里拿)
sqlNode.setBelongMethodId(CallTreeHolder.getCurrentMethodId());
return sqlNode;
}
// 處理PreparedStatement的參數(shù),比如把?id=123變成[id=123]
private String getParameters(PreparedStatement pstmt) {
try {
// 通過PreparedStatement獲取參數(shù)元數(shù)據(jù)
ParameterMetaData metaData = pstmt.getParameterMetaData();
int paramCount = metaData.getParameterCount();
if (paramCount == 0) {
return "[]";
}
StringBuilder params = new StringBuilder("[");
for (int i = 1; i <= paramCount; i++) {
// 這里需要注意:PreparedStatement沒有直接獲取參數(shù)值的方法,咱們得用反射
// 因為不同數(shù)據(jù)庫驅(qū)動的PreparedStatement實現(xiàn)不一樣,這里以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) {
// 反射失敗也不影響主流程,返回未知參數(shù)
return "[unknown parameters]";
}
}
}這里有個小細節(jié):PreparedStatement沒有提供直接獲取參數(shù)值的 API,所以咱們用反射取parameterValues字段(這是 MySQL 驅(qū)動里的字段,其他數(shù)據(jù)庫可能不一樣,比如 Oracle 是bindVars,實際用的時候可以做適配)。如果反射失敗,就返回 “unknown parameters”,不影響主流程,畢竟能拿到 SQL 和耗時已經(jīng)很有用了。
3.3 第三步:用 AOP 構(gòu)建方法調(diào)用樹
接下來要解決 “怎么記錄方法調(diào)用關(guān)系” 的問題。咱們用 Spring 的 AOP,攔截 Controller、Service、DAO 層的方法,在方法執(zhí)行前創(chuàng)建MethodNode,掛到父節(jié)點下;方法執(zhí)行后計算耗時。
首先,得定義一個 AOP 切面,并且用ThreadLocal存儲當(dāng)前調(diào)用樹的根節(jié)點和當(dāng)前節(jié)點 —— 這就是CallTreeHolder:
// 調(diào)用樹持有者:用ThreadLocal存儲每個線程的調(diào)用樹
@Component
public class CallTreeHolder {
// 每個線程的調(diào)用樹根節(jié)點(一個請求對應(yīng)一個根節(jié)點)
private static final ThreadLocal<MethodNode> ROOT_NODE = new ThreadLocal<>();
// 每個線程的當(dāng)前方法節(jié)點(用來掛子節(jié)點)
private static final ThreadLocal<MethodNode> CURRENT_NODE = new ThreadLocal<>();
// 每個線程的當(dāng)前方法ID(用來關(guān)聯(lián)SQL節(jié)點)
private static final ThreadLocal<String> CURRENT_METHOD_ID = new ThreadLocal<>();
// 方法執(zhí)行前:創(chuàng)建方法節(jié)點,加入調(diào)用樹
public void beforeMethod(String className, String methodName) {
// 創(chuàng)建新的方法節(jié)點
MethodNode newNode = new MethodNode();
String methodId = className + "." + methodName;
newNode.setMethodId(methodId);
newNode.setClassName(className);
newNode.setMethodName(methodName);
newNode.setStartTime(System.currentTimeMillis());
// 獲取當(dāng)前節(jié)點(父節(jié)點)
MethodNode currentNode = CURRENT_NODE.get();
if (currentNode == null) {
// 如果沒有當(dāng)前節(jié)點,說明是根節(jié)點(比如Controller方法)
ROOT_NODE.set(newNode);
} else {
// 如果有當(dāng)前節(jié)點,就把新節(jié)點加到父節(jié)點的children里
currentNode.getChildren().add(newNode);
newNode.setParent(currentNode);
}
// 更新當(dāng)前節(jié)點和當(dāng)前方法ID
CURRENT_NODE.set(newNode);
CURRENT_METHOD_ID.set(methodId);
}
// 方法執(zhí)行后:計算耗時,切回父節(jié)點
public void afterMethod() {
MethodNode currentNode = CURRENT_NODE.get();
if (currentNode == null) {
return;
}
// 計算耗時
long endTime = System.currentTimeMillis();
currentNode.setEndTime(endTime);
currentNode.setCostTime(endTime - currentNode.getStartTime());
// 切回父節(jié)點
MethodNode parentNode = currentNode.getParent();
CURRENT_NODE.set(parentNode);
CURRENT_METHOD_ID.set(parentNode != null ? parentNode.getMethodId() : null);
// 如果切回父節(jié)點后是null,說明整個調(diào)用鏈結(jié)束,清空ThreadLocal(避免內(nèi)存泄漏)
if (parentNode == null) {
ROOT_NODE.remove();
CURRENT_NODE.remove();
CURRENT_METHOD_ID.remove();
}
}
// 添加SQL節(jié)點到當(dāng)前方法節(jié)點下
public void addSqlNode(SqlNode sqlNode) {
MethodNode currentNode = CURRENT_NODE.get();
if (currentNode != null) {
currentNode.getChildren().add(sqlNode);
}
}
// 獲取當(dāng)前調(diào)用樹的根節(jié)點
public MethodNode getRootNode() {
return ROOT_NODE.get();
}
// 獲取當(dāng)前方法ID(給SqlNodeBuilder用)
public String getCurrentMethodId() {
return CURRENT_METHOD_ID.get();
}
}然后寫 AOP 切面,攔截指定注解或包下的方法。這里咱們可以定義一個@CallTreeMonitor注解,讓用戶自己決定哪些方法需要被監(jiān)控;同時默認攔截@RestController、@Service、@Repository注解的類的方法(這樣不用用戶手動加注解)。
// 自定義注解:標(biāo)記需要監(jiān)控的方法
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CallTreeMonitor {
}
// AOP切面:攔截方法,構(gòu)建調(diào)用樹
@Aspect
@Component
@Order(Ordered.HIGHEST_PRECEDENCE) // 確保AOP優(yōu)先級最高,先于其他切面執(zhí)行
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() {
}
// 方法執(zhí)行前:創(chuàng)建方法節(jié)點
@Before("callTreePointcut()")
public void before(JoinPoint joinPoint) {
// 獲取類名和方法名
String className = joinPoint.getTarget().getClass().getName();
String methodName = joinPoint.getSignature().getName();
// 調(diào)用CallTreeHolder創(chuàng)建節(jié)點
callTreeHolder.beforeMethod(className, methodName);
}
// 方法執(zhí)行后:計算耗時,切回父節(jié)點
@After("callTreePointcut()")
public void after(JoinPoint joinPoint) {
callTreeHolder.afterMethod();
}
}這里有個小注意點:AOP 的Order要設(shè)為最高優(yōu)先級(Ordered.HIGHEST_PRECEDENCE),因為如果有其他 AOP 切面(比如事務(wù)切面、日志切面),咱們的調(diào)用樹切面要先執(zhí)行,才能正確記錄方法調(diào)用順序。
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 頁面展示調(diào)用樹
調(diào)用樹建好了,得有個地方看啊。咱們用 SpringMVC 寫兩個接口:一個用來獲取當(dāng)前請求的調(diào)用樹,一個提供一個簡單的 HTML 頁面展示。
首先寫 Controller:
// 調(diào)用樹展示Controller
@RestController
@RequestMapping("/sql-call-tree")
publicclass CallTreeController {
@Autowired
private CallTreeHolder callTreeHolder;
// 獲取當(dāng)前請求的調(diào)用樹(JSON格式)
@GetMapping("/current")
public Result<MethodNode> getCurrentCallTree() {
MethodNode rootNode = callTreeHolder.getRootNode();
if (rootNode == null) {
return Result.fail("當(dāng)前請求沒有調(diào)用樹數(shù)據(jù)");
}
return Result.success(rootNode);
}
// 展示調(diào)用樹頁面(HTML)
@GetMapping("/view")
public ModelAndView viewCallTree(ModelAndView mav) {
mav.setViewName("call-tree"); // 對應(yīng)templates目錄下的call-tree.html
return mav;
}
}然后寫 HTML 頁面,用 Vue.js+Element UI 來展示樹形結(jié)構(gòu)(因為 Element UI 的 Tree 組件很好用,而且不用寫太多 JS)。咱們把 HTML 放在resources/templates目錄下:
<!-- call-tree.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>SQL調(diào)用樹</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">加載當(dāng)前請求調(diào)用樹</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'// 自定義標(biāo)簽,后面用renderContent渲染
}
};
},
methods: {
// 加載當(dāng)前請求的調(diào)用樹
loadCallTree() {
let _this = this;
this.$http.get('/sql-call-tree/current')
.then(response => {
let result = response.data;
if (result.success) {
// 把MethodNode轉(zhuǎn)換成Tree組件需要的格式
_this.treeData = [_this.convertNode(result.data)];
} else {
_this.$message.error(result.msg);
}
})
.catch(error => {
_this.$message.error('加載調(diào)用樹失?。? + error.message);
});
},
// 轉(zhuǎn)換節(jié)點:MethodNode和SqlNode統(tǒng)一成Tree組件的格式
convertNode(node) {
let treeNode = {
children: []
};
// 如果是MethodNode(有methodId字段)
if (node.methodId) {
treeNode.label = `${node.className}.${node.methodName}`;
treeNode.type = 'method';
treeNode.costTime = node.costTime;
// 轉(zhuǎn)換子節(jié)點
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;
},
// 自定義渲染節(jié)點內(nèi)容
renderContent(h, {node, data, store}) {
let content = '';
if (data.type === 'method') {
// 方法節(jié)點:顯示類名.方法名 + 耗時
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節(jié)點:慢SQL標(biāo)紅,顯示參數(shù)和耗時
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;">參數(shù):${data.parameters}</span>
<span style="color: #666; margin-left: 10px;">耗時:${data.costTime}ms</span>
</span>`;
}
return h('div', {
domProps: {
innerHTML: content
}
});
}
},
mounted() {
// 頁面加載時自動加載調(diào)用樹
this.loadCallTree();
}
});
</script>
</body>
</html>這個頁面很簡單:頂部有個輸入框,用來設(shè)置慢 SQL 閾值(默認 500 毫秒),點擊按鈕加載當(dāng)前請求的調(diào)用樹,用不同顏色區(qū)分方法節(jié)點和 SQL 節(jié)點,慢 SQL 標(biāo)紅顯示。這樣一來,只要訪問http://localhost:8080/sql-call-tree/view,就能看到當(dāng)前請求的 SQL 調(diào)用樹了。
3.6 第六步:打包成 Starter,方便集成
為了讓其他項目能快速集成,咱們把這個功能打包成 SpringBoot Starter。 Starter 的核心是spring.factories文件,用來告訴 Spring 要自動配置哪些類。
首先,在resources/META-INF目錄下創(chuàng)建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在切面里可能不生效,需要構(gòu)造注入)
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;
}
}這里有個小細節(jié):因為 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 調(diào)用樹 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>
<!-- 數(shù)據(jù)庫驅(qū)動(按需引入,這里只做示例) -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.30</version>
<scope>provided</scope>
</dependency>
</dependencies>這樣,一個 SQL 調(diào)用樹 Starter 就打包好了。其他 SpringBoot 項目只要引入這個依賴,不用改任何代碼,就能用這個功能了!
四、實戰(zhàn):3 分鐘定位慢 SQL
光說不練假把式,咱們拿一個真實的業(yè)務(wù)場景來測試一下 —— 用戶下單接口。
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,準(zhǔn)備看調(diào)用樹。
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. 創(chuàng)建訂單
Order order = new Order();
order.setUserId(dto.getUserId());
order.setProductId(dto.getProductId());
order.setAmount(product.getPrice() * dto.getQuantity());
orderDAO.insert(order);
// 4. 組裝返回結(jié)果
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);
}對應(yīng)的 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 查看調(diào)用樹,定位慢 SQL
- 用 Postman 調(diào)用POST /order/create接口,傳入?yún)?shù):
{
"userId": 1,
"productId": 1001,
"quantity": 2
}- 訪問http://localhost:8080/sql-call-tree/view,點擊 “加載當(dāng)前請求調(diào)用樹”,就能看到這樣的樹形結(jié)構(gòu):
com.xxx.OrderController.createOrder(耗時:1050ms)
└── com.xxx.OrderService.createOrder(耗時:1045ms)
├── com.xxx.UserDAO.selectById(耗時:10ms)
│ └── SQL:selectid, namefromuserwhereid = ?(參數(shù):[BIGINT=1],耗時:8ms)
├── com.xxx.ProductDAO.selectById(耗時:1010ms)
│ └── SQL:selectsleep(1) assleep, id, name, price from product whereid = ?(參數(shù):[BIGINT=1001],耗時:1008ms)
└── com.xxx.OrderDAO.insert(耗時:15ms)
└── SQL:insertintoorder (user_id, product_id, amount) values (?, ?, ?)(參數(shù):[BIGINT=1, BIGINT=1001, DECIMAL=200.00],耗時:12ms)因為咱們設(shè)置的慢 SQL 閾值是 500ms,所以ProductDAO.selectById對應(yīng)的 SQL 會標(biāo)紅顯示。一眼就能看出來:整個下單接口耗時 1.05 秒,主要是ProductDAO.selectById的 SQL 慢了,耗時 1.008 秒。再點進這個 SQL 看一下,發(fā)現(xiàn)里面有sleep(1),瞬間就知道問題所在了 —— 這就是 3 分鐘定位慢 SQL 的魅力!
五、優(yōu)化與擴展:讓工具更實用
咱們這個基礎(chǔ)版本已經(jīng)能解決大部分問題了,但實際用的時候,還可以做一些優(yōu)化和擴展,讓它更強大。
5.1 性能優(yōu)化:避免影響業(yè)務(wù)
有老鐵可能會擔(dān)心:代理DataSource和 AOP 會不會影響業(yè)務(wù)性能?其實不用太擔(dān)心,因為咱們的代碼都很輕量,主要是記錄時間和構(gòu)建節(jié)點,沒有復(fù)雜的邏輯。但如果是高并發(fā)場景,還是可以做一些優(yōu)化:
- 開關(guān)控制:加一個配置項(比如sql.call.tree.enabled=true/false),讓用戶可以在生產(chǎn)環(huán)境按需開啟,非高峰時段排查問題時再打開。
- 采樣率控制:加一個采樣率配置(比如sql.call.tree.sample.rate=0.1),只采集 10% 的請求,減少性能消耗。
- 異步存儲:如果需要持久化調(diào)用樹數(shù)據(jù)(比如存到 MySQL 或 Elasticsearch),可以用異步線程池,避免阻塞業(yè)務(wù)線程。
5.2 功能擴展:滿足更多需求
- 支持 SQL 格式化:在展示 SQL 的時候,用工具類(比如com.alibaba.druid.sql.SQLUtils)把 SQL 格式化,看起來更清晰。
- SQL 執(zhí)行計劃分析:集成EXPLAIN語句,點擊 SQL 節(jié)點就能查看執(zhí)行計劃,直接判斷是否缺少索引。
- 多請求對比:把調(diào)用樹數(shù)據(jù)持久化后,支持對比不同請求的調(diào)用樹,看哪個 SQL 的耗時突然增加了。
- 告警功能:當(dāng)出現(xiàn)慢 SQL 時,自動發(fā)送告警(比如釘釘、企業(yè)微信),不用人工盯著頁面。
5.3 適配更多場景
- 支持 JPA/Hibernate:目前咱們只適配了 MyBatis,其實 JPA/Hibernate 也是通過DataSource執(zhí)行 SQL 的,只要調(diào)整SqlNodeBuilder里獲取參數(shù)的邏輯,就能支持。
- 支持多數(shù)據(jù)源:如果項目用了多數(shù)據(jù)源(比如動態(tài)數(shù)據(jù)源),只要確保每個DataSource都被代理,就能正常采集 SQL 信息。
- 支持分布式鏈路:如果是分布式項目,可以把調(diào)用樹的rootNode和分布式鏈路 ID(比如 Trace ID)關(guān)聯(lián)起來,在 SkyWalking 等工具里也能看到 SQL 調(diào)用樹。
六、總結(jié):為啥這個工具值得收藏?
咱們花了這么多篇幅,從思路到代碼,再到實戰(zhàn),把 SpringBoot 自研 SQL 調(diào)用樹的整個過程講透了。這個工具之所以值得收藏,有三個原因:
- 輕量級:不用搭額外的服務(wù),集成 Starter 就能用,小項目無壓力。
- 直觀:把 “業(yè)務(wù)→方法→SQL” 的調(diào)用關(guān)系可視化,慢 SQL 一目了然,不用再對著日志 “大海撈針”。
- 靈活:可以根據(jù)自己的需求擴展功能,比如加告警、加 SQL 分析,定制成適合自己項目的工具。































