首页
学习
活动
专区
圈层
工具
发布

SpringBoot 自研运行时 SQL 调用树,3 分钟定位慢 SQL!

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

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

你想啊,平时咱们用 SpringBoot + MyBatis 或 JPA,SQL 都是藏在 Mapper 或 Repository 里头,慢 SQL 一旦出现,表面上你看到日志里那条执行了 10 秒的 SQL,可它是哪个请求引起的、是哪个调用链触发的,压根不清楚。

举个真实的例子,前段时间有个报表接口,用户一点击就卡住,监控里只知道 MySQL 里有一条 join 查询跑了十几秒。要查它是哪个接口发出来的,真的头大。于是我想,不如在运行时构建一颗 SQL 调用树,把谁调用了谁耗时多少全挂在树上,一眼就能看出慢 SQL 在哪一层触发。

核心思路

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

拦截 SQL:在 MyBatis 或 DataSource 层打个代理,把 SQL 和耗时拦下来。

绑定调用上下文:结合 ThreadLocal,把 SQL 执行跟当前的请求上下文绑一块。

构建调用树:用一个树形结构存储调用关系,每个节点一个 SQL 片段或者方法名,最后序列化输出。

拦截 SQL 的实现

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

import java.sql.*;

import javax.sql.DataSource;

publicclass 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 打印,三分钟搞定,完全不用盲人摸象。

实战踩坑

调用树过大:一旦请求里 SQL 特别多,树会膨胀,我加了个阈值,超过 1000 个节点就丢弃。

异步调用:像线程池里的异步任务没办法自动带上 ThreadLocal,我做了个包装,把上下文透传。

SQL 截断:有的 SQL 太长了,日志只保留前 200 字符,避免撑爆磁盘。

跟现有工具的关系

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

总结

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

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

  • 发表于:
  • 原文链接https://page.om.qq.com/page/O26cn7RYm_mw7sz32MaxA5BQ0
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。
领券