跳到主要内容

Spring-Boot-SQL-Execution-Tree

前两天我在公司加班,晚上十一点多,楼下抽烟的时候还跟我们组的小李吐槽过:生产上查一个慢 SQL,真的是要命,日志翻半天,甚至还得开 profiler,搞下来至少一两个小时。后来我就下定决心在 SpringBoot 里自研了一套运行时 SQL 调用树,能三分钟就定位到问题点。今天就把这个经历聊一聊,顺便带点代码,大家能感受到怎么落地。

为什么要搞运行时 SQL 调用树

你想啊,平时咱们用 SpringBoot + MyBatis 或 JPA,SQL 都是藏在 MapperRepository 里头,慢 SQL 一旦出现,表面上你看到日志里那条执行了 10 秒的 SQL,可它是哪个请求引起的、是哪个调用链触发的,压根不清楚。举个真实的例子,前段时间有个报表接口,用户一点击就卡住,监控里只知道 MySQL 里有一条 join 查询跑了十几秒。要查它是哪个接口发出来的,真的头大。于是我想,不如在运行时构建一颗 SQL 调用树,把 谁调用了谁、耗时多少 全挂在树上,一眼就能看出慢 SQL 在哪一层触发。

核心思路

说起来其实不复杂,三步走:

  • 拦截 SQL:在 MyBatis 或 DataSource 层打个代理,把 SQL 和耗时拦下来。
  • 绑定调用上下文:结合 ThreadLocal,把 SQL 执行跟当前的请求上下文绑一块。
  • 构建调用树:用一个树形结构存储调用关系,每个节点一个 SQL 片段或者方法名,最后序列化输出。

拦截 SQL 的实现

我用的是 Spring 提供的 DataSourceProxy 思路。简单写段代码感受一下:

import java.sql.*;
import javax.sql.DataSource;

public class SqlTimingDataSource extends DataSourceWrapper {
    public SqlTimingDataSource(DataSource delegate) {
        super(delegate);
    }

    @Override
    public Connection getConnection() throws SQLException {
        returnnew SqlTimingConnection(super.getConnection());
    }
}

class SqlTimingConnection extends ConnectionWrapper {
    public SqlTimingConnection(Connection delegate) {
        super(delegate);
    }

    @Override
    public PreparedStatement prepareStatement(String sql) throws SQLException {
        returnnew SqlTimingPreparedStatement(super.prepareStatement(sql), sql);
    }
}

class SqlTimingPreparedStatement extends PreparedStatementWrapper {
    privatefinal String sql;

    public SqlTimingPreparedStatement(PreparedStatement delegate, String sql) {
        super(delegate);
        this.sql = sql;
    }

    @Override
    public boolean execute() throws SQLException {
        long start = System.currentTimeMillis();
        try {
            returnsuper.execute();
        } finally {
            long cost = System.currentTimeMillis() - start;
            SqlCallTreeCollector.record(sql, cost);
        }
    }
}

这里的 SqlCallTreeCollector.record 就是核心,负责把 SQL 和耗时塞到调用树里。

构建调用树

public class SqlNode {
    private String sqlOrMethod;
    private long cost;
    private List<SqlNode> children = new ArrayList<>();
    
    // getter/setter 略
}

调用时我用 ThreadLocal<Deque<SqlNode>> 来维护调用栈。每当进入一个方法或者 SQL,就 push 一个节点,执行完就 pop,最后一合并,树就出来了。

怎么把方法也纳入树?

光有 SQL 还不够,你得知道它是被哪个 service 调的。我这里用了 Spring 的 @Around 切面:

@Aspect
@Component
public class CallTreeAspect {
    @Around("execution(* com.example..service..*(..))")
    public Object recordCall(ProceedingJoinPoint pjp) throws Throwable {
        String method = pjp.getSignature().toShortString();
        SqlCallTreeCollector.enter(method);
        try {
            return pjp.proceed();
        } finally {
            SqlCallTreeCollector.exit();
        }
    }
}

这样每个 service 方法、DAO 方法的调用,都会被挂在调用树上。SQL 节点是子节点,整个树串起来就有意思了。

三分钟定位慢 SQL

有了调用树,定位就很快。比如一次请求生成的调用树大概长这样(伪输出):

UserReportService.getReport [12050ms]
  UserDao.queryUser [50ms]
    SELECT * FROM user WHERE id=? [48ms]
  ReportDao.queryReport [11980ms]
    SELECT * FROM report r JOIN data d ON ... [11980ms]  <<--- 慢SQL

一眼就能看出是 ReportDao.queryReport 里的 join 拖慢了。再结合 SQL 打印,三分钟搞定,完全不用盲人摸象。

实战踩坑

  1. 调用树过大:一旦请求里 SQL 特别多,树会膨胀,我加了个阈值,超过 1000 个节点就丢弃。
  2. 异步调用:像线程池里的异步任务没办法自动带上 ThreadLocal,我做了个包装,把上下文透传。
  3. SQL 截断:有的 SQL 太长了,日志只保留前 200 字符,避免撑爆磁盘。

跟现有工具的关系

可能有人会说:不都用 Arthas trace 或 SkyWalking 了吗?我也用,但这些要么成本高,要么是线上紧急用。这个自研的调用树非常轻量,嵌在 SpringBoot 里,本地调试、预发环境秒查问题。就像给项目加了个内置的“黑匣子”。

总结

说白了,这套东西就是让 SQL 不再是“孤岛”,而是跟调用链挂钩。拦截 SQL → 绑定上下文 → 构建调用树,这三步下来,慢 SQL 定位效率直接提升一个数量级。

最后再提一句,别迷信工具,关键还是思路:把信息串联起来,让问题能一眼看穿。