1. 为什么需要监控MyBatis的SQL执行信息
在实际开发过程中,我们经常需要查看MyBatis执行的SQL语句及其相关信息。这主要基于以下几个实际需求:
-
调试需求:当业务逻辑出现问题时,我们需要确认MyBatis最终生成的SQL是否符合预期。比如参数是否正确绑定、动态SQL是否按条件拼接等。
-
性能优化:通过查看SQL执行时间,我们可以快速定位慢查询,进而考虑是否需要添加索引或优化SQL结构。
-
审计需求:某些场景下需要记录所有执行的SQL语句,以便后续审计或分析。
-
学习目的:对于MyBatis初学者,查看实际执行的SQL有助于理解框架的工作原理。
提示:在生产环境中,建议通过日志框架(如Logback)配置输出SQL日志,而不是直接使用控制台打印,以避免性能影响和安全风险。
2. MyBatis SQL日志输出的基本原理
MyBatis本身提供了多种日志实现方式,默认情况下会根据classpath中的日志框架自动选择适配器。常见的日志实现包括:
- SLF4J + Logback(推荐)
- Log4j2
- JDK logging
- Commons Logging
- STDOUT_LOGGING(直接输出到控制台)
当我们需要在控制台查看SQL执行信息时,可以通过以下两种主要方式实现:
2.1 配置日志级别
最简单的方式是配置MyBatis相关命名空间的日志级别为DEBUG:
properties复制# logback配置示例
<logger name="org.mybatis" level="DEBUG"/>
<logger name="java.sql" level="DEBUG"/>
这种方式的优点是简单直接,缺点是输出信息格式固定,无法自定义内容。
2.2 使用拦截器
更灵活的方式是实现MyBatis的Interceptor接口,自定义SQL执行信息的收集和输出逻辑。这也是本文重点介绍的方法。
3. 实现自定义SQL日志拦截器
下面我们详细讲解如何通过拦截器实现SQL执行信息的收集和输出。
3.1 创建拦截器类
首先创建一个实现Interceptor接口的类:
java复制@Intercepts({
@Signature(type = StatementHandler.class, method = "query", args = {Statement.class, ResultHandler.class}),
@Signature(type = StatementHandler.class, method = "update", args = {Statement.class}),
@Signature(type = StatementHandler.class, method = "batch", args = {Statement.class})
})
public class SqlExecutionInterceptor implements Interceptor {
// 实现细节将在下面展开
}
3.2 实现拦截逻辑
在拦截器中,我们需要获取以下信息:
- 执行的Mapper方法
- 实际执行的SQL语句
- 执行时间
- 影响行数(对于update操作)
java复制@Override
public Object intercept(Invocation invocation) throws Throwable {
long startTime = System.currentTimeMillis();
try {
// 执行原始方法
Object result = invocation.proceed();
// 计算执行时间
long endTime = System.currentTimeMillis();
long executionTime = endTime - startTime;
// 获取StatementHandler
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
// 获取BoundSql,包含SQL语句和参数
BoundSql boundSql = statementHandler.getBoundSql();
// 获取Mapper方法信息
MappedStatement mappedStatement = (MappedStatement)
ReflectUtil.getFieldValue(statementHandler, "mappedStatement");
String methodName = mappedStatement.getId();
// 获取执行的SQL(已替换参数)
String sql = boundSql.getSql();
// 格式化SQL以便阅读
sql = formatSql(sql);
// 获取影响行数
int affectedRows = 0;
if (result instanceof Integer) {
affectedRows = (Integer) result;
}
// 输出执行信息
printExecutionInfo(methodName, sql, executionTime, affectedRows);
return result;
} catch (Exception e) {
// 异常处理
long endTime = System.currentTimeMillis();
printErrorInfo(invocation, endTime - startTime, e);
throw e;
}
}
3.3 辅助方法实现
3.3.1 SQL格式化方法
java复制private String formatSql(String sql) {
if (sql == null || sql.isEmpty()) {
return sql;
}
// 替换多个空格为单个空格
sql = sql.replaceAll("\\s+", " ");
// 添加换行和缩进
sql = sql.replaceAll("(?i)select ", "SELECT\n\t")
.replaceAll("(?i)from ", "\nFROM\n\t")
.replaceAll("(?i)where ", "\nWHERE\n\t")
.replaceAll("(?i)order by ", "\nORDER BY\n\t")
.replaceAll("(?i)group by ", "\nGROUP BY\n\t")
.replaceAll("(?i)left join ", "\nLEFT JOIN\n\t")
.replaceAll("(?i)inner join ", "\nINNER JOIN\n\t");
return sql;
}
3.3.2 信息输出方法
java复制private void printExecutionInfo(String methodName, String sql,
long executionTime, int affectedRows) {
StringBuilder sb = new StringBuilder("\n=== SQL Execution Info ===\n");
sb.append("Method: ").append(methodName).append("\n");
sb.append("SQL: \n").append(sql).append("\n");
sb.append("Execution Time: ").append(executionTime).append("ms\n");
if (affectedRows > 0) {
sb.append("Affected Rows: ").append(affectedRows).append("\n");
}
sb.append("=======================");
System.out.println(sb.toString());
}
3.4 注册拦截器
最后,我们需要将拦截器注册到MyBatis配置中:
3.4.1 Spring Boot配置方式
java复制@Configuration
public class MyBatisConfig {
@Bean
public SqlExecutionInterceptor sqlExecutionInterceptor() {
return new SqlExecutionInterceptor();
}
@Bean
public ConfigurationCustomizer mybatisConfigurationCustomizer() {
return configuration -> {
configuration.addInterceptor(sqlExecutionInterceptor());
};
}
}
3.4.2 传统MyBatis配置方式
xml复制<plugins>
<plugin interceptor="com.your.package.SqlExecutionInterceptor"/>
</plugins>
4. 高级功能扩展
基础的SQL信息输出已经实现,下面介绍一些实用的扩展功能。
4.1 参数替换显示
默认情况下,MyBatis使用预编译语句,我们看到的SQL中的参数是"?"。可以通过以下方式显示实际参数值:
java复制private String getParameterizedSql(BoundSql boundSql) {
String sql = boundSql.getSql();
Object parameterObject = boundSql.getParameterObject();
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
if (parameterObject == null || parameterMappings == null || parameterMappings.isEmpty()) {
return sql;
}
// 获取类型处理器注册器
Configuration configuration = boundSql.getConfiguration();
TypeHandlerRegistry typeHandlerRegistry = configuration.getTypeHandlerRegistry();
// 复制SQL以便修改
StringBuilder sqlBuilder = new StringBuilder(sql);
// 反向遍历参数映射,避免替换时位置变化
for (int i = parameterMappings.size() - 1; i >= 0; i--) {
ParameterMapping parameterMapping = parameterMappings.get(i);
String property = parameterMapping.getProperty();
Object value;
if (boundSql.hasAdditionalParameter(property)) {
value = boundSql.getAdditionalParameter(property);
} else if (parameterObject == null) {
value = null;
} else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
value = parameterObject;
} else {
MetaObject metaObject = configuration.newMetaObject(parameterObject);
value = metaObject.getValue(property);
}
// 替换SQL中的"?"为实际值
int index = sqlBuilder.lastIndexOf("?");
if (index >= 0) {
String parameterValue = value == null ? "null" : "'" + value.toString() + "'";
sqlBuilder.replace(index, index + 1, parameterValue);
}
}
return sqlBuilder.toString();
}
4.2 慢SQL警告
可以添加慢SQL检测功能,当SQL执行时间超过阈值时输出警告:
java复制private static final long SLOW_SQL_THRESHOLD = 1000; // 1秒
private void printExecutionInfo(String methodName, String sql,
long executionTime, int affectedRows) {
// ... 原有代码 ...
if (executionTime > SLOW_SQL_THRESHOLD) {
System.err.println("⚠️ WARNING: Slow SQL detected - " + executionTime + "ms");
}
}
4.3 执行计划收集
对于性能分析,可以收集SQL执行计划:
java复制private void collectExecutionPlan(Statement statement) throws SQLException {
if (statement instanceof PreparedStatement) {
PreparedStatement ps = (PreparedStatement) statement;
try (ResultSet rs = ps.executeQuery("EXPLAIN " + ps.toString())) {
StringBuilder plan = new StringBuilder("\nExecution Plan:\n");
while (rs.next()) {
plan.append(rs.getString(1)).append("\n");
}
System.out.println(plan.toString());
}
}
}
5. 实际应用中的注意事项
5.1 性能考虑
拦截器会增加一定的性能开销,特别是在高并发场景下。建议:
- 在生产环境中关闭详细的SQL日志输出,或仅对特定Mapper开启
- 避免在拦截器中执行耗时操作
- 考虑使用异步方式输出日志
5.2 安全性考虑
直接打印SQL日志可能存在安全风险:
- 敏感数据可能出现在SQL中(如密码、个人信息)
- SQL注入攻击者可能利用日志信息
解决方案:
- 对敏感字段进行脱敏处理
- 生产环境不开启DEBUG级别的SQL日志
- 使用专门的日志管理工具,设置访问权限
5.3 与其他拦截器的兼容性
当存在多个拦截器时,执行顺序可能影响结果。可以通过@Order注解或实现Ordered接口指定顺序:
java复制@Component
@Order(1) // 值越小优先级越高
public class SqlExecutionInterceptor implements Interceptor {
// ...
}
5.4 多数据源支持
对于多数据源项目,需要确保拦截器被正确应用到所有数据源:
java复制@Bean
public DataSource dataSource1() {
DataSource dataSource = // 创建数据源
return new ProxyDataSource(dataSource, sqlExecutionInterceptor());
}
@Bean
public DataSource dataSource2() {
DataSource dataSource = // 创建数据源
return new ProxyDataSource(dataSource, sqlExecutionInterceptor());
}
6. 替代方案比较
除了自定义拦截器,还有其他几种方式可以实现类似功能:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 日志框架配置 | 简单易用,无需代码 | 输出格式固定,无法自定义 |
| 自定义拦截器 | 灵活可控,功能强大 | 需要开发工作,可能影响性能 |
| MyBatis插件 | 官方支持,稳定性高 | 功能有限,学习曲线较陡 |
| JDBC代理 | 不依赖MyBatis,通用性强 | 实现复杂,维护成本高 |
在实际项目中,我通常会根据具体需求选择方案:
- 开发环境:使用日志框架+自定义拦截器组合
- 测试环境:仅使用日志框架
- 生产环境:按需开启最小化的日志输出
7. 常见问题解决
7.1 拦截器不生效
可能原因及解决方案:
- 拦截器未正确注册:检查Spring配置或MyBatis配置文件
- 方法签名不匹配:确保@Signature注解正确指定了要拦截的方法
- 执行的是本地缓存结果:清除MyBatis缓存测试
7.2 SQL日志输出不全
可能原因:
- 日志级别设置不正确
- SQL语句过长被截断
- 参数化SQL未正确解析
解决方案:
java复制// 在拦截器中添加日志长度检查
if (sql.length() > 1000) {
System.out.println("SQL is too long, showing first 1000 chars:");
System.out.println(sql.substring(0, 1000) + "...");
} else {
System.out.println(sql);
}
7.3 控制台乱码问题
解决方案:
- 确保IDE控制台编码设置为UTF-8
- 在输出前对字符串进行编码转换:
java复制private static String ensureUtf8(String str) {
try {
return new String(str.getBytes("ISO-8859-1"), "UTF-8");
} catch (UnsupportedEncodingException e) {
return str;
}
}
8. 性能优化建议
对于需要长期监控SQL性能的场景,建议:
- 将SQL执行信息存入数据库,便于分析
- 实现采样机制,不记录每一条SQL
- 使用异步方式处理日志,避免阻塞主流程
- 添加过滤条件,只监控特定模式的SQL
示例异步处理实现:
java复制private final ExecutorService executor = Executors.newSingleThreadExecutor();
private void asyncPrintExecutionInfo(String methodName, String sql,
long executionTime, int affectedRows) {
executor.submit(() -> {
printExecutionInfo(methodName, sql, executionTime, affectedRows);
});
}
9. 与其他框架集成
9.1 与Spring Boot Actuator集成
可以将SQL监控信息暴露为Actuator端点:
java复制@Endpoint(id = "sqlstats")
@Component
public class SqlStatsEndpoint {
private final ConcurrentMap<String, SqlStats> statsMap = new ConcurrentHashMap<>();
@ReadOperation
public Map<String, SqlStats> sqlStats() {
return new HashMap<>(statsMap);
}
public void recordSqlExecution(String sql, long executionTime) {
statsMap.compute(sql, (k, v) -> {
if (v == null) {
return new SqlStats(sql, executionTime);
}
v.recordExecution(executionTime);
return v;
});
}
public static class SqlStats {
private String sql;
private long totalTime;
private int executionCount;
private long maxTime;
private long minTime = Long.MAX_VALUE;
// 构造方法、getter和recordExecution方法
}
}
9.2 与Micrometer集成
将SQL指标输出到监控系统:
java复制private final Timer sqlTimer = Metrics.timer("sql.execution.time");
private void recordMetrics(String methodName, long executionTime) {
sqlTimer.record(executionTime, TimeUnit.MILLISECONDS);
Tags tags = Tags.of(
"method", methodName,
"type", methodName.startsWith("select") ? "read" : "write"
);
Metrics.counter("sql.execution.count", tags).increment();
Metrics.summary("sql.execution.time.summary", tags).record(executionTime);
}
10. 实际案例分享
最近在一个电商项目中,我们使用自定义SQL拦截器解决了以下问题:
- 动态数据源路由问题:通过拦截器记录实际使用的数据源,发现某些查询意外路由到了从库
- N+1查询问题:通过分析日志,发现一个商品详情页生成了120+条SQL,最终通过添加关联查询解决
- 慢查询优化:识别出一个执行时间超过5秒的统计查询,通过添加复合索引将时间降到200ms
关键优化代码片段:
java复制// 在拦截器中添加数据源记录
private void recordDataSource() {
String dataSource = TransactionSynchronizationManager.isActualTransactionActive() ?
TransactionSynchronizationManager.getCurrentTransactionName() : "default";
System.out.println("Data Source: " + dataSource);
}
// 识别N+1查询模式
private boolean isNPlusOneQuery(String methodName, String sql) {
return methodName.contains("findBy") &&
sql.toLowerCase().contains("where") &&
sql.toLowerCase().indexOf("select") != sql.toLowerCase().lastIndexOf("select");
}
通过这些实践经验,我总结了几个有用的技巧:
- 为不同环境配置不同的日志级别:开发环境可以详细记录所有SQL,而生产环境只记录慢查询
- 将SQL日志与业务日志关联:在MDC(Mapped Diagnostic Context)中添加traceId,便于追踪完整的请求链路
- 定期分析SQL日志:使用脚本或工具分析日志,识别潜在的性能问题和优化机会
