MyBatis插件實(shí)現(xiàn)SQL執(zhí)行耗時(shí)監(jiān)控
先說說我被慢SQL"折磨"的經(jīng)歷
去年我們團(tuán)隊(duì)負(fù)責(zé)的支付系統(tǒng),突然在雙11前出現(xiàn)性能問題。用戶反饋支付要等十幾秒,DBA說數(shù)據(jù)庫(kù)CPU都90%了,但就是不知道哪個(gè)SQL有問題。我們加了各種日志,還是定位不到慢SQL。
最后沒辦法,我花了一晚上寫了個(gè)MyBatis插件,第二天就找到了罪魁禍?zhǔn)祝阂粋€(gè)被錯(cuò)誤使用的聯(lián)表查詢,全表掃描了上百萬數(shù)據(jù)。修復(fù)后,響應(yīng)時(shí)間從15秒降到200毫秒。
但事情沒完。上線后監(jiān)控顯示,有0.1%的請(qǐng)求還是很慢。又是排查了三天,發(fā)現(xiàn)是因?yàn)橛腥擞?code>${}做了動(dòng)態(tài)排序,導(dǎo)致大量硬解析。這次我直接把插件升級(jí),增加了SQL防注入檢測(cè)。
這兩次經(jīng)歷讓我明白:不懂MyBatis插件開發(fā),就等于開飛機(jī)沒有儀表盤,出事了都不知道原因。
摘要
MyBatis插件(Plugin)是其框架擴(kuò)展性的核心。本文深入剖析插件實(shí)現(xiàn)原理,手把手教你開發(fā)企業(yè)級(jí)SQL監(jiān)控插件。從攔截器接口、責(zé)任鏈模式,到動(dòng)態(tài)代理實(shí)現(xiàn),完整展示SQL執(zhí)行耗時(shí)監(jiān)控、慢SQL告警、SQL防注入檢測(cè)等功能的實(shí)現(xiàn)。通過性能壓測(cè)數(shù)據(jù)和生產(chǎn)環(huán)境案例,提供插件開發(fā)的最佳實(shí)踐和故障排查指南。
1. 插件不是魔法:先理解MyBatis的攔截機(jī)制
1.1 MyBatis能攔截什么
很多人以為插件只能攔截SQL執(zhí)行,太天真了!MyBatis允許你攔截四大核心組件:

圖1:MyBatis可攔截的四大組件
每個(gè)組件的作用:
| 組件 | 攔截點(diǎn) | 能干什么 | 實(shí)際用途 |
|---|---|---|---|
| Executor? | update/query | 控制緩存、事務(wù) | SQL重試、讀寫分離 |
| StatementHandler? | prepare/parameterize/query | 修改SQL、設(shè)置超時(shí) | SQL監(jiān)控、分頁 |
| ParameterHandler? | setParameters | 參數(shù)處理 | 參數(shù)加密、脫敏 |
| ResultSetHandler? | handleResultSets | 結(jié)果集處理 | 數(shù)據(jù)脫敏、格式化 |
1.2 插件的工作原理:責(zé)任鏈模式
這是理解插件開發(fā)的關(guān)鍵。MyBatis用責(zé)任鏈模式實(shí)現(xiàn)插件:
// 簡(jiǎn)化版的插件執(zhí)行流程
public class Plugin implements InvocationHandler {
private final Object target; // 被代理的對(duì)象
private final Interceptor interceptor; // 攔截器
private final Map<Class<?>, Set<Method>> signatureMap; // 方法簽名映射
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 1. 檢查是否是需要攔截的方法
Set<Method> methods = signatureMap.get(method.getDeclaringClass());
if (methods != null && methods.contains(method)) {
// 2. 執(zhí)行攔截器邏輯
return interceptor.intercept(new Invocation(target, method, args));
}
// 3. 否則直接執(zhí)行原方法
return method.invoke(target, args);
}
}代碼清單1:插件代理的核心邏輯
用圖來表示更清楚:

圖2:插件責(zé)任鏈執(zhí)行流程
關(guān)鍵點(diǎn):每個(gè)被攔截的對(duì)象都被層層代理,就像洋蔥一樣,一層包一層。
2. 手把手:寫你的第一個(gè)插件
2.1 需求分析:我們要監(jiān)控什么
在動(dòng)手前,先想清楚需求。一個(gè)生產(chǎn)級(jí)的SQL監(jiān)控插件應(yīng)該監(jiān)控:
- 執(zhí)行時(shí)間:每個(gè)SQL的執(zhí)行耗時(shí)
- SQL語句:實(shí)際執(zhí)行的SQL(帶參數(shù))
- 參數(shù)信息:SQL綁定的參數(shù)
- 調(diào)用位置:哪個(gè)Mapper的哪個(gè)方法
- 結(jié)果大小:返回了多少條數(shù)據(jù)
- 慢SQL告警:超過閾值要告警
2.2 項(xiàng)目結(jié)構(gòu)設(shè)計(jì)
先看項(xiàng)目結(jié)構(gòu),好的結(jié)構(gòu)是成功的一半:
sql-monitor-plugin/
├── src/
│ ├── main/
│ │ ├── java/
│ │ │ └── com/
│ │ │ └── yourcompany/
│ │ │ └── mybatis/
│ │ │ └── plugin/
│ │ │ ├── SqlMonitorPlugin.java # 主插件
│ │ │ ├── SlowSqlAlarmer.java # 慢SQL告警
│ │ │ ├── SqlStatsCollector.java # SQL統(tǒng)計(jì)收集
│ │ │ ├── SqlContextHolder.java # SQL上下文
│ │ │ ├── model/
│ │ │ │ ├── SqlExecutionInfo.java # SQL執(zhí)行信息
│ │ │ │ └── SlowSqlAlert.java # 慢SQL告警信息
│ │ │ └── util/
│ │ │ ├── SqlFormatter.java # SQL格式化
│ │ │ └── StackTraceUtil.java # 堆棧工具
│ │ └── resources/
│ │ └── META-INF/
│ │ └── com.yourcompany.mybatis.properties # 配置文件
│ └── test/
└── pom.xml
2.3 基礎(chǔ)插件實(shí)現(xiàn)
先寫個(gè)最簡(jiǎn)單的插件,打印SQL執(zhí)行時(shí)間:
@Intercepts({
@Signature(
type = StatementHandler.class,
method = "query",
args = {Statement.class, ResultHandler.class}
),
@Signature(
type = StatementHandler.class,
method = "update",
args = {Statement.class}
)
})
@Slf4j
public class SimpleSqlMonitorPlugin implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
long startTime = System.currentTimeMillis();
try {
// 執(zhí)行原方法
return invocation.proceed();
} finally {
long costTime = System.currentTimeMillis() - startTime;
// 獲取StatementHandler
StatementHandler handler = (StatementHandler) invocation.getTarget();
BoundSql boundSql = handler.getBoundSql();
// 打印日志
log.info("SQL執(zhí)行耗時(shí): {}ms, SQL: {}", costTime, boundSql.getSql());
}
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
// 可以讀取配置
}
}代碼清單2:最簡(jiǎn)單的SQL監(jiān)控插件
但這個(gè)插件有問題:
- 沒區(qū)分是query還是update
- 沒獲取參數(shù)值
- 沒處理批量操作
- 沒考慮性能影響
3. 企業(yè)級(jí)SQL監(jiān)控插件完整實(shí)現(xiàn)
3.1 核心數(shù)據(jù)結(jié)構(gòu)設(shè)計(jì)
先定義數(shù)據(jù)結(jié)構(gòu),好的數(shù)據(jù)結(jié)構(gòu)是成功的一半:
@Data
@AllArgsConstructor
@NoArgsConstructor
public class SqlExecutionInfo {
// 基本信息
private String sqlId; // Mapper方法全限定名
private String sql; // 原始SQL
private String realSql; // 實(shí)際SQL(帶參數(shù))
private SqlCommandType commandType; // 操作類型:SELECT/INSERT等
// 執(zhí)行信息
private long startTime; // 開始時(shí)間
private long endTime; // 結(jié)束時(shí)間
private long costTime; // 耗時(shí)(ms)
// 參數(shù)信息
private Object parameters; // 參數(shù)對(duì)象
private Map<String, Object> paramMap; // 參數(shù)Map
// 結(jié)果信息
private Object result; // 執(zhí)行結(jié)果
private int resultSize; // 結(jié)果集大小
private boolean success; // 是否成功
private Throwable exception; // 異常信息
// 上下文信息
private String mapperInterface; // Mapper接口
private String mapperMethod; // Mapper方法
private String stackTrace; // 調(diào)用堆棧
private String dataSource; // 數(shù)據(jù)源
private String transactionId; // 事務(wù)ID
// 性能指標(biāo)
private long fetchSize; // 獲取行數(shù)
private long updateCount; // 更新行數(shù)
private long connectionAcquireTime; // 獲取連接耗時(shí)
public enum SqlCommandType {
SELECT, INSERT, UPDATE, DELETE, UNKNOWN
}
}代碼清單3:SQL執(zhí)行信息實(shí)體類
3.2 完整插件實(shí)現(xiàn)
現(xiàn)在實(shí)現(xiàn)完整的企業(yè)級(jí)插件:
@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}
),
@Signature(
type = Executor.class,
method = "query",
args = {MappedStatement.class, Object.class,
RowBounds.class, ResultHandler.class,
CacheKey.class, BoundSql.class}
)
})
@Component
@Slf4j
public class SqlMonitorPlugin implements Interceptor {
// 配置
private long slowSqlThreshold = 1000; // 慢SQL閾值(ms),默認(rèn)1秒
private boolean enableStackTrace = true; // 是否收集堆棧
private boolean enableAlert = true; // 是否開啟告警
private int maxStackTraceDepth = 5; // 最大堆棧深度
// 統(tǒng)計(jì)收集器
private final SqlStatsCollector statsCollector = new SqlStatsCollector();
// 告警器
private final SlowSqlAlarmer alarmer = new SlowSqlAlarmer();
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 1. 創(chuàng)建執(zhí)行信息
SqlExecutionInfo executionInfo = createExecutionInfo(invocation);
Object result = null;
Throwable exception = null;
try {
// 2. 執(zhí)行原方法
result = invocation.proceed();
// 3. 記錄結(jié)果信息
recordResultInfo(executionInfo, result);
executionInfo.setSuccess(true);
return result;
} catch (Throwable t) {
// 4. 記錄異常
exception = t;
executionInfo.setException(t);
executionInfo.setSuccess(false);
throw t;
} finally {
// 5. 計(jì)算耗時(shí)
executionInfo.setEndTime(System.currentTimeMillis());
executionInfo.setCostTime(
executionInfo.getEndTime() - executionInfo.getStartTime()
);
// 6. 收集統(tǒng)計(jì)信息
statsCollector.collect(executionInfo);
// 7. 記錄日志
logExecution(executionInfo);
// 8. 慢SQL告警
if (executionInfo.getCostTime() > slowSqlThreshold) {
triggerSlowSqlAlert(executionInfo);
}
}
}
private SqlExecutionInfo createExecutionInfo(Invocation invocation) {
SqlExecutionInfo info = new SqlExecutionInfo();
info.setStartTime(System.currentTimeMillis());
// 獲取MappedStatement
MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
Object parameter = invocation.getArgs()[1];
// 設(shè)置基本信息
info.setSqlId(ms.getId());
info.setCommandType(ms.getSqlCommandType());
// 獲取BoundSql
BoundSql boundSql = ms.getBoundSql(parameter);
info.setSql(boundSql.getSql());
info.setParameters(parameter);
// 解析參數(shù)
parseParameters(info, boundSql, parameter);
// 獲取調(diào)用堆棧
if (enableStackTrace) {
info.setStackTrace(getStackTrace());
}
// 解析Mapper信息
parseMapperInfo(info, ms);
return info;
}
private void parseParameters(SqlExecutionInfo info, BoundSql boundSql, Object parameter) {
try {
// 如果是Map類型
if (parameter instanceof Map) {
info.setParamMap((Map<String, Object>) parameter);
}
// 如果是單個(gè)參數(shù)
else if (parameter != null) {
Map<String, Object> paramMap = new HashMap<>();
// 獲取參數(shù)名稱
Object paramObj = boundSql.getParameterObject();
if (paramObj != null) {
// 如果是@Param注解的參數(shù)
if (paramObj instanceof Map) {
paramMap.putAll((Map<String, Object>) paramObj);
}
// 如果是實(shí)體對(duì)象
else {
// 通過反射獲取屬性值
BeanInfo beanInfo = Introspector.getBeanInfo(paramObj.getClass());
PropertyDescriptor[] props = beanInfo.getPropertyDescriptors();
for (PropertyDescriptor prop : props) {
if (!"class".equals(prop.getName())) {
Method getter = prop.getReadMethod();
if (getter != null) {
Object value = getter.invoke(paramObj);
paramMap.put(prop.getName(), value);
}
}
}
}
}
info.setParamMap(paramMap);
}
// 生成實(shí)際SQL(用于調(diào)試)
info.setRealSql(generateRealSql(boundSql));
} catch (Exception e) {
log.warn("解析SQL參數(shù)失敗", e);
}
}
private String generateRealSql(BoundSql boundSql) {
String sql = boundSql.getSql();
Object parameterObject = boundSql.getParameterObject();
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
if (parameterMappings == null || parameterMappings.isEmpty() || parameterObject == null) {
return sql;
}
// 這里簡(jiǎn)化處理,實(shí)際應(yīng)該用TypeHandler處理類型轉(zhuǎn)換
try {
for (ParameterMapping mapping : parameterMappings) {
String property = mapping.getProperty();
Object value = getParameterValue(property, parameterObject);
// 簡(jiǎn)單的字符串替換(僅用于日志,不要用于實(shí)際執(zhí)行)
if (value instanceof String) {
value = "'" + value + "'";
}
sql = sql.replaceFirst("\\?", value.toString());
}
} catch (Exception e) {
// 生成失敗返回原始SQL
}
return sql;
}
private Object getParameterValue(String property, Object parameterObject) {
if (parameterObject instanceof Map) {
return ((Map<?, ?>) parameterObject).get(property);
} else {
// 反射獲取屬性值
try {
BeanInfo beanInfo = Introspector.getBeanInfo(parameterObject.getClass());
PropertyDescriptor[] props = beanInfo.getPropertyDescriptors();
for (PropertyDescriptor prop : props) {
if (prop.getName().equals(property)) {
Method getter = prop.getReadMethod();
if (getter != null) {
return getter.invoke(parameterObject);
}
}
}
} catch (Exception e) {
// ignore
}
}
return "?";
}
private void parseMapperInfo(SqlExecutionInfo info, MappedStatement ms) {
String sqlId = ms.getId();
int lastDotIndex = sqlId.lastIndexOf(".");
if (lastDotIndex > 0) {
info.setMapperInterface(sqlId.substring(0, lastDotIndex));
info.setMapperMethod(sqlId.substring(lastDotIndex + 1));
}
}
private void recordResultInfo(SqlExecutionInfo info, Object result) {
if (result instanceof List) {
info.setResultSize(((List<?>) result).size());
} else if (result instanceof Collection) {
info.setResultSize(((Collection<?>) result).size());
} else if (result != null) {
info.setResultSize(1);
}
info.setResult(result);
}
private String getStackTrace() {
StringBuilder stackTrace = new StringBuilder();
StackTraceElement[] elements = Thread.currentThread().getStackTrace();
int depth = 0;
for (StackTraceElement element : elements) {
// 過濾框架調(diào)用
if (element.getClassName().startsWith("org.apache.ibatis") ||
element.getClassName().startsWith("com.sun.proxy") ||
element.getClassName().startsWith("java.lang.Thread")) {
continue;
}
stackTrace.append(element.getClassName())
.append(".")
.append(element.getMethodName())
.append("(")
.append(element.getFileName())
.append(":")
.append(element.getLineNumber())
.append(")\n");
if (++depth >= maxStackTraceDepth) {
break;
}
}
return stackTrace.toString();
}
private void logExecution(SqlExecutionInfo info) {
if (log.isInfoEnabled()) {
String logMsg = String.format(
"SQL執(zhí)行統(tǒng)計(jì) - 方法: %s, 耗時(shí): %dms, 類型: %s, 結(jié)果: %d條, SQL: %s",
info.getSqlId(),
info.getCostTime(),
info.getCommandType(),
info.getResultSize(),
info.getSql()
);
if (info.getCostTime() > slowSqlThreshold) {
log.warn("?? " + logMsg);
} else {
log.info("? " + logMsg);
}
}
}
private void triggerSlowSqlAlert(SqlExecutionInfo info) {
if (!enableAlert) {
return;
}
SlowSqlAlert alert = new SlowSqlAlert();
alert.setSqlId(info.getSqlId());
alert.setSql(info.getSql());
alert.setCostTime(info.getCostTime());
alert.setThreshold(slowSqlThreshold);
alert.setParameters(info.getParamMap());
alert.setStackTrace(info.getStackTrace());
alert.setAlertTime(new Date());
alarmer.sendAlert(alert);
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
// 讀取配置
String threshold = properties.getProperty("slowSqlThreshold");
if (threshold != null) {
this.slowSqlThreshold = Long.parseLong(threshold);
}
String enableStack = properties.getProperty("enableStackTrace");
if (enableStack != null) {
this.enableStackTrace = Boolean.parseBoolean(enableStack);
}
String enableAlertProp = properties.getProperty("enableAlert");
if (enableAlertProp != null) {
this.enableAlert = Boolean.parseBoolean(enableAlertProp);
}
String maxDepth = properties.getProperty("maxStackTraceDepth");
if (maxDepth != null) {
this.maxStackTraceDepth = Integer.parseInt(maxDepth);
}
}
}代碼清單4:完整的企業(yè)級(jí)SQL監(jiān)控插件
4. 高級(jí)功能:SQL統(tǒng)計(jì)與告警
4.1 SQL統(tǒng)計(jì)收集器
監(jiān)控不能只記錄,還要能分析。實(shí)現(xiàn)一個(gè)統(tǒng)計(jì)收集器:
@Component
@Slf4j
public class SqlStatsCollector {
// 使用ConcurrentHashMap保證線程安全
private final ConcurrentHashMap<String, SqlStats> statsMap = new ConcurrentHashMap<>();
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
// 統(tǒng)計(jì)信息
@Data
public static class SqlStats {
private String sqlId;
private long totalCount; // 總執(zhí)行次數(shù)
private long successCount; // 成功次數(shù)
private long errorCount; // 失敗次數(shù)
private long totalCostTime; // 總耗時(shí)
private long maxCostTime; // 最大耗時(shí)
private long minCostTime = Long.MAX_VALUE; // 最小耗時(shí)
private double avgCostTime; // 平均耗時(shí)
// 耗時(shí)分布
private long[] costTimeDistribution = new long[6]; // 0-100, 100-500, 500-1000, 1000-3000, 3000-10000, >10000 ms
// 最近100次耗時(shí)(用于計(jì)算P99等)
private final LinkedList<Long> recentCosts = new LinkedList<>();
private static final int RECENT_SIZE = 100;
public synchronized void record(SqlExecutionInfo info) {
totalCount++;
if (info.isSuccess()) {
successCount++;
} else {
errorCount++;
}
long cost = info.getCostTime();
totalCostTime += cost;
if (cost > maxCostTime) {
maxCostTime = cost;
}
if (cost < minCostTime) {
minCostTime = cost;
}
avgCostTime = (double) totalCostTime / totalCount;
// 記錄耗時(shí)分布
if (cost < 100) {
costTimeDistribution[0]++;
} else if (cost < 500) {
costTimeDistribution[1]++;
} else if (cost < 1000) {
costTimeDistribution[2]++;
} else if (cost < 3000) {
costTimeDistribution[3]++;
} else if (cost < 10000) {
costTimeDistribution[4]++;
} else {
costTimeDistribution[5]++;
}
// 記錄最近耗時(shí)
recentCosts.add(cost);
if (recentCosts.size() > RECENT_SIZE) {
recentCosts.removeFirst();
}
}
public double getP99CostTime() {
if (recentCosts.isEmpty()) {
return 0;
}
List<Long> sorted = new ArrayList<>(recentCosts);
Collections.sort(sorted);
int index = (int) Math.ceil(0.99 * sorted.size()) - 1;
return index >= 0 ? sorted.get(index) : sorted.get(0);
}
public double getSuccessRate() {
return totalCount > 0 ? (double) successCount / totalCount * 100 : 100;
}
}
public SqlStatsCollector() {
// 每分鐘輸出一次統(tǒng)計(jì)報(bào)告
scheduler.scheduleAtFixedRate(this::printStatsReport, 1, 1, TimeUnit.MINUTES);
// 每小時(shí)清理一次舊數(shù)據(jù)
scheduler.scheduleAtFixedRate(this::cleanupOldStats, 1, 1, TimeUnit.HOURS);
}
public void collect(SqlExecutionInfo info) {
String sqlId = info.getSqlId();
statsMap.compute(sqlId, (key, stats) -> {
if (stats == null) {
stats = new SqlStats();
stats.setSqlId(sqlId);
}
stats.record(info);
return stats;
});
}
public SqlStats getStats(String sqlId) {
return statsMap.get(sqlId);
}
public Map<String, SqlStats> getAllStats() {
return new HashMap<>(statsMap);
}
private void printStatsReport() {
if (statsMap.isEmpty()) {
return;
}
log.info("======= SQL執(zhí)行統(tǒng)計(jì)報(bào)告 =======");
log.info("統(tǒng)計(jì)時(shí)間: {}", new Date());
log.info("總SQL數(shù)量: {}", statsMap.size());
// 找出最慢的10個(gè)SQL
List<SqlStats> topSlow = statsMap.values().stream()
.sorted((s1, s2) -> Long.compare(s2.getMaxCostTime(), s1.getMaxCostTime()))
.limit(10)
.collect(Collectors.toList());
log.info("最慢的10個(gè)SQL:");
for (int i = 0; i < topSlow.size(); i++) {
SqlStats stats = topSlow.get(i);
log.info("{}. {} - 最大: {}ms, 平均: {:.2f}ms, 成功: {:.2f}%, 調(diào)用: {}次",
i + 1, stats.getSqlId(), stats.getMaxCostTime(),
stats.getAvgCostTime(), stats.getSuccessRate(), stats.getTotalCount());
}
// 統(tǒng)計(jì)總體情況
long totalExecutions = statsMap.values().stream()
.mapToLong(SqlStats::getTotalCount)
.sum();
double avgSuccessRate = statsMap.values().stream()
.mapToDouble(SqlStats::getSuccessRate)
.average()
.orElse(0);
log.info("總體統(tǒng)計(jì) - 總執(zhí)行: {}次, 平均成功率: {:.2f}%",
totalExecutions, avgSuccessRate);
}
private void cleanupOldStats() {
// 清理24小時(shí)無調(diào)用的統(tǒng)計(jì)
// 實(shí)際實(shí)現(xiàn)中可以添加最后調(diào)用時(shí)間字段
}
}代碼清單5:SQL統(tǒng)計(jì)收集器
4.2 慢SQL告警器
告警要及時(shí),但不能太頻繁:
@Component
@Slf4j
public class SlowSqlAlarmer {
// 告警規(guī)則
@Data
public static class AlertRule {
private String sqlPattern; // SQL模式匹配
private long threshold; // 閾值(ms)
private int interval; // 告警間隔(分鐘)
private String[] receivers; // 接收人
private AlertLevel level; // 告警級(jí)別
public enum AlertLevel {
INFO, WARNING, ERROR, CRITICAL
}
}
// 告警記錄
@Data
public static class AlertRecord {
private String sqlId;
private long costTime;
private long threshold;
private Date alertTime;
private int count; // 本次告警周期內(nèi)的次數(shù)
}
private final List<AlertRule> rules = new ArrayList<>();
private final Map<String, AlertRecord> lastAlertMap = new ConcurrentHashMap<>();
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
public SlowSqlAlarmer() {
// 加載默認(rèn)規(guī)則
loadDefaultRules();
// 每小時(shí)清理一次舊告警記錄
scheduler.scheduleAtFixedRate(this::cleanupAlertRecords, 1, 1, TimeUnit.HOURS);
}
private void loadDefaultRules() {
// 規(guī)則1:所有SQL超過5秒
AlertRule rule1 = new AlertRule();
rule1.setSqlPattern(".*");
rule1.setThreshold(5000);
rule1.setInterval(5);
rule1.setLevel(AlertRule.AlertLevel.ERROR);
rule1.setReceivers(new String[]{"dba@company.com"});
// 規(guī)則2:重要業(yè)務(wù)SQL超過1秒
AlertRule rule2 = new AlertRule();
rule2.setSqlPattern(".*(User|Order|Payment).*");
rule2.setThreshold(1000);
rule2.setInterval(1);
rule2.setLevel(AlertRule.AlertLevel.WARNING);
rule2.setReceivers(new String[]{"dev@company.com"});
rules.add(rule1);
rules.add(rule2);
}
public void sendAlert(SlowSqlAlert alert) {
// 1. 匹配規(guī)則
List<AlertRule> matchedRules = matchRules(alert);
if (matchedRules.isEmpty()) {
return;
}
for (AlertRule rule : matchedRules) {
// 2. 檢查是否需要告警(防騷擾)
if (shouldAlert(alert, rule)) {
// 3. 發(fā)送告警
doSendAlert(alert, rule);
// 4. 記錄告警
recordAlert(alert, rule);
}
}
}
private List<AlertRule> matchRules(SlowSqlAlert alert) {
return rules.stream()
.filter(rule -> alert.getCostTime() >= rule.getThreshold())
.filter(rule -> alert.getSql().matches(rule.getSqlPattern()))
.collect(Collectors.toList());
}
private boolean shouldAlert(SlowSqlAlert alert, AlertRule rule) {
String key = alert.getSqlId() + ":" + rule.getThreshold();
AlertRecord lastRecord = lastAlertMap.get(key);
if (lastRecord == null) {
return true;
}
// 檢查是否在告警間隔內(nèi)
long timeDiff = System.currentTimeMillis() - lastRecord.getAlertTime().getTime();
return timeDiff > rule.getInterval() * 60 * 1000;
}
private void doSendAlert(SlowSqlAlert alert, AlertRule rule) {
String title = String.format("[%s] 慢SQL告警: %s",
rule.getLevel(), alert.getSqlId());
StringBuilder content = new StringBuilder();
content.append("SQL ID: ").append(alert.getSqlId()).append("\n");
content.append("執(zhí)行耗時(shí): ").append(alert.getCostTime()).append("ms\n");
content.append("閾值: ").append(rule.getThreshold()).append("ms\n");
content.append("實(shí)際SQL: ").append(alert.getSql()).append("\n");
content.append("參數(shù): ").append(alert.getParameters()).append("\n");
content.append("告警時(shí)間: ").append(new Date()).append("\n");
if (alert.getStackTrace() != null) {
content.append("調(diào)用堆棧:\n").append(alert.getStackTrace());
}
// 發(fā)送郵件
sendEmail(rule.getReceivers(), title, content.toString());
// 發(fā)送企業(yè)微信/釘釘
sendChatMessage(rule.getLevel(), title, content.toString());
log.warn("發(fā)送慢SQL告警: {}, 耗時(shí): {}ms", alert.getSqlId(), alert.getCostTime());
}
private void recordAlert(SlowSqlAlert alert, AlertRule rule) {
String key = alert.getSqlId() + ":" + rule.getThreshold();
AlertRecord record = new AlertRecord();
record.setSqlId(alert.getSqlId());
record.setCostTime(alert.getCostTime());
record.setThreshold(rule.getThreshold());
record.setAlertTime(new Date());
record.setCount(1);
lastAlertMap.put(key, record);
}
private void sendEmail(String[] receivers, String title, String content) {
// 實(shí)現(xiàn)郵件發(fā)送邏輯
// 可以使用JavaMail或Spring Mail
}
private void sendChatMessage(AlertRule.AlertLevel level, String title, String content) {
// 實(shí)現(xiàn)企業(yè)微信/釘釘消息發(fā)送
}
private void cleanupAlertRecords() {
long now = System.currentTimeMillis();
long hourMillis = 60 * 60 * 1000;
lastAlertMap.entrySet().removeIf(entry ->
now - entry.getValue().getAlertTime().getTime() > 24 * hourMillis
);
}
}代碼清單6:慢SQL告警器
5. 插件配置與使用
5.1 MyBatis配置
在mybatis-config.xml中配置插件:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<settings>
<!-- 開啟駝峰命名轉(zhuǎn)換 -->
<setting name="mapUnderscoreToCamelCase" value="true"/>
</settings>
<!-- 插件配置 -->
<plugins>
<plugin interceptor="com.yourcompany.mybatis.plugin.SqlMonitorPlugin">
<!-- 慢SQL閾值,單位毫秒 -->
<property name="slowSqlThreshold" value="1000"/>
<!-- 是否開啟堆棧信息收集 -->
<property name="enableStackTrace" value="true"/>
<!-- 是否開啟告警 -->
<property name="enableAlert" value="true"/>
<!-- 最大堆棧深度 -->
<property name="maxStackTraceDepth" value="5"/>
<!-- 慢SQL告警規(guī)則(JSON格式) -->
<property name="alertRules" value='
[
{
"sqlPattern": ".*",
"threshold": 5000,
"interval": 5,
"level": "ERROR",
"receivers": ["dba@company.com"]
},
{
"sqlPattern": ".*(User|Order|Payment).*",
"threshold": 1000,
"interval": 1,
"level": "WARNING",
"receivers": ["dev@company.com"]
}
]
'/>
</plugin>
</plugins>
<!-- 其他配置... -->
</configuration>代碼清單7:MyBatis配置插件
5.2 Spring Boot配置
在Spring Boot中配置:
# application.yml
mybatis:
configuration:
# MyBatis配置
map-underscore-to-camel-case: true
default-statement-timeout: 30
# 插件配置
configuration-properties:
slowSqlThreshold: 1000
enableStackTrace: true
enableAlert: true
maxStackTraceDepth: 5Java配置類:
@Configuration
public class MyBatisConfig {
@Bean
public SqlMonitorPlugin sqlMonitorPlugin() {
SqlMonitorPlugin plugin = new SqlMonitorPlugin();
Properties properties = new Properties();
properties.setProperty("slowSqlThreshold", "1000");
properties.setProperty("enableStackTrace", "true");
properties.setProperty("enableAlert", "true");
properties.setProperty("maxStackTraceDepth", "5");
plugin.setProperties(properties);
return plugin;
}
}6. 性能測(cè)試與優(yōu)化
6.1 插件性能影響測(cè)試
插件有性能開銷,必須測(cè)試:
測(cè)試環(huán)境:
- CPU: 4核
- 內(nèi)存: 8GB
- MySQL: 8.0
- MyBatis: 3.5.7
- 測(cè)試數(shù)據(jù): 10000次查詢
測(cè)試結(jié)果:
| 插件功能 | 平均耗時(shí)(ms) | 性能影響 | 內(nèi)存增加 |
|---|---|---|---|
| 無插件 | 12.5 | 基準(zhǔn) | 0MB |
| 基礎(chǔ)監(jiān)控 | 13.8 | +10.4% | 5MB |
| 完整監(jiān)控 | 15.2 | +21.6% | 12MB |
| 監(jiān)控+告警 | 16.7 | +33.6% | 18MB |
結(jié)論:插件會(huì)增加10-30%的性能開銷,在可接受范圍內(nèi)。
6.2 性能優(yōu)化技巧
優(yōu)化1:異步處理
// 異步發(fā)送告警
private void triggerSlowSqlAlert(SqlExecutionInfo info) {
if (!enableAlert) {
return;
}
CompletableFuture.runAsync(() -> {
SlowSqlAlert alert = new SlowSqlAlert();
// 構(gòu)建告警信息...
alarmer.sendAlert(alert);
});
}優(yōu)化2:采樣率控制
// 控制采樣率,避免全量監(jiān)控
private boolean shouldSample() {
// 10%的采樣率
return ThreadLocalRandom.current().nextInt(100) < 10;
}
// 在intercept方法中使用
if (!shouldSample() && info.getCostTime() < slowSqlThreshold) {
return invocation.proceed();
}優(yōu)化3:使用對(duì)象池
// 重用SqlExecutionInfo對(duì)象
private final ObjectPool<SqlExecutionInfo> infoPool = new GenericObjectPool<>(
new BasePooledObjectFactory<SqlExecutionInfo>() {
@Override
public SqlExecutionInfo create() {
return new SqlExecutionInfo();
}
@Override
public void passivateObject(PooledObject<SqlExecutionInfo> p) {
// 重置對(duì)象狀態(tài)
p.getObject().reset();
}
}
);
private SqlExecutionInfo createExecutionInfo(Invocation invocation) {
SqlExecutionInfo info = null;
try {
info = infoPool.borrowObject();
// 填充數(shù)據(jù)...
return info;
} catch (Exception e) {
return new SqlExecutionInfo();
} finally {
if (info != null) {
infoPool.returnObject(info);
}
}
}7. 生產(chǎn)環(huán)境問題排查
7.1 常見問題排查清單
我總結(jié)了插件開發(fā)中最常見的10個(gè)問題:
問題1:插件不生效
排查步驟:
- 檢查
@Intercepts注解配置是否正確 - 檢查插件是否在
mybatis-config.xml中配置 - 檢查Spring Boot自動(dòng)配置是否正確
- 檢查插件順序(如果有多個(gè)插件)
// 調(diào)試方法:在插件中加日志
@Override
public Object plugin(Object target) {
log.info("插件包裝對(duì)象: {}", target.getClass().getName());
return Plugin.wrap(target, this);
}問題2:性能下降明顯
排查:
- 檢查是否頻繁創(chuàng)建對(duì)象
- 檢查字符串操作是否過多
- 檢查日志級(jí)別是否正確
// 使用JMH進(jìn)行性能測(cè)試
@Benchmark
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public void testPluginPerformance() {
// 測(cè)試代碼
}問題3:內(nèi)存泄漏
排查:
- 檢查是否有靜態(tài)Map無限增長(zhǎng)
- 檢查線程局部變量是否清理
- 使用MAT分析內(nèi)存快照
// 定期清理緩存
scheduler.scheduleAtFixedRate(() -> {
statsMap.entrySet().removeIf(entry ->
System.currentTimeMillis() - entry.getValue().getLastAccessTime() > 3600000
);
}, 1, 1, TimeUnit.HOURS);7.2 監(jiān)控指標(biāo)
插件自身也要被監(jiān)控:
@Component
public class PluginMetrics {
private final MeterRegistry meterRegistry;
// 監(jiān)控指標(biāo)
private final Counter totalSqlCounter;
private final Counter slowSqlCounter;
private final Timer sqlTimer;
public PluginMetrics(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
this.totalSqlCounter = Counter.builder("mybatis.sql.total")
.description("SQL執(zhí)行總次數(shù)")
.register(meterRegistry);
this.slowSqlCounter = Counter.builder("mybatis.sql.slow")
.description("慢SQL次數(shù)")
.register(meterRegistry);
this.sqlTimer = Timer.builder("mybatis.sql.duration")
.description("SQL執(zhí)行耗時(shí)")
.publishPercentiles(0.5, 0.95, 0.99)
.register(meterRegistry);
}
public void recordSqlExecution(long costTime, boolean isSlow) {
totalSqlCounter.increment();
sqlTimer.record(costTime, TimeUnit.MILLISECONDS);
if (isSlow) {
slowSqlCounter.increment();
}
}
}8. 高級(jí)功能擴(kuò)展
8.1 SQL防注入檢測(cè)
在監(jiān)控基礎(chǔ)上,增加安全檢測(cè):
public class SqlInjectionDetector {
private static final Pattern SQL_INJECTION_PATTERN = Pattern.compile(
"('|--|;|\\|/\\*|\\*/|@@|char|nchar|varchar|nvarchar|alter|begin|cast|create|cursor|declare|delete|drop|end|exec|execute|fetch|insert|kill|open|select|sys|sysobjects|syscolumns|table|update|union)"
);
public static boolean detectInjection(String sql) {
if (sql == null) {
return false;
}
// 檢查是否使用${}(容易導(dǎo)致注入)
if (sql.contains("${")) {
return true;
}
// 檢查危險(xiǎn)關(guān)鍵字
return SQL_INJECTION_PATTERN.matcher(sql.toLowerCase()).find();
}
public static String sanitizeSql(String sql) {
if (sql == null) {
return null;
}
// 簡(jiǎn)單的SQL清理
return sql.replace("'", "''")
.replace("--", "")
.replace(";", "");
}
}
// 在插件中使用
private void checkSqlInjection(SqlExecutionInfo info) {
if (SqlInjectionDetector.detectInjection(info.getSql())) {
log.error("檢測(cè)到可能的SQL注入: {}", info.getSql());
// 發(fā)送安全告警
sendSecurityAlert(info);
}
}8.2 SQL執(zhí)行計(jì)劃分析
集成數(shù)據(jù)庫(kù)執(zhí)行計(jì)劃分析:
public class ExplainAnalyzer {
public ExecutionPlan analyzeExplain(String sql, Object params) {
// 生成EXPLAIN SQL
String explainSql = "EXPLAIN " + sql;
// 執(zhí)行EXPLAIN
// 解析結(jié)果
// 返回執(zhí)行計(jì)劃
return executionPlan;
}
@Data
public static class ExecutionPlan {
private String id;
private String selectType;
private String table;
private String type; // ALL, index, range, ref, eq_ref, const, system, NULL
private String possibleKeys;
private String key;
private int keyLen;
private String ref;
private int rows;
private String extra; // Using filesort, Using temporary
}
public boolean isSlowPlan(ExecutionPlan plan) {
// 判斷是否為慢查詢計(jì)劃
return "ALL".equals(plan.getType()) || // 全表掃描
plan.getExtra().contains("filesort") || // 文件排序
plan.getExtra().contains("temporary"); // 臨時(shí)表
}
}9. 企業(yè)級(jí)最佳實(shí)踐
9.1 我的"插件開發(fā)規(guī)則"
經(jīng)過多年實(shí)踐,我總結(jié)了一套插件開發(fā)最佳實(shí)踐:
第一條:明確職責(zé)
一個(gè)插件只做一件事,不要大而全。監(jiān)控插件就只監(jiān)控,不要混入業(yè)務(wù)邏輯。
第二條:性能優(yōu)先
插件調(diào)用非常頻繁,每個(gè)操作都要考慮性能。避免在插件中做耗時(shí)的IO操作。
第三條:配置化
所有參數(shù)都要可配置,避免硬編碼。通過Properties傳遞配置。
第四條:異常處理
插件異常不能影響主流程,要捕獲所有異常,只記錄不拋出。
第五條:兼容性
考慮不同MyBatis版本、不同數(shù)據(jù)庫(kù)的兼容性。使用反射時(shí)要檢查方法是否存在。
9.2 生產(chǎn)環(huán)境部署檢查清單
上線前必須檢查:
- [ ] 性能測(cè)試通過
- [ ] 內(nèi)存泄漏測(cè)試通過
- [ ] 異常情況測(cè)試通過
- [ ] 配置項(xiàng)驗(yàn)證通過
- [ ] 監(jiān)控指標(biāo)配置完成
- [ ] 告警渠道測(cè)試通過
- [ ] 回滾方案準(zhǔn)備就緒
10. 最后的話
MyBatis插件開發(fā)就像給汽車裝行車記錄儀,平時(shí)用不上,關(guān)鍵時(shí)刻能救命。
我見過太多團(tuán)隊(duì)在這上面栽跟頭:有的因?yàn)椴寮阅軉栴}拖垮系統(tǒng),有的因?yàn)閮?nèi)存泄漏導(dǎo)致OOM,有的因?yàn)楫惓L幚聿划?dāng)導(dǎo)致事務(wù)回滾。
記住:插件是利器,用好了能提升系統(tǒng)可觀測(cè)性,用不好就是線上事故。理解原理,小心使用,持續(xù)優(yōu)化。
最后建議:不要直接在生產(chǎn)環(huán)境使用本文的代碼。先在測(cè)試環(huán)境跑通,性能測(cè)試通過,再逐步灰度上線。記?。?strong>先監(jiān)控,后優(yōu)化;先測(cè)試,后上線。
以上就是MyBatis插件實(shí)現(xiàn)SQL執(zhí)行耗時(shí)監(jiān)控的詳細(xì)內(nèi)容,更多關(guān)于MyBatis SQL耗時(shí)監(jiān)控的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
雪花算法(snowflak)生成有序不重復(fù)ID的Java實(shí)現(xiàn)代碼
雪花算法是一種分布式系統(tǒng)中生成唯一ID的方法,由41位時(shí)間戳、10位機(jī)器碼和12位序列號(hào)組成,具有唯一性、有序性和高效率等優(yōu)點(diǎn),這篇文章主要介紹了雪花算法(snowflak)生成有序不重復(fù)ID的Java實(shí)現(xiàn)的相關(guān)資料,需要的朋友可以參考下2024-11-11
一文深入分析java.lang.ClassNotFoundException異常
這篇文章主要給大家介紹了關(guān)于java.lang.ClassNotFoundException異常的相關(guān)資料,java.lang.ClassNotFoundException是Java編程時(shí)經(jīng)常會(huì)遇到的一個(gè)異常,它表示JVM在嘗試加載某個(gè)類時(shí)未能找到該類,需要的朋友可以參考下2023-10-10
Spring Security學(xué)習(xí)筆記(一)
這篇文章主要介紹了Spring Security的相關(guān)資料,幫助大家開始學(xué)習(xí)Spring Security框架,感興趣的朋友可以了解下2020-09-09
Java使用screw來對(duì)比數(shù)據(jù)庫(kù)表和字段差異
這篇文章主要介紹了Java如何使用screw來對(duì)比數(shù)據(jù)庫(kù)表和字段差異,文中的示例代碼講解詳細(xì),感興趣的小伙伴可以跟隨小編一起學(xué)習(xí)一下2024-12-12
Java中調(diào)用DLL動(dòng)態(tài)庫(kù)的操作方法
在Java編程中,有時(shí)我們需要調(diào)用本地代碼庫(kù),特別是Windows平臺(tái)上的DLL(動(dòng)態(tài)鏈接庫(kù)),本文中,我們將詳細(xì)討論如何在Java中加載和調(diào)用DLL動(dòng)態(tài)庫(kù),并通過具體示例來展示這個(gè)過程,感興趣的朋友跟隨小編一起看看吧2024-03-03
SpringBoot使用@Valid或者@Validated時(shí)自定義校驗(yàn)的場(chǎng)景分析
文章介紹了在Java開發(fā)中,如何處理需要根據(jù)多個(gè)字段進(jìn)行校驗(yàn)的自定義場(chǎng)景,通過創(chuàng)建自定義注解和實(shí)現(xiàn)相應(yīng)的約束類,可以在DTO類中對(duì)這些字段進(jìn)行復(fù)雜校驗(yàn),最后,通過全局異常處理機(jī)制,可以統(tǒng)一處理校驗(yàn)失敗的情況,本文給大家介紹的非常詳細(xì),感興趣的朋友一起看看吧2025-12-12
Java小項(xiàng)目之迷宮游戲的實(shí)現(xiàn)方法
這篇文章主要給大家介紹了關(guān)于Java小項(xiàng)目之迷宮的實(shí)現(xiàn)方法,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2021-01-01
關(guān)于java.lang.NumberFormatException: null的問題及解決
這篇文章主要介紹了關(guān)于java.lang.NumberFormatException: null的問題及解決方案,具有很好的參考價(jià)值,希望對(duì)大家有所幫助,如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2023-09-09
Java POI-TL設(shè)置Word圖片浮于文字上方
這篇文章主要為大家詳細(xì)介紹了Java如何利用POI-TL設(shè)置Word圖片環(huán)繞方式為浮于文字上方而不是嵌入的方式,感興趣的小伙伴可以參考一下2025-03-03
springboot如何使用thymeleaf模板訪問html頁面
springboot中推薦使用thymeleaf模板,使用html作為頁面展示。那么如何通過Controller來訪問來訪問html頁面呢?下面通過本文給大家詳細(xì)介紹,感興趣的朋友跟隨腳本之家小編一起看看吧2018-05-05

