1. 项目概述:SQL调试的痛点与可视化方案价值
在SpringBoot应用开发中,SQL调试一直是让开发者头疼的环节。传统做法是通过日志查看拼接后的SQL语句,但这种方式存在三个明显缺陷:
- 占位符与实际参数分离,需要开发者手动拼接才能得到完整可执行的SQL
- 复杂SQL的多层嵌套会导致日志可读性急剧下降
- 动态SQL的条件判断逻辑难以直观验证
我在最近一个电商平台项目中就遇到了典型场景:一个商品检索接口涉及8张表关联查询,MyBatis动态生成的SQL日志打印出来足足有3屏长度,排查一个日期范围查询的Bug花了整整半天时间。这促使我开始寻找更高效的SQL调试方案。
经过多个方案的对比测试,最终采用MyBatis-Plus + P6Spy + 自定义插件的组合方案,实现了:
- 实时可视化完整可执行SQL
- 参数自动替换无需手动拼接
- 执行耗时统计与慢SQL标记
- 语法高亮与格式化输出
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型与核心组件解析
2.1 MyBatis-Plus 3.5.0+ 特性利用
作为MyBatis的增强工具,MyBatis-Plus 3.5.0版本后提供了重要的SQL注入分析功能。关键类MybatisPlusSqlInjector允许我们获取到:
java复制// 获取原始SQL信息
SqlSource sqlSource = mappedStatement.getSqlSource();
BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
String rawSql = boundSql.getSql();
但直接使用存在两个问题:
- 参数仍是
?占位符形式 - 动态SQL的
<if>标签未被实际解析
2.2 P6Spy 3.9.1 数据源代理
P6Spy作为数据源中间层,可以拦截所有JDBC操作。其核心配置spy.properties需要特别关注:
properties复制# 开启SQL日志输出
appender=com.p6spy.engine.spy.appender.Slf4JLogger
# 日期格式
dateformat=yyyy-MM-dd HH:mm:ss.SSS
# 实际SQL日志格式
logMessageFormat=com.p6spy.engine.spy.appender.CustomLineFormat
customLogMessageFormat=%(currentTime)|%(executionTime)|%(category)|%(sql)
但原生P6Spy的输出格式对复杂SQL可读性不佳,需要配合自定义格式化器。
2.3 自定义SQL美化插件
为解决可读性问题,开发了SqlPrettyInterceptor插件,核心功能包括:
- 参数替换:通过
ParameterHandler获取参数值替换? - 语法高亮:基于ANSI颜色码区分SQL关键字
- 格式化:使用JSqlParser库进行SQL格式化
- 耗时统计:记录执行时间并标记超过阈值的SQL
3. 完整实现步骤与配置
3.1 项目依赖配置
pom.xml关键依赖:
xml复制<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3</version>
</dependency>
<dependency>
<groupId>p6spy</groupId>
<artifactId>p6spy</artifactId>
<version>3.9.1</version>
</dependency>
<dependency>
<groupId>com.github.jsqlparser</groupId>
<artifactId>jsqlparser</artifactId>
<version>4.5</version>
</dependency>
3.2 数据源代理配置
application.yml配置要点:
yaml复制spring:
datasource:
driver-class-name: com.p6spy.engine.spy.P6SpyDriver
url: jdbc:p6spy:mysql://localhost:3306/demo
username: root
password: 123456
spy.properties增强配置:
properties复制modulelist=com.p6spy.engine.spy.P6SpyFactory,com.p6spy.engine.logging.P6LogFactory
deregisterdrivers=true
useprefix=true
excludecategories=info,debug,result,resultset
outagedetection=true
outagedetectioninterval=2
filter=false
3.3 自定义拦截器实现
核心拦截器代码结构:
java复制@Intercepts({
@Signature(type = StatementHandler.class, method = "query",
args = {Statement.class, ResultHandler.class}),
@Signature(type = StatementHandler.class, method = "update",
args = {Statement.class}),
@Signature(type = ParameterHandler.class, method = "setParameters",
args = {PreparedStatement.class})
})
public class SqlPrettyInterceptor implements Interceptor {
private static final ThreadLocal<Long> START_TIME = new ThreadLocal<>();
private static final int SLOW_SQL_THRESHOLD = 500; //ms
@Override
public Object intercept(Invocation invocation) {
// 获取原始SQL和参数
// 执行耗时计算
// SQL美化处理
// 日志输出
}
private String highlightSQL(String sql) {
// ANSI颜色码处理
}
}
4. 实战效果与对比分析
4.1 传统日志 vs 可视化输出对比
传统日志示例:
code复制2023-08-01 14:30:22.159 DEBUG 12345 --- [nio-8080-exec-1] c.e.mapper.UserMapper.selectList_mpCount : ==> Preparing: SELECT id,name,age FROM user WHERE age > ? AND name LIKE ?
2023-08-01 14:30:22.161 DEBUG 12345 --- [nio-8080-exec-1] c.e.mapper.UserMapper.selectList_mpCount : ==> Parameters: 18(Integer), %张%(String)
可视化输出示例:
code复制[SQL-REPORT] 2023-08-01 14:30:22.165 | Cost: 12ms
SELECT
`id`, `name`, `age`
FROM
`user`
WHERE
`age` > 18
AND `name` LIKE '%张%'
4.2 动态SQL解析效果
对于如下MyBatis动态SQL:
xml复制<select id="searchUsers">
SELECT * FROM user
<where>
<if test="name != null">
AND name LIKE #{name}
</if>
<if test="minAge != null">
AND age >= #{minAge}
</if>
</where>
</select>
当传入参数name="张%",minAge=18时,输出:
sql复制SELECT * FROM user WHERE name LIKE '张%' AND age >= 18
而传统日志只会显示带?的预处理语句,无法直观看到实际执行的SQL结构。
5. 高级功能扩展
5.1 慢SQL监控告警
在拦截器中增加逻辑:
java复制long cost = System.currentTimeMillis() - START_TIME.get();
if (cost > SLOW_SQL_THRESHOLD) {
log.warn("[SLOW-SQL] Cost: {}ms\n{}", cost, prettySql);
// 可接入邮件/钉钉告警
}
5.2 SQL执行计划分析
结合MySQL的EXPLAIN功能:
java复制String explainSql = "EXPLAIN " + originalSql;
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(explainSql)) {
// 解析执行计划结果
while (rs.next()) {
String table = rs.getString("table");
String type = rs.getString("type");
// 输出分析建议
}
}
5.3 多数据源适配方案
对于多数据源项目,需要特殊处理:
- 为每个数据源创建独立的P6Spy配置
- 使用
@Primary标注主数据源 - 自定义
AbstractRoutingDataSource实现
6. 生产环境注意事项
-
性能影响:在高压测试中,完整SQL日志会使TPS下降约5-8%,建议:
- 开发环境开启所有功能
- 测试环境关闭语法高亮
- 生产环境仅记录慢SQL
-
敏感数据处理:对于密码等敏感字段,添加脱敏规则:
java复制if (sql.contains("password")) { sql = sql.replaceAll("password = '.*?'", "password = '******'"); } -
日志分离策略:建议将SQL日志单独输出到文件:
properties复制logging.file.name=logs/app.log logging.logback.rollingpolicy.file-name-pattern=logs/sql-%d{yyyy-MM-dd}.%i.log -
MyBatis-Plus版本兼容:3.4.x与3.5.x的SQL注入方式有差异,需要注意:
java复制// 3.4.x SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(...); // 3.5.x MybatisPlusSqlInjector injector = new MybatisPlusSqlInjector();
7. 常见问题排查指南
问题1:参数替换异常
- 现象:日期类型参数显示为毫秒数
- 原因:未正确处理Temporal类型参数
- 修复:
java复制if (parameter instanceof Temporal) { return "'" + DateTimeFormatter.ISO_LOCAL_DATE_TIME .format((Temporal) parameter) + "'"; }
问题2:动态SQL未解析
- 现象:
<if>标签仍保留在输出中 - 检查:
- 确保使用
@MapperScan扫描Mapper接口 - 拦截器顺序需在MyBatis内置拦截器之后
- 确保使用
问题3:P6Spy连接泄漏
- 现象:连接数持续增长
- 解决方案:
properties复制# spy.properties deregisterdrivers=true outagedetection=true outagedetectioninterval=30
8. 替代方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 本文方案 | 完整SQL可视化 | 需引入多个组件 | 复杂项目深度调试 |
| MyBatis原生日志 | 零配置 | 需手动拼接参数 | 简单查询快速验证 |
| Druid Filter | 内置统计功能 | 格式化能力有限 | 监控为主场景 |
| IDEA MyBatis插件 | 图形化界面 | 依赖特定IDE | 开发阶段本地调试 |
在实际项目中,我通常会根据阶段选择不同方案:
- 开发期:本文完整方案 + IDEA插件
- 测试期:保留P6Spy基础日志
- 生产环境:仅启用Druid监控
这种分层策略既能保证开发效率,又不会对生产系统造成额外负担。对于特别复杂的SQL场景,还会结合Arthas等工具进行运行时诊断。
