偷偷摘套内射激情视频,久久精品99国产国产精,中文字幕无线乱码人妻,中文在线中文a,性爽19p

SpringBoot 自研運行時 SQL 調(diào)用樹,三分鐘定位慢 SQL!

數(shù)據(jù)庫 SQL Server
支持 JPA/Hibernate:目前咱們只適配了 MyBatis,其實 JPA/Hibernate 也是通過DataSource執(zhí)行 SQL 的,只要調(diào)整SqlNodeBuilder里獲取參數(shù)的邏輯,就能支持。

小伙伴們,當(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é)一下核心流程:

  1. 代理DataSource,攔截 SQL 執(zhí)行,采集 “SQL 語句、參數(shù)、執(zhí)行時間、所屬方法”;
  2. 用 AOP 攔截 Controller、Service、DAO 層方法,記錄方法調(diào)用關(guān)系,構(gòu)建 “方法調(diào)用樹”;
  3. 把 SQL 信息掛載到對應(yīng)的 DAO 方法節(jié)點下,形成完整的 “SQL 調(diào)用樹”;
  4. 提供一個簡單的 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)化:

  1. 開關(guān)控制:加一個配置項(比如sql.call.tree.enabled=true/false),讓用戶可以在生產(chǎn)環(huán)境按需開啟,非高峰時段排查問題時再打開。
  2. 采樣率控制:加一個采樣率配置(比如sql.call.tree.sample.rate=0.1),只采集 10% 的請求,減少性能消耗。
  3. 異步存儲:如果需要持久化調(diào)用樹數(shù)據(jù)(比如存到 MySQL 或 Elasticsearch),可以用異步線程池,避免阻塞業(yè)務(wù)線程。

5.2 功能擴展:滿足更多需求

  1. 支持 SQL 格式化:在展示 SQL 的時候,用工具類(比如com.alibaba.druid.sql.SQLUtils)把 SQL 格式化,看起來更清晰。
  2. SQL 執(zhí)行計劃分析:集成EXPLAIN語句,點擊 SQL 節(jié)點就能查看執(zhí)行計劃,直接判斷是否缺少索引。
  3. 多請求對比:把調(diào)用樹數(shù)據(jù)持久化后,支持對比不同請求的調(diào)用樹,看哪個 SQL 的耗時突然增加了。
  4. 告警功能:當(dāng)出現(xiàn)慢 SQL 時,自動發(fā)送告警(比如釘釘、企業(yè)微信),不用人工盯著頁面。

5.3 適配更多場景

  1. 支持 JPA/Hibernate:目前咱們只適配了 MyBatis,其實 JPA/Hibernate 也是通過DataSource執(zhí)行 SQL 的,只要調(diào)整SqlNodeBuilder里獲取參數(shù)的邏輯,就能支持。
  2. 支持多數(shù)據(jù)源:如果項目用了多數(shù)據(jù)源(比如動態(tài)數(shù)據(jù)源),只要確保每個DataSource都被代理,就能正常采集 SQL 信息。
  3. 支持分布式鏈路:如果是分布式項目,可以把調(diào)用樹的rootNode和分布式鏈路 ID(比如 Trace ID)關(guān)聯(lián)起來,在 SkyWalking 等工具里也能看到 SQL 調(diào)用樹。

六、總結(jié):為啥這個工具值得收藏?

咱們花了這么多篇幅,從思路到代碼,再到實戰(zhàn),把 SpringBoot 自研 SQL 調(diào)用樹的整個過程講透了。這個工具之所以值得收藏,有三個原因:

  1. 輕量級:不用搭額外的服務(wù),集成 Starter 就能用,小項目無壓力。
  2. 直觀:把 “業(yè)務(wù)→方法→SQL” 的調(diào)用關(guān)系可視化,慢 SQL 一目了然,不用再對著日志 “大海撈針”。
  3. 靈活:可以根據(jù)自己的需求擴展功能,比如加告警、加 SQL 分析,定制成適合自己項目的工具。
責(zé)任編輯:武曉燕 來源: 石杉的架構(gòu)筆記
相關(guān)推薦

2024-12-18 10:24:59

代理技術(shù)JDK動態(tài)代理

2009-11-09 12:55:43

WCF事務(wù)

2024-05-16 11:13:16

Helm工具release

2020-06-30 10:45:28

Web開發(fā)工具

2013-06-28 14:30:26

棱鏡計劃棱鏡棱鏡監(jiān)控項目

2025-10-27 01:35:00

2021-04-20 13:59:37

云計算

2023-12-27 08:15:47

Java虛擬線程

2024-08-30 08:50:00

2022-02-17 09:24:11

TypeScript編程語言javaScrip

2024-01-16 07:46:14

FutureTask接口用法

2025-02-24 10:40:55

2021-12-17 07:47:37

IT風(fēng)險框架

2020-03-08 16:45:58

數(shù)據(jù)挖掘學(xué)習(xí)數(shù)據(jù)量

2024-04-01 09:59:08

消息隊列通信微服務(wù)

2017-01-18 15:38:20

語言

2024-08-02 08:31:08

2024-09-13 08:49:45

2023-07-25 09:00:27

RocketMQ開源

2022-03-26 09:06:40

ActorCSP模型
點贊
收藏

51CTO技術(shù)棧公眾號