精品欧美一区二区三区在线观看 _久久久久国色av免费观看性色_国产精品久久在线观看_亚洲第一综合网站_91精品又粗又猛又爽_小泽玛利亚一区二区免费_91亚洲精品国偷拍自产在线观看 _久久精品视频在线播放_美女精品久久久_欧美日韩国产成人在线

SpringBoot 自研運行時 SQL 調用樹,三分鐘定位慢 SQL!

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

小伙伴們,當線上項目突然卡得像老黃牛拉破車,日志刷了幾百行,一眼望去全是 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里的 “當前節點”;當方法執行完,再把 “當前節點” 切回父節點。這樣一來,整個調用過程就像 “搭積木” 一樣,自然形成了一棵樹。

總結一下核心流程:

  1. 代理DataSource,攔截 SQL 執行,采集 “SQL 語句、參數、執行時間、所屬方法”;
  2. 用 AOP 攔截 Controller、Service、DAO 層方法,記錄方法調用關系,構建 “方法調用樹”;
  3. 把 SQL 信息掛載到對應的 DAO 方法節點下,形成完整的 “SQL 調用樹”;
  4. 提供一個簡單的 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 會不會影響業務性能?其實不用太擔心,因為咱們的代碼都很輕量,主要是記錄時間和構建節點,沒有復雜的邏輯。但如果是高并發場景,還是可以做一些優化:

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

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

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

5.3 適配更多場景

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

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

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

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

2024-05-16 11:13:16

Helm工具release

2024-12-18 10:24:59

代理技術JDK動態代理

2009-11-09 12:55:43

WCF事務

2021-04-20 13:59:37

云計算

2022-02-17 09:24:11

TypeScript編程語言javaScrip

2023-12-27 08:15:47

Java虛擬線程

2024-01-16 07:46:14

FutureTask接口用法

2024-08-30 08:50:00

2025-10-27 01:35:00

2020-06-30 10:45:28

Web開發工具

2013-06-28 14:30:26

棱鏡計劃棱鏡棱鏡監控項目

2021-12-17 07:47:37

IT風險框架

2025-02-24 10:40:55

2024-10-15 09:18:30

2020-06-29 07:42:20

邊緣計算云計算技術

2009-11-05 16:04:19

Oracle用戶表

2024-01-12 07:38:38

AQS原理JUC

2023-12-04 18:13:03

GPU編程

2021-02-03 14:31:53

人工智能人臉識別

2024-07-05 09:31:37

點贊
收藏

51CTO技術棧公眾號

97成人在线| 大菠萝精品导航| 久久精品国产999大香线蕉| 久久精品国产精品| 午夜视频在线免费看| 国产拍在线视频| 中文在线一区二区| 成人免费观看网站| 精品人妻一区二区三区潮喷在线| 国产韩日影视精品| 日韩精品中文字幕在线| 看欧美ab黄色大片视频免费| 狂野欧美激情性xxxx欧美| 99久久国产综合色|国产精品| 国产精品久久久久久久av电影| h色网站在线观看| 亚洲人成精品久久久| 日韩亚洲欧美在线| 手机看片福利盒子久久| 中文字幕中文字幕在线中高清免费版 | 国产一区一区| 欧美性猛交xxxx富婆| 国内自拍中文字幕| 成年网站在线| 99久久婷婷国产| 91久久久国产精品| 不卡av电影在线| 亚洲第一黄色| 久久国产精品久久久久久| 男生草女生视频| 国产一区调教| 日韩一区二区三区在线| 亚洲黄色小视频在线观看| 日韩欧美一中文字暮专区 | 欧美日韩综合在线观看| 亚洲午夜精品一区 二区 三区| 亚洲图片欧洲图片av| 中文字幕乱视频| 日韩精品视频中文字幕| 欧美性高清videossexo| 亚洲成熟丰满熟妇高潮xxxxx| 久久不射影院| 亚洲一区二区精品3399| 91九色国产ts另类人妖| 日本暖暖在线视频| 国产精品久久免费看| 日韩欧美电影一区二区| 免费福利在线观看| 91捆绑美女网站| 国内外成人免费视频| 国产综合在线播放| 国产成人免费xxxxxxxx| 91成人伦理在线电影| 11024精品一区二区三区日韩| 日本麻豆一区二区三区视频| 国产999在线| www.久久久久久久| 天堂一区二区在线| 国产成人鲁鲁免费视频a| 久久精品视频1| 国产精品美女| 欧日韩在线观看| 在线视频一区二区三区四区| 国产精品一卡| 国产成人精品久久二区二区| 黄色免费av网站| 久热综合在线亚洲精品| 国产精品精品视频| 国产情侣小视频| 麻豆成人综合网| 91久久久久久久| 国产成人精品一区二三区四区五区| 狠狠色丁香久久婷婷综| 999精品视频一区二区三区| 亚洲第一天堂影院| 成人美女视频在线观看| 九九九九精品| 黄色毛片在线观看| 国产精品无圣光一区二区| 综合网五月天| 狂野欧美性猛交xxxxx视频| 黑人巨大精品欧美一区二区三区| 爆乳熟妇一区二区三区霸乳| 欧美黄页免费| 欧美一区二区三区四区高清| 野战少妇38p| 精品国产网站| 久久国产精品久久久久| 性无码专区无码| 久久精品国产一区二区三区免费看| 亚洲iv一区二区三区| 三级网站在线看| 久久精品在这里| 国产激情片在线观看| 麻豆视频在线观看免费网站黄| 色婷婷亚洲综合| 韩国三级与黑人| 中文字幕精品影院| 久久国产色av| 成人av网站在线播放| 国产激情精品久久久第一区二区| 久久久久综合一区二区三区| 欧美a免费在线| 天天综合色天天| 色呦色呦色精品| 神马午夜久久| 不卡毛片在线看| 国产一卡二卡三卡| 国产成人精品免费一区二区| 日韩精品久久久| ****av在线网毛片| 欧美日韩成人综合在线一区二区| 国产精品300页| 五月天久久久| 国产精品成人观看视频国产奇米| 亚洲成人av综合| 国产精品久久久久久福利一牛影视| 免费超爽大片黄| 国产亚洲观看| 在线性视频日韩欧美| 日韩欧美三级在线观看| 国产在线精品一区二区三区不卡| 免费日韩av电影| 菠萝蜜视频在线观看www入口| 欧美日韩免费高清一区色橹橹| 午夜视频在线观看国产| 欧美在线三区| 国产日韩欧美日韩大片| 国产精品天堂| 欧美色videos| 亚洲中文字幕无码一区| 欧美 日韩 国产 一区| 国产一区二区丝袜| 97在线观看免费观看高清| 色婷婷国产精品| 在线观看国产免费视频| 精品动漫3d一区二区三区免费版 | 668精品在线视频| 亚洲va天堂va欧美ⅴa在线| 中文字幕在线视频一区| www.这里只有精品| 欧美呦呦网站| 国产精品爽爽ⅴa在线观看| 国产原创av在线| 一本一道久久a久久精品综合蜜臀| 国产高清成人久久| 亚洲精品美女91| 国产一区二区三区无遮挡 | 五月天婷婷综合| av av在线| 99精品福利视频| 久草精品电影| 欧美aa视频| 亚洲免费视频一区二区| 无码免费一区二区三区| 国产亚洲精品久| 色片在线免费观看| 99精品视频在线观看免费播放| 国产热re99久久6国产精品| 蜜桃视频在线观看www社区| 欧美高清视频一二三区 | 无码任你躁久久久久久老妇| 韩国自拍一区| 国产伦精品一区二区三区在线 | 9a蜜桃久久久久久免费| 欧美韩日亚洲| 日韩电影在线观看永久视频免费网站| 免费在线不卡视频| 国产亚洲成av人在线观看导航| 国产情侣av自拍| 四虎国产精品免费观看| 亚洲字幕在线观看| 538在线视频| 亚洲精品自拍第一页| 日本久久综合网| 中文字幕在线一区| 久久久久亚洲AV成人网人人小说| 一区二区三区四区五区在线| 日韩久久久久久久久久久久久| 国外成人福利视频| 欧美国产日韩精品| 日韩欧美在线观看一区二区| 欧美日韩一区三区四区| 成年人一级黄色片| 91视频xxxx| 一道本在线免费视频| 国产精品v欧美精品v日本精品动漫| 含羞草久久爱69一区| 成人精品三级| 欧美激情视频在线观看| 国产三级视频在线播放线观看| 欧美日韩国产小视频在线观看| 久久久久久久久久久久久久久久久 | 国产自产女人91一区在线观看| 美女网站视频在线| 亚洲图片欧美午夜| 黄色av小说在线观看| 欧美性感一类影片在线播放| 免费在线黄色片| 国产欧美一区二区在线| 精品人妻无码中文字幕18禁| 日韩制服丝袜先锋影音| 99久久久精品视频| 欧美丝袜丝交足nylons172| 国产91视觉| 日韩三级成人| 奇米4444一区二区三区| 久草在线资源站资源站| 日韩在线播放视频| 欧美理论在线观看| 欧美tk丨vk视频| 96亚洲精品久久久蜜桃| 欧美日韩国产在线| 九九热只有精品| 中文字幕亚洲电影| 色噜噜日韩精品欧美一区二区| 国产成人在线视频播放| 欧美wwwwwww| 日日欢夜夜爽一区| 成人综合视频在线| 亚洲午夜电影| www.激情网| 天堂美国久久| 亚洲高清资源综合久久精品| 蜜臀av免费一区二区三区| 国产精品久久国产三级国电话系列| 日韩毛片免费视频一级特黄| 国产成人精品一区| 一根才成人网| 91国内揄拍国内精品对白| 污视频网站免费在线观看| 日韩中文字幕精品视频| 成年人在线看| 国产亚洲精品va在线观看| 欧美日韩在线中文字幕| 亚洲国产精品女人久久久| 人人妻人人澡人人爽人人欧美一区| 91精品国产欧美一区二区18| 亚洲一级片免费看| 精品视频在线免费| 中文字幕 欧美激情| 欧美主播一区二区三区| 在线观看国产区| 欧美制服丝袜第一页| 成人黄色激情视频| 欧美三片在线视频观看| 最近中文字幕免费在线观看| 欧美自拍丝袜亚洲| 中文字幕免费观看视频| 欧美日本在线播放| 国产一区二区在线播放视频| 在线成人av影院| av在线亚洲天堂| 欧美mv日韩mv| 香蕉国产在线视频| 亚洲精品一区二区久| 久草视频在线看| 亚洲热线99精品视频| 成年人视频网站在线| 色悠悠久久久久| 91精品久久久久久粉嫩| 欧美精品18videos性欧| 黄色18在线观看| 日本在线精品视频| 日本电影久久久| 91一区二区三区| 久久久免费毛片| 欧美亚洲精品日韩| 色琪琪久久se色| 久久久久久久香蕉| 中文精品视频| 国产wwwxx| 国产激情偷乱视频一区二区三区 | 久久夜色电影| 日本免费高清一区| 国产精品久久久久无码av| 精品无码av无码免费专区| 最新日韩在线| 欧美伦理片在线看| 国产在线视频一区二区| www.啪啪.com| 欧美激情一区二区| 九九久久免费视频| 在线亚洲免费视频| av网站免费大全| 亚洲欧美国产精品va在线观看| 最新电影电视剧在线观看免费观看| 久久躁日日躁aaaaxxxx| 性欧美又大又长又硬| 成人黄色免费片| 精品女人视频| 爱爱爱视频网站| 国产精品入口| 搡的我好爽在线观看免费视频| 99久久久精品| 国精品人伦一区二区三区蜜桃| 亚洲国产三级在线| 在线黄色av网站| 亚洲精品一区二区三区香蕉| caoporn国产精品免费视频| 欧美精品18videosex性欧美| 成人不卡视频| 久久国产精品精品国产色婷婷| 99热国内精品| 麻豆av免费在线| 懂色一区二区三区免费观看 | 色播色播色播色播色播在线 | 九九视频免费在线观看| 在线区一区二视频| 天天干天天色天天| 美日韩精品视频免费看| 日本精品裸体写真集在线观看| 国产伦精品一区二区三区照片| 久久久久久免费视频| 黄色片视频在线播放| 成人免费视频一区| 成人在线观看免费完整| 在线看日本不卡| 日韩在线免费看| 国语自产精品视频在线看一大j8 | 欧美一区二区人人喊爽| 91精品专区| 国产91精品在线播放| 老司机在线精品视频| 欧美黑人在线观看| 精品一区二区三区在线播放| 三区四区在线观看| 欧美午夜片在线免费观看| 日韩一级片免费在线观看| 欧美精品亚州精品| 国产电影一区二区| 亚洲天堂av免费在线观看| 美女精品一区二区| 国产三级在线观看完整版| 欧美性猛交99久久久久99按摩| 色欲久久久天天天综合网 | 日韩性xxx| 久久精品日韩| 国产日韩欧美一区二区三区在线观看| 亚洲精品激情视频| 亚洲午夜在线观看视频在线| jizz国产视频| 欧美精品生活片| 亚洲乱码一区| 日韩 欧美 视频| 国产91精品露脸国语对白| 久草免费在线视频观看| 日韩女优av电影| missav|免费高清av在线看| 91视频婷婷| 亚洲激情一区| 中文字幕5566| 日韩欧美一区二区三区久久| 国产精品影院在线| 国产精品最新在线观看| 99精品在线观看| 波多野结衣网页| 亚洲成在人线免费| 香港一级纯黄大片| 国产精品高潮呻吟久久av野狼| 日韩大片在线观看| 国产精品久久久久久久99| 一区二区欧美视频| 亚洲欧美自偷自拍| 国产精品video| 天天综合网91| 秘密基地免费观看完整版中文 | 欧美精品羞羞答答| 国产精品v日韩精品v在线观看| 亚洲欧美综合色| 后入内射欧美99二区视频| 91高潮在线观看| 欧美韩日高清| 亚洲精品久久一区二区三区777| 精品国产精品自拍| 自拍视频在线网| 成人精品一二区| 久久中文字幕一区二区三区| 久艹在线观看视频| 亚洲成av人片在线观看香蕉| 日本免费久久| 久久香蕉视频网站| 2024国产精品视频| 国产精品久久久久久久久毛片 | 日韩伦理精品| 一区二区不卡在线观看| 成人一二三区视频| aaa在线视频| 欧美精品18videos性欧美| 精品美女久久久| 337p日本欧洲亚洲大胆张筱雨| 色综合一区二区三区| av在线免费网站| 蜜桃999成人看片在线观看| 国产在线视频一区二区三区| 黄色免费av网站| 欧美黄网免费在线观看| 欧美日中文字幕| 特级西西人体4444xxxx| 9191国产精品|