1. 为什么我们需要自定义MyBatis拦截器
在典型的Java企业应用中,数据库操作往往占据着性能消耗的重要部分。MyBatis作为主流的ORM框架,其默认的日志输出存在几个明显的痛点:
首先,原生日志输出的SQL语句中参数是用问号占位符表示的,这给问题排查带来了不便。想象一下,当你看到日志中记录着SELECT * FROM users WHERE id=?这样的语句时,你无法立即知道实际查询的是哪个用户ID。
其次,默认日志会输出大量执行细节(如连接获取、参数设置等),这些信息在生产环境中往往是噪音。我曾在一个中型电商系统中统计过,这类非核心日志占用了近40%的日志存储空间。
更重要的是,当我们需要对SQL执行进行监控时(如慢查询统计),原生日志缺乏结构化数据支持。这导致我们不得不通过正则表达式等方式从日志文本中提取信息,既低效又容易出错。
2. 拦截器核心设计思路
2.1 拦截点选择与执行时机
MyBatis提供了四大拦截接口:
- Executor (update/query/commit等)
- StatementHandler (prepare/parameterize等)
- ParameterHandler (参数处理)
- ResultSetHandler (结果处理)
对于SQL日志记录,最合适的是拦截Executor的query和update方法。这两个方法包含了完整的SQL执行生命周期,可以获取到:
- 执行的SQL语句(已替换参数)
- 执行参数
- 执行耗时
- 返回结果集大小
java复制@Intercepts({
@Signature(type = Executor.class, method = "update",
args = {MappedStatement.class, Object.class}),
@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class SqlLogInterceptor implements Interceptor {
// 实现细节将在下文展开
}
2.2 日志结构化设计
传统日志最大的问题是信息分散且非结构化。我们的拦截器应该输出统一的JSON格式日志,包含以下关键字段:
json复制{
"timestamp": "2023-07-20T14:30:45.123Z",
"traceId": "abc123",
"operation": "query",
"mapper": "com.example.UserMapper.selectById",
"sql": "SELECT * FROM users WHERE id = 1",
"params": {"id":1},
"executionTime": 45,
"resultSize": 1,
"slowQuery": false
}
这种结构化设计带来三个显著优势:
- 日志存储体积减少30%-50%(去除了冗余文本)
- 便于日志系统(如ELK)直接索引和分析
- 可以基于特定字段(如executionTime)快速过滤和统计
3. 实现细节与性能优化
3.1 获取完整SQL语句
MyBatis执行时,BoundSql对象包含了SQL和参数信息,但需要特殊处理才能获取可执行的完整SQL:
java复制String getCompleteSql(Configuration configuration, BoundSql boundSql) {
String sql = boundSql.getSql();
Object parameterObject = boundSql.getParameterObject();
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
if (parameterMappings == null || parameterMappings.isEmpty()) {
return sql;
}
// 使用MyBatis内置工具类处理参数替换
MetaObject metaObject = configuration.newMetaObject(parameterObject);
for (ParameterMapping parameterMapping : parameterMappings) {
String property = parameterMapping.getProperty();
Object value = metaObject.getValue(property);
sql = sql.replaceFirst("\\?", String.valueOf(value));
}
return sql;
}
注意:直接字符串替换在某些复杂场景下可能有SQL注入风险,生产环境应该:
- 仅用于日志记录,不用于实际执行
- 对字符串类型参数添加引号转义
3.2 耗时计算与慢查询标记
精确计算SQL执行时间需要考虑MyBatis的拦截器链式调用:
java复制@Override
public Object intercept(Invocation invocation) throws Throwable {
long start = System.currentTimeMillis();
try {
return invocation.proceed();
} finally {
long end = System.currentTimeMillis();
long executionTime = end - start;
boolean isSlow = executionTime > SLOW_QUERY_THRESHOLD;
// 记录日志...
}
}
建议根据业务特点设置动态阈值:
- OLTP系统:100-300ms
- 报表查询:1-3s
- 大数据分析:5-10s
3.3 日志分级存储策略
为了进一步降低存储成本,我们可以实现分级存储:
- 普通查询:只记录基础信息(SQL、耗时),保留7天
- 慢查询:记录完整上下文(参数、调用栈),保留30天
- 错误查询:记录异常堆栈,保留90天
这可以通过在拦截器中判断条件后,发送到不同的Log4j/Kafka Appender实现。
4. 生产环境部署要点
4.1 性能影响测试
在预发布环境必须进行压测对比,重点关注:
- 不开启拦截器时的QPS基准值
- 开启拦截器后的QPS下降比例
- 不同日志级别(DEBUG/INFO)的性能差异
实测数据表明,一个优化良好的拦截器对性能影响应控制在3%以内。
4.2 动态开关设计
通过配置中心实现运行时动态控制:
java复制public class FeatureToggle {
private static volatile boolean sqlLogEnabled = true;
public static boolean isSqlLogEnabled() {
return sqlLogEnabled;
}
@Scheduled(fixedRate = 5000)
public void refreshConfig() {
// 从配置中心获取最新状态
sqlLogEnabled = configClient.getBool("sql.log.enabled");
}
}
然后在拦截器中添加判断:
java复制if (!FeatureToggle.isSqlLogEnabled()) {
return invocation.proceed();
}
4.3 与现有监控系统集成
将拦截器数据对接三大监控系统:
- Prometheus:暴露metrics端点统计慢查询次数
- SkyWalking:通过Trace将SQL执行纳入分布式追踪
- ELK:日志直接输出到Logstash管道
5. 存储成本优化实战
5.1 日志采样策略
对于高频查询实施采样:
- 每10次只记录1次完整日志
- 其余只记录计数指标
- 通过MD5对相同SQL去重
java复制String sqlMd5 = DigestUtils.md5Hex(normalizeSql(sql));
if (shouldSample(sqlMd5)) {
logFullDetails(sql, params);
} else {
incrementCounter(sqlMd5);
}
5.2 日志压缩与清理
结合日志系统的特性实施优化:
- 在Filebeat端启用gzip压缩(可减少70%体积)
- 设置ES索引的lifecycle策略:
- 热节点保留3天(1副本)
- 温节点保留7天(无副本)
- 冷节点保留30天(压缩存储)
5.3 字段级裁剪
根据业务需求裁剪非必要字段:
java复制public String trimSensitiveData(String sql) {
// 移除SELECT子句中的敏感字段
if (sql.startsWith("SELECT")) {
return sql.replaceAll("password|token|credit_card", "[REDACTED]");
}
return sql;
}
6. 避坑指南
6.1 拦截器顺序问题
多个拦截器共存时,执行顺序由@Intercepts注解的配置顺序决定。如果同时有分页拦截器和日志拦截器,应该:
java复制@InterceptorChain({
@Interceptor(PaginationInterceptor.class),
@Interceptor(SqlLogInterceptor.class) // 日志放在最后
})
6.2 批量操作处理
对于批量insert/update,需要特殊处理:
java复制if (sql.contains("INSERT") && parameterObject instanceof Map) {
Map<?,?> paramMap = (Map<?,?>) parameterObject;
if (paramMap.containsKey("list")) {
// 这是MyBatis批量操作
int batchSize = ((Collection<?>)paramMap.get("list")).size();
log.info("Batch operation size: {}", batchSize);
}
}
6.3 事务边界识别
通过Connection的autoCommit状态判断事务:
java复制boolean inTransaction = !connection.getAutoCommit();
建议在事务提交/回滚时记录额外标记。
经过这些优化后,在一个日活百万的系统中,我们成功将SQL日志存储成本从每月$1500降低到了$1000左右,同时提高了日志的可用性。关键在于平衡信息的完整性和存储效率,而不是简单地减少日志量。
