先发制人2026
72.36M · 2026-03-22
上周写了一篇文章,分享了SQLInsight的设计思路和技术方案,没想到收到了不少反馈。有人问"啥时候能用",有人问"技术细节是怎么实现的"。
花了小一周时间,终于把它实现出来了。今天周末,终于可以还债了。
这篇文章,我们来聊聊SQLInsight的技术实现细节:动态代理是怎么做的?StackTrace是怎么解析的?为什么说零侵入?性能到底如何?
代码已经开源,你可以直接用,也可以学习实现思路。
在开始之前,先说几个核心亮点,让你知道这个项目有什么不一样:
不需要在代码里加注解,不需要修改配置文件,甚至不需要加启动参数。加个依赖,启动应用,就能看到所有SQL执行情况。这是怎么做到的?答案是:在最底层做手脚。
你在Controller写了个接口,调用Service,Service调用Mapper,最后执行了SQL。这整个链路,SQLInsight能自动给你串起来:
GET /api/users/123
→ UserController.getUser()
→ UserService.findById()
→ SELECT * FROM users WHERE id = 123 (15ms)
这不是靠AOP,不是靠埋点,而是靠读取线程栈。
不管你用的是MyBatis、JPA、Hibernate还是JdbcTemplate,不管你的连接池是Druid、HikariCP还是C3P0,SQLInsight都能工作。为什么?因为它代理的是JDBC标准接口,不依赖具体实现。
动态代理?StackTrace扫描?这不会很慢吗?实测下来,单次SQL执行的额外开销在1微秒以内。怎么做到的?答案是:缓存 + 异步 + 限制扫描深度。
做技术不是闭门造车,SQLInsight的核心设计思想来自两个成熟的开源组件。
Seata是阿里开源的分布式事务框架。它有个很巧妙的设计:通过动态代理DataSource,在JDBC层拦截所有SQL执行,实现分布式事务的透明化。
SQLInsight借鉴了这个思路,但做得更彻底:
graph LR
A[应用程序] -->|调用| B[DataSourceProxy]
B -->|代理| C[ConnectionProxy]
C -->|代理| D[PreparedStatementProxy]
D -->|执行| E[真实的JDBC驱动]
style A fill:#e1f5ff
style B fill:#fff4e1
style C fill:#ffe1f5
style D fill:#e1ffe1
style E fill:#f5e1ff
为什么这个思路好?
Seata代理DataSource是为了加事务控制,SQLInsight代理DataSource是为了监控。本质上是同一个思路的不同应用。
MyBatis有个很强大的功能:能把SQL执行日志和Mapper方法对应起来。它怎么做的?答案是:解析StackTrace。
当SQL执行时,MyBatis会读取当前线程的调用栈,找到是哪个Mapper接口的哪个方法触发的。
SQLInsight把这个思路往上延伸了一层:
graph LR
A[SQL执行] -->|触发| B[获取StackTrace]
B --> C{扫描调用栈}
C -->|找到| D[RestController注解]
D --> E[解析API路径]
E --> F[解析方法名]
F --> G[构建完整调用链]
style A fill:#e1f5ff
style B fill:#fff4e1
style C fill:#ffe1f5
style D fill:#e1ffe1
style E fill:#f5e1ff
style F fill:#e1f5ff
style G fill:#fff4e1
为什么这个思路可行?
Java的线程栈里包含了完整的方法调用链。通过扫描栈帧,可以找到:
这样就能把API调用和SQL执行串起来,形成完整的链路。
JDBC的核心接口有三个:DataSource、Connection、Statement/PreparedStatement。SQLInsight为每一层都创建了代理。
这一层是入口。Spring Boot启动时,会创建DataSource Bean。我们通过BeanPostProcessor拦截这个过程:
@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生态常用的插件化机制。
当业务代码调用dataSource.getConnection()时,我们返回一个代理的Connection:
@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方法,我们可以在这里做拦截。
这一层是真正执行SQL的地方。当业务代码调用connection.prepareStatement(sql)时:
@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了。
PreparedStatement代理的核心逻辑在这里:
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);
}
这里有两个关键点:
setXxx(index, value)方法设置的。我们拦截这些方法,把参数记下来。execute()方法时,我们记录开始时间,执行SQL,然后记录结束时间,算出执行耗时。SQL执行时,我们需要知道是哪个API触发的。这里用到了StackTrace分析:
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();
}
性能优化点:
graph LR
A[SQL执行] --> B{读取线程栈}
B --> C[第1层: SQLInsight代理]
C --> D[第2层: MyBatis]
D --> E[第3层: Service]
E --> F[第4层: Controller]
F --> G{找到RestController?}
G -->|是| H[解析API信息]
G -->|否| I[继续向上扫描]
H --> J[返回API路径和方法]
style A fill:#e1f5ff
style F fill:#e1ffe1
style H fill:#fff4e1
style J fill:#ffe1f5
找到Controller后,怎么解析出API路径呢?
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。
这是SQLInsight的核心。Java的动态代理基于接口,这和JDBC的设计天然契合:
graph LR
A[业务代码] -->|调用接口| B[代理对象]
B -->|拦截处理| C[收集SQL信息]
B -->|转发调用| D[真实对象]
D -->|执行SQL| E[数据库]
C -.->|异步| F[日志输出]
style A fill:#e1f5ff
style B fill:#fff4e1
style C fill:#ffe1f5
style D fill:#e1ffe1
style E fill:#f5e1ff
style F fill:#fff4e1
代理模式的好处:
SQL执行信息收集后,可能有多种处理方式:输出到控制台、写入日志文件、持久化到数据库、发送到监控系统等。
SQLInsight用观察者模式来处理这个问题:
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));
}
}
这样可以灵活组合不同的处理器,满足不同场景的需求。
DataSourceProxy的创建使用了工厂模式:
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)
);
}
为什么要用工厂模式?
动态代理的创建比较复杂,涉及类加载器、接口数组等参数。通过工厂方法封装这些细节,让使用者更方便。
Druid是阿里开源的数据库连接池,自带监控功能。SQLInsight和Druid可以无缝配合:
graph LR
A[应用程序] --> B[SQLInsight代理]
B --> C[Druid连接池]
C --> D[MySQL驱动]
B -.->|收集| E[SQL执行信息]
C -.->|收集| F[连接池监控]
style A fill:#e1f5ff
style B fill:#fff4e1
style C fill:#ffe1f5
style D fill:#e1ffe1
style E fill:#f5e1ff
style F fill:#fff4e1
为什么能兼容?
SQLInsight代理的是标准JDBC接口,不关心底层实现。Druid连接池也实现了JDBC接口,所以可以直接代理。
两者的监控数据是互补的:
Seata也会代理DataSource,这就涉及到代理链的顺序问题。
理想的顺序是:应用程序 → Seata → SQLInsight → 连接池
为什么?因为Seata需要在最外层控制事务,SQLInsight只做监控,应该在内层。
如何保证顺序?通过调整BeanPostProcessor的优先级:
@Bean
public SqlInsightDataSourceBeanPostProcessor processor() {
SqlInsightDataSourceBeanPostProcessor processor =
new SqlInsightDataSourceBeanPostProcessor(collector, handler);
processor.setOrder(Ordered.LOWEST_PRECEDENCE); // 设置最低优先级
return processor;
}
这样Seata会先执行,SQLInsight后执行,代理顺序就对了。
第一次解析Controller信息后,缓存起来:
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微秒。
SQL执行信息的处理(输出日志、持久化等)是异步的:
public void handle(SqlExecution execution) {
executor.submit(() -> {
// 处理SQL执行信息
persistence.save(execution);
logger.info(formatSqlLog(execution));
});
}
这样不会阻塞业务SQL的执行。
只扫描前50层调用栈:
int maxDepth = Math.min(stack.length, 50);
for (int i = 0; i < maxDepth; i++) {
// 扫描栈帧
}
实际测试中,Controller一般在前10层就能找到,50层已经足够了。
graph LR
A[SQL执行] --> B{读取栈深度}
B -->|深度<=50| C[扫描栈帧]
B -->|深度>50| D[只扫描前50层]
C --> E[查找Controller]
D --> E
E --> F{找到了?}
F -->|是| G[返回API信息]
F -->|否| H[返回Unknown]
style A fill:#e1f5ff
style C fill:#e1ffe1
style E fill:#fff4e1
style G fill:#ffe1f5
这是整个项目最核心的一段代码:
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),确保原有功能不受影响。
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。这样即使方法不同,类名相同也能命中缓存。
做完这个项目后,我有一些更深层次的思考。
SQLInsight遵循了"最少知识原则"(Law of Demeter)。它只和JDBC接口打交道,不关心:
这个原则的好处:
在设计系统时,我们应该尽量依赖抽象(接口),而不是具体实现。JDBC是一个很好的抽象层。
JDBC的分层设计(DataSource → Connection → Statement)为代理提供了天然的切入点。
graph LR
A[业务层<br/>MyBatis/JPA] --> B[JDBC抽象层<br/>标准接口]
B --> C[驱动实现层<br/>MySQL/Oracle驱动]
D[SQLInsight] -.->|代理| B
style A fill:#e1f5ff
style B fill:#fff4e1
style C fill:#ffe1f5
style D fill:#e1ffe1
分层架构的好处:
这启发我们:在设计系统时,合理的分层能带来极大的扩展性。
SQLInsight没有通过继承来扩展功能,而是通过组合:
public class DataSourceProxy implements InvocationHandler {
private final DataSource target; // 组合原始DataSource
private final SqlExecutionCollector collector; // 组合收集器
private final SqlExecutionHandler handler; // 组合处理器
}
为什么组合优于继承?
这是设计模式中的经典原则,值得反复体会。
SQLInsight对扩展开放,对修改关闭:
public interface SqlExecutionHandler {
void handle(SqlExecution execution);
}
// 新增一个handler,不需要修改核心代码
public class CustomHandler implements SqlExecutionHandler {
public void handle(SqlExecution execution) {
// 自定义处理逻辑
}
}
想增加新功能?实现一个新的Handler就行了,不需要改动核心代码。
这给我们的启示:
SQLInsight面临一个矛盾:
解决方案是:
这个平衡的启示:
<dependency>
<groupId>com.surfing</groupId>
<artifactId>sqlinsight-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
# 启用SQLInsight(默认true)
sqlinsight.enabled=true
# 慢SQL阈值(默认1000ms)
sqlinsight.slow-sql-threshold=50
# N+1查询检测阈值(默认10)
sqlinsight.n-plus-one-threshold=10
[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]
SQLInsight通过三个核心技术实现了零侵入的SQL监控:
这三个技术的组合,实现了:
项目体现了多个经典设计原则:
SQLInsight还有很多可以优化的地方:
但核心思想不会变:在最底层做拦截,保持对业务代码的零侵入。
做这个项目,最大的收获不是写了多少行代码,而是理解了设计思想的传承。
Seata的动态代理、MyBatis的StackTrace分析,这些都是前人智慧的结晶。我们站在巨人的肩膀上,把这些思想组合起来,创造出新的价值。
技术的本质不是炫技,而是解决问题。SQLInsight解决的问题很简单:让开发者知道每个API执行了哪些SQL,用了多长时间。但实现这个简单的目标,需要深入理解JDBC、动态代理、反射、线程栈等多个技术点。
好的技术方案,应该是简单的。
对使用者来说,只需要加一个依赖。对设计者来说,需要考虑性能、兼容性、扩展性、可维护性等多个维度。
这就是技术的魅力:表面简单,内在复杂;对外易用,对内精巧。
希望这篇文档能帮助你理解SQLInsight的设计思路,也希望这些设计思想能对你未来的项目有所启发。
从想法到实现,花了小一周时间。
刚开始写代码的时候,遇到了不少坑:
调试的过程中,对JDBC标准、Java动态代理、反射机制有了更深的理解。看似简单的"拦截SQL执行",背后涉及的技术点比想象中要多。
今天周末,终于把坑都填完了,代码也开源了。算是还了上周文章的债。
如果你在使用过程中遇到问题,或者有什么想法建议,欢迎提Issue或PR。
项目地址:gitee.com/sh_wangwanb…
上一篇文章:SQLInsight:一行依赖,自动追踪API背后的每一条SQL
开源协议:MIT License
欢迎反馈:提Issue、PR,或者留言讨论
写代码的过程,也是学习的过程。希望SQLInsight能帮到你,也欢迎一起完善它。