问题:一条不合作的执行链路
SQL 监控是后端工程中最基础的需求之一:参数化 SQL 绑定实际值、超长参数列表缩略打印、执行耗时统计与慢查询报警。如果你使用了成熟的 ORM 框架(MyBatis Interceptor、JOOQ Listener),这些都不是问题。
但如果你的技术栈不提供这些能力呢?
一个 SQL 请求的执行链路:DAO → ORM → DataSource → Connection → Driver → DB。要实现独立于 ORM 的通用监控,可以在 DataSource → Connection → Driver 三个环节切入:
| 切入点 | 方式 | 代表工具 |
|---|---|---|
| Driver 层 | JDBC URL 加 logger 参数 | MySQL profileSQL |
| Connection 层 | 代理驱动,修改 JDBC URL | P6Spy、log4jdbc |
| DataSource 层 | 包装 DataSource 对象 | P6DataSource |
三种方案都可以实现 SQL 监控。在本文系统当时的配置与发布约束下,我们选择从 DataSource 切入;这不是其他生产系统的唯一选择。
约束条件
这套系统的基础设施有几个硬约束:数据库连接由 DBA 在平台配置,底层数据源是 ShardingSphere + HikariDataSource 的深度封装。
- Driver 层方案:需要在 JDBC URL 中加
logger=Slf4JLogger和profileSQL=true两个参数,而 URL 由 DBA 维护,修改流程长且和标准配置冲突。另外,不同数据库的 Driver 实现不统一(Oracle 就不支持 profileSQL)——排除 - Connection 层方案:同样需要修改 JDBC URL(
jdbc:p6spy:mysql://...),且比 Driver 层方案更容易配错——排除 - DataSource 层方案:不需要改 URL,只需在运行时包装 DataSource 对象——唯一可行
可以用 P6DataSource 包装原始 DataSource,但这套框架封装了创建过程,业务侧缺少在所有数据访问模板初始化前统一替换数据源的入口。问题在初始化时机与覆盖范围,而不是绝对无法取得实例。
框架提供的数据访问入口是一个 DataSourceConfig 接口:
public interface DataSourceConfig {
String bizName();
default NamedParameterJdbcTemplate read() {
return InternalDatasourceConfig.readForceAz(this, currentAz(), currentPaz(), "read");
}
default NamedParameterJdbcTemplate write() {
return InternalDatasourceConfig.writeForceAz(this, currentAz(), currentPaz(), "write");
}
}
业务方通过枚举实现该接口来定义数据源。标准 NamedParameterJdbcTemplate 可以通过其 JdbcTemplate 访问底层 DataSource,因此“框架不直接暴露”不等于完全无法获取。不过,取得引用仍不等于能在所有模板和连接创建之前统一替换它;本文选择在框架的委托数据源初始化路径上插入代理。上述业务类名和代码为说明调用关系的节选。
我们需要解决的具体问题是:在不修改 JDBC URL 和框架业务源码的前提下,统一拦截委托数据源的初始化。
核心突破:顺着继承链找到注入点
这是整篇文章最关键的部分——不是"怎么改字节码",而是**"改哪里"**。
定位 DataSource 的创建位置
第一步是找到 DataSource 到底在哪里被 new 出来的。框架代码虽然不能修改,但反编译后源码是可以阅读的。
追踪方法有两个:一是在 IDE 中对 HikariDataSource 的构造方法设断点,启动应用后查看调用栈;二是在依赖 jar 中全局搜索 new HikariDataSource。两种方法都能快速定位到同一个位置。
沿着调用链一路追踪:
DataSourceConfig.read()
→ InternalDatasourceConfig.readForceAz()
→ DataSourceFactory.create()
→ new ListenableDataSource<>(bizName, new HikariDataSource(config), ...)
DataSourceFactory.create() 的关键代码:
public static ListenableDataSource<Failover<Instance>> create(Instance i) {
return supplyWithRetry(
DATA_SOURCE_BUILD_RETRY,
DATA_SOURCE_BUILD_RETRY_DELAY,
() -> new ListenableDataSource<>(
bizName,
new HikariDataSource(config), // ← 真正的 DataSource 在这里创建
ds -> i.toString(), i),
DataSourceFactory::needRetry);
}
最直接的想法:把 new HikariDataSource(config) 改成 new P6DataSource(new HikariDataSource(config))。但这是框架的代码,没有修改权限。如果只盯着 DataSourceFactory,就会陷入死胡同。
继承链上的转折
转折点在于:看一下 ListenableDataSource 的继承关系。
ListenableDataSource
→ extends DelegatingDataSource (Spring JDBC)
→ implements DataSource
DelegatingDataSource 是 Spring JDBC 提供的标准委托类,它的构造方法和 setter:
public class DelegatingDataSource implements DataSource {
public DelegatingDataSource(DataSource targetDataSource) {
this.setTargetDataSource(targetDataSource);
}
public void setTargetDataSource(@Nullable DataSource targetDataSource) {
this.targetDataSource = targetDataSource;
}
}
这意味着 ListenableDataSource 在构造时,会调用父类 DelegatingDataSource 的 setTargetDataSource() 方法,保存内部的 HikariDataSource。
如果我们改写 setTargetDataSource() 方法,在保存之前先用 P6DataSource 包一层,就能实现无侵入的 DataSource 代理:
public void setTargetDataSource(@Nullable DataSource targetDataSource) {
this.targetDataSource = targetDataSource == null || targetDataSource instanceof P6DataSource
? targetDataSource : new P6DataSource(targetDataSource);
}
这个方案的精妙之处在于:
- 不修改业务源码——运行时实际改变的是 Spring JDBC 类的方法体,仍需纳入依赖升级和运行环境验证
- 不改配置——不需要碰 JDBC URL 或启动参数
- 位于关键路径上——所有通过
ListenableDataSource创建的数据源都会经过这个方法 - 切入点明确——目标是公开方法,但构造路径、类加载器和版本兼容性仍需逐版验证
找到这个切入点不是靠灵感,而是一个系统性过程:先定位目标行为的执行链路,再沿着继承链寻找可控节点。
字节码改写:三种姿势与选型
确定了"改哪里",下一步是"怎么改"。
我们需要在运行时修改 DelegatingDataSource 的 setTargetDataSource() 方法。这里要修改的是类定义本身,因此采用字节码改写;仅对某个实例创建动态代理,不能改变构造过程中的这次调用。
动态 Attach 并非 JDK 9 才出现,也不是所有运行环境都允许。改写已加载类需要已安装的 Instrumentation、可修改的目标类及重定义能力;可在启动时加载 Agent,或在允许运行时 Attach 的环境中安装。JDK 21 对动态加载 Agent 发出警告,JEP 451 说明了相关限制方向,不能承诺始终无需启动参数。
以下是三种 Byte Buddy 实现方式:
方案一:类文件替换
预先编译好一个修改过的 DelegatingDataSource 类,运行时整体替换:
new ByteBuddy()
.redefine(NewDelegatingDataSource.class)
.name(DelegatingDataSource.class.getName())
.make()
.load(Thread.currentThread().getContextClassLoader(),
ClassReloadingStrategy.fromInstalledAgent());
需要在代码中维护一份完整的替换类,当 Spring 版本升级时,替换类可能与原始类不兼容。
方案二:直接操作字节码
通过 ASM 级别的 API 逐条编写字节码指令:
new ByteBuddy()
.redefine(DelegatingDataSource.class)
.method(named("setTargetDataSource"))
.intercept(MyImplementation.INSTANCE)
.make()
.load(Thread.currentThread().getContextClassLoader(),
ClassReloadingStrategy.fromInstalledAgent());
其中 MyImplementation 需要手写 ASM 字节码——visitVarInsn、visitMethodInsn、visitFieldInsn 逐行控制栈操作。可以用 IDEA 的 Byte-Code-Analyzer 插件辅助生成,但本质上仍是在操作底层指令,不可读、无法调试、维护成本极高。
方案三:Byte Buddy Advice(最终选择)
public static void redefine() {
new ByteBuddy()
.redefine(DelegatingDataSource.class)
.visit(Advice.to(Decorator.class)
.on(ElementMatchers.named("setTargetDataSource")
.and(ElementMatchers.takesArguments(DataSource.class))))
.make()
.load(DelegatingDataSource.class.getClassLoader(),
ClassReloadingStrategy.fromInstalledAgent()).getLoaded();
}
static class Decorator {
@Advice.OnMethodEnter
public static void enter(
@Advice.Argument(value = 0, readOnly = false)
DataSource dataSource) {
if (dataSource != null && !(dataSource instanceof P6DataSource)) {
dataSource = new P6DataSource(dataSource);
}
}
}
Advice 的原理不是动态代理,而是直接修改方法体的字节码。上面的代码等价于在方法开头插入一行:
public void setTargetDataSource(@Nullable DataSource targetDataSource) {
if (targetDataSource != null && !(targetDataSource instanceof P6DataSource)) {
targetDataSource = new P6DataSource(targetDataSource);
}
this.targetDataSource = targetDataSource;
}
选型对比
| 类文件替换 | 操作字节码 | Advice | |
|---|---|---|---|
| 可读性 | 中(需维护完整类) | 低(ASM 指令) | 高(Java 注解) |
| 可调试性 | 中 | 低 | 高 |
| 维护成本 | 高(跟随 Spring 版本) | 极高 | 低 |
| 灵活性 | 高 | 极高 | 中 |
方案三是最终选择:代码可读、可调试,修改范围精确到方法级别,不依赖原类的完整实现。
两个限制需要注意:
- 动态修改已加载的类,不能添加或删除方法/字段,只能修改方法体
- 委托给实例的 MethodDelegation 通常需要保存实例的字段,可能超出重定义限制;静态委托并非必然增加字段,不能一概排除。这里使用 Advice 是为了保持类结构不变
该改写影响同一类加载器内所有调用此父类方法的实例,而非只影响指定业务数据源。应在目标数据源创建之前安装;已有连接不会被追溯包装。还需验证目标类加载器能看到 P6Spy、包装器的 unwrap 行为和连接池关闭路径。本文未提供完整 Starter 源码,示例用于说明注入点,不代表任意框架版本都可直接接入。
封装成 Starter:开箱即用
目标是让业务方零代码接入——只加一个 Maven 依赖就自动启用 SQL 监控:
<dependency>
<groupId>com.kuaishou.ad</groupId>
<artifactId>sqllog-spring-boot-starter</artifactId>
<version>制品库查询最新版</version>
</dependency>
利用 Spring Boot 的自动配置机制,Starter 在应用启动时自动执行字节码改写逻辑——既不修改业务代码,也不更改系统配置。
Spring Boot 3.0 不再支持通过 spring.factories 的自动配置条目注册配置类,改用下面的导入文件(该方式从 Boot 2.7 开始支持):
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
Starter 应根据目标 Spring Boot 版本提供相应的注册文件,参见 Spring Boot 3.0 迁移指南。
SQL 打印效果
启用后的 SQL 日志输出分为三行:
- 第一行:执行时间、耗时、SQL 操作类型、数据库连接信息
- 第二行:参数化 SQL(缩略)
- 第三行:根据参数还原的日志展示文本,并非数据库收到的协议报文
SQL 缩略打印
回到开头提到的需求——SQL 缩略不是简单的字符串截断,而是解析 SQL 结构,仅对 IN (...) 等参数列表进行智能缩略:
-- 原始 SQL(参数列表超长)
SELECT * FROM user
WHERE id IN (1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008)
AND name IN (SELECT name FROM whitelist
WHERE name IN ('a','b','c','d','e','f','g','h','i','j','k','l','m'))
-- 缩略后(保留结构,截断参数)
SELECT * FROM user
WHERE id IN (1001, 1002, 1003, 1004, 1005, ...)
AND name IN (SELECT name FROM whitelist
WHERE name IN ('a','b','c','d','e', ...))
这一功能基于 P6Spy 的自定义 MessageFormattingStrategy 实现。简单正则只能处理受限的语句格式,无法可靠区分嵌套子查询、字符串内逗号、注释和方言语法;需要保留结构时,应使用相应方言的解析器或可靠的词法处理。上面的省略输出只是日志文本,不能执行。参数展开也不保证等价于驱动的二进制参数绑定;默认脱敏敏感参数并限制日志长度,排查时按授权范围保留必要值。
方法论提炼:寻找代理切入点
回过头看,这个问题的具体技术细节(Byte Buddy、P6Spy、Spring Boot Starter)都不是核心——核心是在不修改外部代码的前提下,找到一个可拦截的切入点。
面对“第三方组件无法修改但需要增强”的问题,可以按下面的顺序缩小方案范围:
- 定位目标行为:从 DAO、ORM、DataSource、Connection 到 Driver,确认行为在哪一层发生
- 明确可控范围:把权限、配置归属和兼容性约束逐项列出,再排除无法实施的入口
- 追踪对象构造:确认包装对象何时创建、是否存在可拦截的方法,以及类是否已经加载
- 选择介入方式:优先评估配置和代理;确实需要改写字节码时,再验证版本兼容、加载顺序与运行权限
本文的解法是一个特定实例,但这套决策路径是通用的。下次遇到"这段代码我不能碰,但我需要改变它的行为"时,不妨沿着这条路径走一遍——答案往往藏在继承链或依赖关系图的某个节点上。