1. 为什么需要SQL执行耗时监控?
在企业级应用开发中,数据库访问性能往往是系统瓶颈所在。我经历过一个电商项目,在促销活动期间突然出现系统卡顿,经过排查发现是某个商品详情查询SQL执行时间从平时的50ms飙升到2秒以上。这种场景下,如果没有SQL执行耗时监控,我们就像在黑暗中摸索,很难快速定位问题根源。
MyBatis作为Java生态中最流行的ORM框架,其插件机制为我们提供了绝佳的监控切入点。通过拦截SQL执行过程,我们可以:
- 实时捕获慢查询(比如超过500ms的SQL)
- 统计高频SQL的执行效率
- 发现N+1查询等性能反模式
- 为数据库优化提供数据支撑
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. MyBatis插件机制深度解析
2.1 插件工作原理
MyBatis采用责任链模式处理SQL执行过程。当执行一个Mapper方法时,会依次经过以下四大核心对象:
- Executor (执行器)
- StatementHandler (语句处理器)
- ParameterHandler (参数处理器)
- ResultSetHandler (结果集处理器)
插件本质上就是对这些对象进行动态代理。比如我们要监控SQL耗时,最合适的拦截点是StatementHandler,因为它在prepare()时生成最终SQL,在query()时执行查询。
2.2 拦截器签名与实现
一个标准的MyBatis插件需要三个关键部分:
java复制@Intercepts({
@Signature(type = StatementHandler.class,
method = "query",
args = {Statement.class, ResultHandler.class})
})
public class SqlCostInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
long start = System.currentTimeMillis();
try {
return invocation.proceed();
} finally {
long cost = System.currentTimeMillis() - start;
// 记录耗时逻辑...
}
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
// 读取配置
}
}
关键点:@Signature注解指定要拦截的类、方法及参数类型,必须精确匹配MyBatis源码中的方法签名。
3. 完整实现SQL监控插件
3.1 基础版实现
我们先实现一个记录单次SQL耗时的简单版本:
java复制public Object intercept(Invocation invocation) throws Throwable {
StatementHandler handler = (StatementHandler) invocation.getTarget();
BoundSql boundSql = handler.getBoundSql();
String sql = boundSql.getSql();
long start = System.currentTimeMillis();
try {
return invocation.proceed();
} finally {
long cost = System.currentTimeMillis() - start;
if (cost > SLOW_SQL_THRESHOLD) {
log.warn("Slow SQL detected: {}ms - {}", cost, sql);
}
}
}
3.2 增强版实现
实际项目中我们需要更完善的监控:
java复制// 使用ThreadLocal保存执行上下文
private static final ThreadLocal<SqlContext> SQL_CONTEXT = new ThreadLocal<>();
public Object intercept(Invocation invocation) throws Throwable {
SqlContext context = new SqlContext();
SQL_CONTEXT.set(context);
try {
// 前置处理
context.setStartTime(System.currentTimeMillis());
context.setSql(handler.getBoundSql().getSql());
Object result = invocation.proceed();
// 后置处理
context.setEndTime(System.currentTimeMillis());
reportSqlMetrics(context);
return result;
} finally {
SQL_CONTEXT.remove();
}
}
// 上报指标到监控系统
private void reportSqlMetrics(SqlContext context) {
Map<String,String> tags = new HashMap<>();
tags.put("sqlId", getSqlId(context.getSql()));
Metrics.recordTimer("sql.execute.time",
context.getCostTime(),
tags);
}
3.3 关键优化点
- SQL归一化处理:
java复制// 将SELECT * FROM user WHERE id=? 和 SELECT * FROM user WHERE id=1
// 识别为同一种SQL
private String normalizeSql(String rawSql) {
return rawSql.replaceAll("\\?", "?")
.replaceAll("\\d+", "?");
}
- 批量操作统计:
java复制if (sql.startsWith("INSERT") &&
handler instanceof BatchStatementHandler) {
int batchSize = ((BatchResult) result).getUpdateCounts().length;
metrics.put("batchSize", batchSize);
}
- 事务上下文感知:
java复制TransactionStatus txStatus = TransactionSynchronizationManager
.getCurrentTransactionStatus();
if (txStatus != null) {
context.setInTransaction(true);
}
4. 生产环境部署方案
4.1 插件注册方式
在MyBatis配置文件中注册:
xml复制<plugins>
<plugin interceptor="com.your.pkg.SqlCostInterceptor">
<property name="slowThreshold" value="500"/>
</plugin>
</plugins>
或在Spring Boot中通过Bean方式注册:
java复制@Bean
public SqlCostInterceptor sqlCostInterceptor() {
SqlCostInterceptor interceptor = new SqlCostInterceptor();
interceptor.setProperties(new Properties());
return interceptor;
}
4.2 监控数据可视化
建议将数据推送到以下系统:
- Prometheus + Grafana:适合实时监控和告警
- ELK:适合日志分析和长期存储
- SkyWalking:适合分布式链路追踪
示例Grafana面板指标:
- SQL平均耗时(分应用、分SQL类型)
- 慢查询占比
- SQL执行次数Top 10
- 事务内SQL数量分布
4.3 性能影响评估
在测试环境对比结果:
- 不开启插件:平均TPS 1250
- 开启基础版:平均TPS 1180(下降5.6%)
- 开启增强版:平均TPS 1050(下降16%)
建议在高并发场景下:
- 采样率控制(如只记录10%的请求)
- 异步上报机制
- 避免在拦截器中做复杂计算
5. 典型问题排查案例
5.1 案例一:批量插入性能骤降
现象:监控发现批量插入从平均200ms涨到2s
排查过程:
- 通过SQL监控发现耗时增长主要发生在commit阶段
- 检查事务日志发现批量条数从100增加到了5000
- 确认数据库max_allowed_packet配置限制
解决方案:
java复制// 在插件中添加批量操作分段建议
if (batchSize > MAX_RECOMMEND_BATCH) {
log.warn("Large batch detected: {}, recommend split into {}",
batchSize, MAX_RECOMMEND_BATCH);
}
5.2 案例二:N+1查询问题
现象:某个接口平均响应时间1.2s,但单条SQL都很快
排查过程:
- 监控显示同一事务内执行了45条相似SQL
- 分析代码发现是循环中查询关联数据
- 确认开启了MyBatis一级缓存但未生效
解决方案:
java复制// 在插件中添加事务内SQL重复检测
if (isSimilarSqlInTransaction(currentSql)) {
log.warn("Potential N+1 query: {}", currentSql);
}
6. 进阶开发技巧
6.1 动态阈值调整
通过JMX实现运行时配置更新:
java复制@ManagedResource
public class SqlCostInterceptor implements Interceptor {
@ManagedAttribute
public void setSlowThreshold(long threshold) {
this.slowThreshold = threshold;
}
}
6.2 链路追踪集成
与OpenTelemetry整合:
java复制Span span = tracer.spanBuilder("SQL Execute")
.setAttribute("db.statement", sql)
.startSpan();
try (Scope scope = span.makeCurrent()) {
return invocation.proceed();
} finally {
span.end();
}
6.3 智能分析建议
基于历史数据提供优化建议:
java复制public void analyzeSqlPattern(String sql) {
if (sql.matches("SELECT.*WHERE.*LIKE '%.*%'")) {
log.warn("Leading wildcard LIKE detected: {}", sql);
}
if (sql.matches("SELECT.*FROM.*WHERE.*IS NULL")) {
log.info("Consider adding index for NULL condition: {}", sql);
}
}
7. 避坑指南
-
拦截点选择:
- 不要同时拦截Executor和StatementHandler的update方法,会导致重复计算
- 查询操作优先拦截StatementHandler.query()
-
线程安全:
- 避免在拦截器中使用实例变量
- ThreadLocal使用后必须remove()
-
SQL处理:
- 超长SQL需要截断处理(如超过10KB)
- 敏感信息过滤(如密码字段)
-
性能陷阱:
- 不要在拦截器中同步写日志
- 正则表达式需要预编译
-
Spring事务特殊处理:
java复制// 识别Spring事务的只读状态 if (TransactionSynchronizationManager .isCurrentTransactionReadOnly()) { context.setReadOnly(true); }
经过多个项目的实践验证,这套监控方案可以帮助团队:
- 将慢查询发现时间从小时级降到分钟级
- 数据库CPU使用率平均降低30%
- 显著减少生产环境SQL相关事故
