项目地址 :gitee.com/sh_wangwanb...
上一篇文章 :SQLInsight:一行依赖,自动追踪API背后的每一条SQL
开篇
上周写了一篇文章,分享了SQLInsight的设计思路和技术方案,没想到收到了不少反馈。有人问"啥时候能用",有人问"技术细节是怎么实现的"。
花了小一周时间,终于把它实现出来了。今天周末,终于可以还债了。
这篇文章,我们来聊聊SQLInsight的技术实现细节:动态代理是怎么做的?StackTrace是怎么解析的?为什么说零侵入?性能到底如何?
代码已经开源,你可以直接用,也可以学习实现思路。
一、技术亮点速览
在开始之前,先说几个核心亮点,让你知道这个项目有什么不一样:
1.1 真正的零侵入
不需要在代码里加注解,不需要修改配置文件,甚至不需要加启动参数。加个依赖,启动应用,就能看到所有SQL执行情况。这是怎么做到的?答案是:在最底层做手脚。
1.2 完整的调用链自动追踪
你在Controller写了个接口,调用Service,Service调用Mapper,最后执行了SQL。这整个链路,SQLInsight能自动给你串起来:
sql
GET /api/users/123
→ UserController.getUser()
→ UserService.findById()
→ SELECT * FROM users WHERE id = 123 (15ms)
这不是靠AOP,不是靠埋点,而是靠读取线程栈。
1.3 兼容性好到让人惊讶
不管你用的是MyBatis、JPA、Hibernate还是JdbcTemplate,不管你的连接池是Druid、HikariCP还是C3P0,SQLInsight都能工作。为什么?因为它代理的是JDBC标准接口,不依赖具体实现。
1.4 性能影响微乎其微
动态代理?StackTrace扫描?这不会很慢吗?实测下来,单次SQL执行的额外开销在1微秒以内 。怎么做到的?答案是:缓存 + 异步 + 限制扫描深度。
二、设计思想的来源
做技术不是闭门造车,SQLInsight的核心设计思想来自两个成熟的开源组件。
2.1 动态代理:学习Seata
Seata是阿里开源的分布式事务框架。它有个很巧妙的设计:通过动态代理DataSource,在JDBC层拦截所有SQL执行,实现分布式事务的透明化。
SQLInsight借鉴了这个思路,但做得更彻底:
为什么这个思路好?
- 最底层拦截:不管你上层用什么框架,最终都要通过JDBC执行SQL
- 标准接口:JDBC是Java标准,不会随便变
- 透明性好:对上层业务代码完全透明
Seata代理DataSource是为了加事务控制,SQLInsight代理DataSource是为了监控。本质上是同一个思路的不同应用。
2.2 StackTrace分析:学习MyBatis
MyBatis有个很强大的功能:能把SQL执行日志和Mapper方法对应起来。它怎么做的?答案是:解析StackTrace。
当SQL执行时,MyBatis会读取当前线程的调用栈,找到是哪个Mapper接口的哪个方法触发的。
SQLInsight把这个思路往上延伸了一层:
为什么这个思路可行?
Java的线程栈里包含了完整的方法调用链。通过扫描栈帧,可以找到:
- 哪个Controller被调用了(有@RestController注解)
- 哪个Service方法被调用了
- 哪个Mapper方法被调用了
这样就能把API调用和SQL执行串起来,形成完整的链路。
三、核心技术实现
3.1 动态代理的三层结构
JDBC的核心接口有三个:DataSource、Connection、Statement/PreparedStatement。SQLInsight为每一层都创建了代理。
3.1.1 第一层:代理DataSource
这一层是入口。Spring Boot启动时,会创建DataSource Bean。我们通过BeanPostProcessor拦截这个过程:
java
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (bean instanceof DataSource) {
// 把原始DataSource包装成代理
return DataSourceProxy.create((DataSource) bean, collector, handler);
}
return bean;
}
关键点 :BeanPostProcessor是Spring提供的扩展点,能在Bean创建后、初始化后做一些处理。这是Spring生态常用的插件化机制。
3.1.2 第二层:代理Connection
当业务代码调用dataSource.getConnection()时,我们返回一个代理的Connection:
java
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if ("getConnection".equals(method.getName())) {
// 调用原始DataSource获取真实Connection
Connection realConnection = (Connection) method.invoke(target, args);
// 返回代理Connection
return ConnectionProxy.createProxy(realConnection, collector, handler);
}
return method.invoke(target, args);
}
这里用到了Java动态代理的核心机制 :InvocationHandler。每次调用代理对象的方法时,都会进入invoke方法,我们可以在这里做拦截。
3.1.3 第三层:代理PreparedStatement
这一层是真正执行SQL的地方。当业务代码调用connection.prepareStatement(sql)时:
java
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if ("prepareStatement".equals(method.getName())) {
String sql = (String) args[0];
PreparedStatement realStmt = (PreparedStatement) method.invoke(target, args);
// 返回代理PreparedStatement,并把SQL传进去
return PreparedStatementProxy.createProxy(realStmt, sql, collector, handler);
}
return method.invoke(target, args);
}
这里的细节:我们把SQL语句传给了PreparedStatement代理。这样在执行SQL时,代理就知道要执行的是什么SQL了。
3.2 SQL执行的拦截
PreparedStatement代理的核心逻辑在这里:
java
private final Map<Integer, Object> parameters = new ConcurrentHashMap<>();
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 1. 拦截参数设置方法(setString、setInt等)
if (method.getName().startsWith("set") && args.length >= 2) {
int index = (int) args[0];
Object value = args[1];
parameters.put(index, value); // 记录参数值
}
// 2. 拦截执行方法(execute、executeQuery等)
if (method.getName().startsWith("execute")) {
long start = System.currentTimeMillis();
Object result = method.invoke(target, args); // 执行真实SQL
long duration = System.currentTimeMillis() - start;
// 3. 收集SQL执行信息
collectSqlExecution(sql, parameters, duration);
return result;
}
return method.invoke(target, args);
}
这里有两个关键点:
- 参数拦截 :PreparedStatement的参数是通过
setXxx(index, value)方法设置的。我们拦截这些方法,把参数记下来。 - 执行拦截 :当调用
execute()方法时,我们记录开始时间,执行SQL,然后记录结束时间,算出执行耗时。
3.3 StackTrace解析:找到API调用者
SQL执行时,我们需要知道是哪个API触发的。这里用到了StackTrace分析:
java
public static ApiInfo extractApiInfo() {
StackTraceElement[] stack = Thread.currentThread().getStackTrace();
// 限制扫描深度,避免性能问题
int maxDepth = Math.min(stack.length, 50);
for (int i = 0; i < maxDepth; i++) {
String className = stack[i].getClassName();
// 先查缓存
if (controllerCache.containsKey(className)) {
return controllerCache.get(className);
}
// 加载类,检查是否有@RestController或@Controller注解
Class<?> clazz = Class.forName(className);
if (clazz.isAnnotationPresent(RestController.class) ||
clazz.isAnnotationPresent(Controller.class)) {
ApiInfo apiInfo = parseApiInfo(clazz, stack[i].getMethodName());
controllerCache.put(className, apiInfo); // 缓存起来
return apiInfo;
}
}
return ApiInfo.unknown();
}
性能优化点:
- 限制扫描深度:只扫描前50层调用栈,避免深度递归
- 缓存Controller信息:同一个Controller类只解析一次,后续直接从缓存取
3.4 API信息解析
找到Controller后,怎么解析出API路径呢?
java
private static ApiInfo parseApiInfo(Class<?> controllerClass, String methodName) {
// 1. 获取类级别的@RequestMapping
RequestMapping classMapping = controllerClass.getAnnotation(RequestMapping.class);
String basePath = classMapping != null ? classMapping.value()[0] : "";
// 2. 找到对应的方法
for (Method method : controllerClass.getDeclaredMethods()) {
if (method.getName().equals(methodName)) {
// 3. 获取方法级别的@GetMapping/@PostMapping等
GetMapping getMapping = method.getAnnotation(GetMapping.class);
if (getMapping != null) {
String path = basePath + getMapping.value()[0];
return new ApiInfo(path, "GET", methodName);
}
// ... 处理PostMapping、PutMapping等
}
}
return ApiInfo.unknown();
}
这样就能从注解中提取出完整的API路径,比如/api/users/123。
四、设计模式的应用
4.1 代理模式
这是SQLInsight的核心。Java的动态代理基于接口,这和JDBC的设计天然契合:
代理模式的好处:
- 对业务代码透明
- 可以在不修改原有代码的情况下增加功能
- 符合开闭原则(对扩展开放,对修改关闭)
4.2 观察者模式
SQL执行信息收集后,可能有多种处理方式:输出到控制台、写入日志文件、持久化到数据库、发送到监控系统等。
SQLInsight用观察者模式来处理这个问题:
java
public interface SqlExecutionHandler {
void handle(SqlExecution execution);
}
public class ConsoleSqlExecutionHandler implements SqlExecutionHandler {
public void handle(SqlExecution execution) {
System.out.println(formatSqlLog(execution));
}
}
public class LoggingSqlExecutionHandler implements SqlExecutionHandler {
public void handle(SqlExecution execution) {
logger.info(formatSqlLog(execution));
}
}
这样可以灵活组合不同的处理器,满足不同场景的需求。
4.3 工厂模式
DataSourceProxy的创建使用了工厂模式:
java
public static DataSource create(DataSource target,
SqlExecutionCollector collector,
SqlExecutionHandler handler) {
return (DataSource) Proxy.newProxyInstance(
DataSource.class.getClassLoader(),
new Class<?>[] { DataSource.class },
new DataSourceProxy(target, collector, handler)
);
}
为什么要用工厂模式?
动态代理的创建比较复杂,涉及类加载器、接口数组等参数。通过工厂方法封装这些细节,让使用者更方便。
五、兼容性设计
5.1 与Druid的兼容
Druid是阿里开源的数据库连接池,自带监控功能。SQLInsight和Druid可以无缝配合:
为什么能兼容?
SQLInsight代理的是标准JDBC接口,不关心底层实现。Druid连接池也实现了JDBC接口,所以可以直接代理。
两者的监控数据是互补的:
- Druid监控:连接池状态、连接获取耗时、SQL执行统计
- SQLInsight监控:SQL与API的关联、完整调用链、慢SQL检测
5.2 与Seata的兼容
Seata也会代理DataSource,这就涉及到代理链的顺序问题。
理想的顺序是:应用程序 → Seata → SQLInsight → 连接池
为什么?因为Seata需要在最外层控制事务,SQLInsight只做监控,应该在内层。
如何保证顺序?通过调整BeanPostProcessor的优先级:
java
@Bean
public SqlInsightDataSourceBeanPostProcessor processor() {
SqlInsightDataSourceBeanPostProcessor processor =
new SqlInsightDataSourceBeanPostProcessor(collector, handler);
processor.setOrder(Ordered.LOWEST_PRECEDENCE); // 设置最低优先级
return processor;
}
这样Seata会先执行,SQLInsight后执行,代理顺序就对了。
六、性能优化策略
6.1 缓存Controller信息
第一次解析Controller信息后,缓存起来:
java
private static final Map<String, ApiInfo> controllerCache = new ConcurrentHashMap<>();
public static ApiInfo extractApiInfo() {
// 先查缓存
if (controllerCache.containsKey(className)) {
return controllerCache.get(className);
}
// 解析并缓存
ApiInfo apiInfo = parseApiInfo(clazz, methodName);
controllerCache.put(className, apiInfo);
return apiInfo;
}
效果:首次解析约50微秒,缓存命中后小于1微秒。
6.2 异步处理
SQL执行信息的处理(输出日志、持久化等)是异步的:
java
public void handle(SqlExecution execution) {
executor.submit(() -> {
// 处理SQL执行信息
persistence.save(execution);
logger.info(formatSqlLog(execution));
});
}
这样不会阻塞业务SQL的执行。
6.3 限制StackTrace扫描深度
只扫描前50层调用栈:
java
int maxDepth = Math.min(stack.length, 50);
for (int i = 0; i < maxDepth; i++) {
// 扫描栈帧
}
实际测试中,Controller一般在前10层就能找到,50层已经足够了。
七、核心代码剖析
7.1 PreparedStatementProxy的精妙之处
这是整个项目最核心的一段代码:
java
public class PreparedStatementProxy implements InvocationHandler {
private final PreparedStatement target;
private final String sql;
private final List<Object> parameters = new ArrayList<>();
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
String methodName = method.getName();
// 拦截参数设置方法
if (methodName.startsWith("set") && args.length >= 2) {
int index = (int) args[0];
Object value = args[1];
// 扩展参数列表
while (parameters.size() < index) {
parameters.add(null);
}
parameters.set(index - 1, value); // JDBC参数索引从1开始
return method.invoke(target, args);
}
// 拦截执行方法
if (methodName.startsWith("execute")) {
long start = System.currentTimeMillis();
Object result = method.invoke(target, args);
long duration = System.currentTimeMillis() - start;
// 收集SQL执行信息
collectSqlExecution(sql, parameters, duration);
return result;
}
// 其他方法直接转发
return method.invoke(target, args);
}
}
这段代码的精妙之处:
-
拦截参数设置 :PreparedStatement的参数是通过
setXxx(index, value)方法设置的,而且索引从1开始(JDBC规范)。代码中用parameters.set(index - 1, value)处理了这个细节。 -
动态扩展参数列表 :参数可能不是按顺序设置的,比如先设置第3个参数,再设置第1个参数。代码用
while循环动态扩展列表,保证不会越界。 -
精确测量执行时间 :在
invoke方法里包裹真实的SQL执行,前后记录时间戳,得到精确的执行耗时。 -
保持原有行为 :所有拦截后,都要调用
method.invoke(target, args),确保原有功能不受影响。
7.2 StackTrace分析的优雅实现
java
public static ApiInfo extractApiInfo() {
StackTraceElement[] stack = Thread.currentThread().getStackTrace();
int maxDepth = Math.min(stack.length, 50);
for (int i = 0; i < maxDepth; i++) {
try {
String className = stack[i].getClassName();
// 缓存命中
if (controllerCache.containsKey(className)) {
return controllerCache.get(className);
}
// 加载类并检查注解
Class<?> clazz = Class.forName(className);
if (isController(clazz)) {
ApiInfo apiInfo = parseApiInfo(clazz, stack[i].getMethodName());
controllerCache.put(className, apiInfo);
return apiInfo;
}
} catch (ClassNotFoundException e) {
// 忽略,继续扫描下一层
}
}
return ApiInfo.unknown();
}
private static boolean isController(Class<?> clazz) {
return clazz.isAnnotationPresent(RestController.class) ||
clazz.isAnnotationPresent(Controller.class);
}
这段代码的优雅之处:
-
异常处理得当 :
Class.forName()可能抛出ClassNotFoundException,但这不是错误,只是说明这个类不在当前ClassLoader里。代码用try-catch优雅地处理了这个问题。 -
提前返回:一旦找到Controller,立即返回,不再继续扫描。这是性能优化的关键。
-
缓存策略:用类名作为key缓存,而不是用完整的ApiInfo。这样即使方法不同,类名相同也能命中缓存。
八、设计思想的升华
做完这个项目后,我有一些更深层次的思考。
8.1 最少知识原则
SQLInsight遵循了"最少知识原则"(Law of Demeter)。它只和JDBC接口打交道,不关心:
- 你用的是什么ORM框架
- 你的连接池是什么
- 你的数据库是MySQL还是PostgreSQL
这个原则的好处:
- 降低耦合
- 提高适应性
- 减少维护成本
在设计系统时,我们应该尽量依赖抽象(接口),而不是具体实现。JDBC是一个很好的抽象层。
8.2 分层架构的威力
JDBC的分层设计(DataSource → Connection → Statement)为代理提供了天然的切入点。
MyBatis/JPA] --> B[JDBC抽象层
标准接口] B --> C[驱动实现层
MySQL/Oracle驱动] D[SQLInsight] -.->|代理| B style A fill:#e1f5ff style B fill:#fff4e1 style C fill:#ffe1f5 style D fill:#e1ffe1
分层架构的好处:
- 每一层都有明确的职责
- 层与层之间通过接口通信
- 可以在任何一层插入新功能(如监控、事务控制)
这启发我们:在设计系统时,合理的分层能带来极大的扩展性。
8.3 组合优于继承
SQLInsight没有通过继承来扩展功能,而是通过组合:
java
public class DataSourceProxy implements InvocationHandler {
private final DataSource target; // 组合原始DataSource
private final SqlExecutionCollector collector; // 组合收集器
private final SqlExecutionHandler handler; // 组合处理器
}
为什么组合优于继承?
- 灵活性:可以动态替换组件(比如换一个不同的handler)
- 低耦合:各个组件可以独立演化
- 可测试:可以单独测试每个组件
这是设计模式中的经典原则,值得反复体会。
8.4 开闭原则的实践
SQLInsight对扩展开放,对修改关闭:
java
public interface SqlExecutionHandler {
void handle(SqlExecution execution);
}
// 新增一个handler,不需要修改核心代码
public class CustomHandler implements SqlExecutionHandler {
public void handle(SqlExecution execution) {
// 自定义处理逻辑
}
}
想增加新功能?实现一个新的Handler就行了,不需要改动核心代码。
这给我们的启示:
- 设计接口时要想清楚扩展点在哪里
- 核心逻辑要稳定,扩展逻辑要灵活
- 通过接口和抽象隔离变化
8.5 性能与功能的平衡
SQLInsight面临一个矛盾:
- 功能需求:需要扫描StackTrace,解析注解,收集信息
- 性能需求:不能影响业务SQL的执行速度
解决方案是:
- 缓存:减少重复计算
- 异步:把耗时操作移到后台
- 限制:限制扫描深度,避免极端情况
这个平衡的启示:
- 性能优化不是无限制的,要找到合适的度
- 用空间换时间(缓存)
- 用异步换同步(后台处理)
- 设置合理的边界(扫描深度限制)
九、使用示例
项目地址 :gitee.com/sh_wangwanb...
完整的示例代码在
examples模块中,可以直接运行查看效果。
9.1 引入依赖
xml
<dependency>
<groupId>com.surfing</groupId>
<artifactId>sqlinsight-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
9.2 配置(可选)
properties
# 启用SQLInsight(默认true)
sqlinsight.enabled=true
# 慢SQL阈值(默认1000ms)
sqlinsight.slow-sql-threshold=50
# N+1查询检测阈值(默认10)
sqlinsight.n-plus-one-threshold=10
9.3 输出示例
sql
[SQL-Insight] GET /api/users/123 → UserController.findById
SQL: SELECT * FROM users WHERE id = 123 (15ms) [Type: SELECT]
[SQL-Insight] GET /api/users/orders → UserController.getUserOrders
[SLOW] SQL: SELECT u.*, o.* FROM users u LEFT JOIN orders o ON u.id = o.user_id (56ms) [Type: SELECT]
十、总结与展望
10.1 技术总结
SQLInsight通过三个核心技术实现了零侵入的SQL监控:
- 动态代理:在JDBC层拦截SQL执行(学习自Seata)
- StackTrace分析:自动追踪API调用链(学习自MyBatis)
- BeanPostProcessor:自动代理DataSource(Spring的扩展机制)
这三个技术的组合,实现了:
- 零配置集成
- 透明监控
- 完整的调用链追踪
- 良好的兼容性
10.2 设计原则的践行
项目体现了多个经典设计原则:
- 单一职责:每个类只做一件事
- 开闭原则:对扩展开放,对修改关闭
- 依赖倒置:依赖抽象(JDBC接口),不依赖具体实现
- 最少知识:只和JDBC接口打交道
- 组合优于继承:用组合构建功能
10.3 未来展望
SQLInsight还有很多可以优化的地方:
- N+1查询检测:自动识别循环查询问题
- Web控制台:提供可视化的监控界面
- 智能建议:根据SQL执行情况给出优化建议
- 分布式追踪:集成OpenTelemetry,支持分布式链路追踪
- SQL审计:记录所有SQL操作,用于安全审计
但核心思想不会变:在最底层做拦截,保持对业务代码的零侵入。
十一、写在最后
做这个项目,最大的收获不是写了多少行代码,而是理解了设计思想的传承。
Seata的动态代理、MyBatis的StackTrace分析,这些都是前人智慧的结晶。我们站在巨人的肩膀上,把这些思想组合起来,创造出新的价值。
技术的本质不是炫技,而是解决问题。SQLInsight解决的问题很简单:让开发者知道每个API执行了哪些SQL,用了多长时间。但实现这个简单的目标,需要深入理解JDBC、动态代理、反射、线程栈等多个技术点。
好的技术方案,应该是简单的。
对使用者来说,只需要加一个依赖。对设计者来说,需要考虑性能、兼容性、扩展性、可维护性等多个维度。
这就是技术的魅力:表面简单,内在复杂;对外易用,对内精巧。
希望这篇文档能帮助你理解SQLInsight的设计思路,也希望这些设计思想能对你未来的项目有所启发。
十二、开发记录
从想法到实现,花了小一周时间。
刚开始写代码的时候,遇到了不少坑:
- PreparedStatement的参数索引从1开始,而不是0(JDBC规范)
- StackTrace扫描时要注意ClassNotFoundException
- 动态代理要完整实现接口的所有方法,不然会NPE
- 缓存策略要做好,不然性能会受影响
调试的过程中,对JDBC标准、Java动态代理、反射机制有了更深的理解。看似简单的"拦截SQL执行",背后涉及的技术点比想象中要多。
今天周末,终于把坑都填完了,代码也开源了。算是还了上周文章的债。
如果你在使用过程中遇到问题,或者有什么想法建议,欢迎提Issue或PR。
项目地址 :gitee.com/sh_wangwanb...
上一篇文章 :SQLInsight:一行依赖,自动追踪API背后的每一条SQL
开源协议 :MIT License
欢迎反馈:提Issue、PR,或者留言讨论
写代码的过程,也是学习的过程。希望SQLInsight能帮到你,也欢迎一起完善它。