一、项目背景与技术目标
1.1 业务痛点
- 分页逻辑侵入业务代码:每个查询都需要手工编写 LIMIT 与 OFFSET 拼接,以及对 COUNT 查询的手动实现,导致代码重复、易出错。
- 慢 SQL 发现滞后:缺少基于指纹的聚合统计,相同的 SQL 模板因参数不同被淹没在海量日志中;告警阈值一刀切,无法针对特定方法微调。
- 日志泄露敏感数据:应用日志直接输出 SQL 和参数,手机号、身份证、密码等明文存储,违反数据安全合规要求。
- 多租户隔离实现复杂:业务代码中手写 tenant_id 过滤条件,不仅污染业务逻辑,还容易因遗漏导致跨租户数据泄露。
- 多插件共存时的行为不确定性:多个插件同时注册,SQL 改写顺序、监控耗时归属、代理嵌套影响等问题缺乏量化分析,生产调优缺少依据。
1.2 设计目标
- 插件职责单一、低耦合:每个插件只处理一个横切关注点,可独立启用或组合。
- SQL 改写基于 AST,安全可靠:杜绝字符串拼接可能引入的 SQL 注入风险,精准处理各种 SQL 结构。
- 性能可观测、可量化:提供毫秒级粒度的执行耗时分解,精确计算插件级联带来的额外开销。
- 生产级监控体系:基于指纹的聚合统计、分位值展示、告警通知,与现有监控系统无缝对接。
- 兼容性与扩展性:支持多种数据库方言,允许用户自定义方言、脱敏规则、告警通道。
二、整体架构
2.1 模块划分与职责
bash
mybatis-plugins (Root)
├── mybatis-plugins-core
│ ├── interceptor # 各拦截器实现
│ ├── dialect # 数据库方言抽象与实现
│ ├── normalizer # SQL 指纹与归一化
│ ├── tracker # 插件链性能追踪
│ ├── statistics # 统计聚合、分位计算
│ └── util # MetaObject 扩展、SQL 帮助类
├── mybatis-plugins-spring-boot-starter
│ ├── autoconfigure # 自动配置类
│ ├── properties # 配置属性绑定
│ └── ordering # 拦截器排序器
├── mybatis-plugins-monitor
│ ├── collector # 统计收集器接口与默认实现
│ ├── storage # 数据持久化接口 (内存/Redis/DB)
│ ├── endpoint # Actuator 端点
├── mybatis-plugins-console
│ ├── controller # 内部管理 API
│ └── resources/templates # Web 控制台页面
└── mybatis-plugins-samples # 集成演示项目
mybatis-plugins-core 不依赖 Spring,可直接在纯 MyBatis 环境中使用。
mybatis-plugins-spring-boot-starter 负责自动注入、加载顺序控制和配置读取。
mybatis-plugins-monitor 提供可插拔的监控存储与暴露方式。
mybatis-plugins-console 提供开箱即用的监控面板。
2.2 核心类图简示
lua
AbstractMybatisInterceptor <|-- PaginationInterceptor
AbstractMybatisInterceptor <|-- SlowSqlMonitorInterceptor
AbstractMybatisInterceptor <|-- TenantPlugin
AbstractMybatisInterceptor <|-- SqlDesensitizationInterceptor
SqlNormalizer (Interface)
└─ JsqlParserNormalizer
Dialect (Interface)
├─ MySQLDialect
└─ PostgreSQLDialect
SqlStatsCollector (Singleton) --> SqlDigestStats (per fingerprint)
└── TDigest
InterceptorChainTracker (ThreadLocal based) --> TraceNode
2.3 关键拦截点与代理关系时序图
以一次带分页和多租户的查询为例:
rust
Client -> SqlSession.selectList
-> Executor.query (SlowSqlMonitorInterceptor代理)
-> 记录开始时间
-> 调用下层 StatementHandler.prepare (分页插件代理)
-> 改写 SQL 添加 LIMIT
-> 调用下层 StatementHandler.prepare (多租户插件代理)
-> 改写 SQL 添加 tenant_id = ?
-> 真实 StatementHandler.prepare (JDBC 预编译)
-> 返回
-> 参数设置 (ParameterHandler.setParameters, 脱敏插件代理输出日志)
-> 执行查询
-> 记录结束时间,进行指纹统计和告警
慢 SQL 监控记录的耗时 = SQL 改写花费(两个插件)+ JDBC 网络往返 + 数据库执行时间 + 结果集映射。
通过 InterceptorChainTracker 可进一步分离出各阶段耗时。
三、分页插件深度设计
3.1 拦截点与注解
java
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class PaginationInterceptor extends AbstractMybatisInterceptor { ... }
3.2 方言抽象与自动识别
java
public interface Dialect {
String buildPaginationSql(String originalSql, long offset, long limit);
String buildCountSql(String originalSql); // 优化后的Count
boolean supportsLimit();
boolean containsPagingClause(String sql); // 避免重复添加
}
// 自动探测
public class DialectResolver {
public static Dialect resolve(Connection connection) {
DatabaseMetaData meta = connection.getMetaData();
String productName = meta.getDatabaseProductName().toLowerCase();
if (productName.contains("mysql")) return new MySQLDialect();
if (productName.contains("postgresql")) return new PostgreSQLDialect();
// ...
}
}
MySQLDialect 实现:
java
public class MySQLDialect implements Dialect {
@Override
public String buildPaginationSql(String sql, long offset, long limit) {
return sql + " LIMIT " + limit + " OFFSET " + offset;
}
@Override
public String buildCountSql(String originalSql) {
// 使用 JSqlParser 直接替换 SELECT 子句为 SELECT COUNT(*) FROM (原表) tmp
// 避免子查询导致索引失效
}
@Override
public boolean containsPagingClause(String sql) {
return sql.matches("(?i).*\\sLIMIT\\s+\\d+.*");
}
}
3.3 避免重复改写
拦截开始时,调用 dialect.containsPagingClause(sql) 检查是否已含分页关键字,若是则直接透传,防止重复追加 LIMIT。同时检测 SQL 中是否存在 #{} 或 ${} 的占位符未被替换的场景(拦截层次保证了此时已是完整的静态 SQL + ?)。
3.4 Count 查询的深度优化
原始设计缺陷 :SELECT COUNT(*) FROM (原 SQL) tmp_count 会生成子查询,数据库无法利用索引优化,且需要原样传递参数。
优化方案 :通过 JSqlParser 解析原始 SQL,将 SELECT 列表直接替换为 COUNT(*),同时移除 ORDER BY、LIMIT 等非必要子句,保留 FROM 和 WHERE 及参数位置不变。
java
public class CountSqlBuilder {
public static String build(Dialect dialect, String sql, BoundSql boundSql) {
try {
Statement stmt = CCJSqlParserUtil.parse(sql);
if (stmt instanceof Select) {
Select select = (Select) stmt;
// 移除ORDER BY和LIMIT
select.accept(new StatementVisitorAdapter() {
@Override public void visit(PlainSelect plainSelect) {
plainSelect.setOrderByElements(null);
plainSelect.setLimit(null);
plainSelect.setOffset(null);
// 将SELECT项替换为COUNT(*)
plainSelect.setSelectItems(Arrays.asList(
new SelectExpressionItem(new FunctionItem("COUNT", new AllColumns())) ));
}
});
return select.toString();
}
} catch (JSQLParserException e) { /* fallback to subquery */ }
return dialect.buildCountSql(sql); // 兜底
}
}
关键点 :BoundSql 的 parameterMappings 列表与原 SQL 的 ? 占位符一一对应,改写 COUNT SQL 后必须确保占位符数量及顺序不变,因此直接复用原 BoundSql 对象的 parameterMappings 和 additionalParameters 生成新的 BoundSql。
3.5 分页查询执行流程
java
@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler handler = (StatementHandler) invocation.getTarget();
MetaObject metaObject = SystemMetaObject.forObject(handler);
PageRequest page = PageContext.getPageRequest();
if (page == null || page.getPageSize() <= 0) return invocation.proceed();
BoundSql boundSql = handler.getBoundSql();
String originalSql = boundSql.getSql();
Connection conn = (Connection) invocation.getArgs()[0];
Dialect dialect = DialectResolver.resolve(conn);
if (dialect.containsPagingClause(originalSql)) return invocation.proceed();
// 1. 执行COUNT查询
if (page.isCount() && !ThreadLocalCache.get("count_executed_for_" + boundSql.hashCode())) {
String countSql = CountSqlBuilder.build(dialect, originalSql, boundSql);
BoundSql countBoundSql = new BoundSql(boundSql.getConfiguration(), countSql,
boundSql.getParameterMappings(), boundSql.getParameterObject());
// 注入额外参数
copyAdditionalParameters(boundSql, countBoundSql);
long total = executeCount(conn, ms, countBoundSql);
PageContext.setTotal(total);
ThreadLocalCache.set("count_executed_for_" + boundSql.hashCode(), true);
}
// 2. 改写分页SQL
String pageSql = dialect.buildPaginationSql(originalSql, page.getOffset(), page.getPageSize());
metaObject.setValue("delegate.boundSql.sql", pageSql);
return invocation.proceed();
}
3.6 配置属性
yaml
mybatis:
plugins:
pagination:
enabled: true
auto-count: true # 是否自动执行COUNT
count-suffix: "_COUNT" # COUNT查询缓存标记
db-type: auto # mysql,postgresql,auto
四、慢 SQL 监控插件深度设计
4.1 拦截点与注解
java
@Intercepts({
@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
@Signature(type = Executor.class, method = "update",
args = {MappedStatement.class, Object.class})
})
public class SlowSqlMonitorInterceptor extends AbstractMybatisInterceptor { }
4.2 SQL 归一化与指纹冲突消除
问题:Executor 拦截拿到的 SQL 可能已被 StatementHandler 级插件改写(如分页、多租户),导致同一业务查询产生多个指纹。
解决方案 :在执行指纹计算前,应用 NormalizationOptions 移除分页方言相关的子句。
java
NormalizationOptions options = NormalizationOptions.builder()
.removeLimit(true)
.removeOffset(true)
.removeOrderBy(true) // ORDER BY 中的参数可能变化,但通常是常量,可配置
.removeTenantId(true) // 移除预知的 tenant_id 条件
.build();
String fingerprint = sqlNormalizer.normalize(originalSql, options);
JsqlParserNormalizer 实现:
java
public class JsqlParserNormalizer implements SqlNormalizer {
@Override
public String normalize(String sql, NormalizationOptions options) {
try {
Statement stmt = CCJSqlParserUtil.parse(sql);
stmt.accept(new NormalizeVisitor(options));
return stmt.toString();
} catch (Exception e) {
return fallbackRegexNormalize(sql, options);
}
}
}
NormalizeVisitor 遍历 AST:
- 移除
LIMIT、OFFSET。 - 移除
ORDER BY子句。 - 若
removeTenantId为 true,移除 WHERE 中形如tenant_id = ?的条件(保留其它条件)。 - 将所有字面量(字符串、数字、十六进制)统一替换为
?,确保指纹稳定。
降级方案 :当 SQL 无法正确解析时,使用正则将 '...' 和数字替换为 ?,并移除 LIMIT \d+、OFFSET \d+。
4.3 阈值配置与注解处理
全局阈值在配置中指定,方法级别注解优先。启动时扫描所有 Mapper XML 和注解方法,构建 MappedStatement -> threshold 映射并缓存。
java
@SlowSqlThreshold(thresholdMs = 500)
List<User> findUsersByCondition(UserQuery query);
在 intercept 中获取:
java
Long methodThreshold = thresholdCache.get(ms.getId());
long threshold = methodThreshold != null ? methodThreshold : globalThreshold;
4.4 统计聚合与分位值计算
每个指纹维护一个 SqlDigestStats 实例,使用 T-Digest 算法。
java
public class SqlDigestStats {
private static final MergingDigest digest = TDigest.createMergingDigest(100);
private final AtomicLong execCount = new AtomicLong();
private final AtomicLong totalTime = new AtomicLong();
private final LongAdder maxTime = new LongAdder();
private volatile long lastMax;
public synchronized void record(long timeMs) {
digest.add(timeMs);
execCount.incrementAndGet();
totalTime.addAndGet(timeMs);
if (timeMs > lastMax) {
lastMax = timeMs;
}
}
public long getP95() { return (long) digest.quantile(0.95); }
public long getP99() { return (long) digest.quantile(0.99); }
public long getAvg() { return execCount.get() == 0 ? 0 : totalTime.get() / execCount.get(); }
// 序列化/反序列化
}
内存存储采用 ConcurrentHashMap<String, SqlDigestStats>,外部可插拔存储(如 Redis、时序库)。
4.5 事件模型与告警
java
public class SlowSqlEvent {
private String fingerprint;
private String sampleSql;
private long costMs;
private String mappedStatementId;
private long timestamp;
// ...
}
告警接口:
java
public interface AlertService {
void alert(SlowSqlEvent event);
}
默认 LogAlertService 使用 ERROR 日志输出;DingTalkAlertService 发送 Webhook 消息;支持自定义实现。
4.6 监控数据持久化策略
- 内存模式:默认,适合单节点快速启动。
- Redis 模式:共享统计,支持集群。
- InfluxDB/TimescaleDB:时序存储,支持长时间趋势分析。
通过 SqlStatsStorage 接口抽象:
java
public interface SqlStatsStorage extends Closeable {
void save(String fingerprint, SqlDigestStats stats);
Map<String, SqlDigestStats> loadAll();
void storeSlowSql(SlowSqlEvent event);
List<SlowSqlEvent> querySlowSql(long from, long to, int limit);
}
定期将内存中的统计快照同步至存储,保证重启后不丢失。
五、SQL 日志脱敏插件深度设计
5.1 设计原则
仅改写日志输出,不修改实际参数。避免对数据正确性产生任何影响。
5.2 敏感字段识别
通过正则表达式匹配 ParameterMapping 的 property 名称:
java
private final Pattern sensitivePattern = Pattern.compile(
".*(password|pwd|secret|mobile|phone|idcard|id_card|email|addr|ssn).*",
Pattern.CASE_INSENSITIVE);
配置增加 sensitive-keywords 属性,支持动态扩展。
5.3 参数对象遍历与掩码
拦截 ParameterHandler.setParameters,在执行后通过 MetaObject 访问参数值,递归处理 Map、Bean。
java
private String formatParamValue(String property, Object value) {
if (value == null) return "null";
if (sensitivePattern.matcher(property).matches()) {
return SensitiveMask.mask(property, value.toString());
}
return value.toString();
}
SensitiveMask 根据字段名自动选择掩码算法:
mobile/phone:保留前3后4,138****8888。idCard:保留前6后4,320123********1234。password/pwd/secret:替换为******。- 其他:默认保留首尾各1/3。
5.4 输出格式与开关
在日志中输出形如:
vbnet
Preparing: SELECT * FROM user WHERE mobile=? AND password=?
Parameters: mobile=138****8888(String), password=******(String)
通过 Logger.isDebugEnabled() 控制脱敏计算的执行,避免高负载下性能损耗。
5.5 与 TypeHandler 的关系
若业务需要入库脱敏(如数据库中存储加密或遮盖数据),应在 TypeHandler 中实现,不在本插件职责范围。文档中明确区分。
六、多租户数据隔离插件深度设计
6.1 核心流程
- 从
TenantContext获取当前租户 ID。 - 检查 Mapper 方法是否有
@IgnoreTenant注解,有则跳过。 - 使用 JSqlParser 解析 SQL,对主查询和子查询中添加
AND tenant_id = ?。 - 在
BoundSql中动态追加参数映射,并设置实际值。 - 确保改写不会破坏原有参数绑定。
6.2 复杂 SQL 的改写规则
- Simple SELECT :
SELECT * FROM t WHERE a=?→SELECT * FROM t WHERE a=? AND tenant_id = ? - JOIN:若 JOIN 存在主从表,仅对主表过滤可能有性能问题,改为对每个实际表加上条件或统一在 WHERE 后添加。经评估选择直接在 WHERE 末尾追加,由数据库优化器处理。
- UNION:每个 SELECT 都需要单独追加。
- WITH 子句:CTE 内容若涉及业务表也需追加。
- INSERT/UPDATE/DELETE:同样支持,但仅对 FROM/WHERE 部分重写。
- DDL:默认不处理,避免破坏结构修改。
通过 TenantSqlRewriter 实现,内部使用 Visitor 模式:
java
public class TenantSqlRewriter extends StatementVisitorAdapter {
private final String tenantColumn;
private final String tenantIdParamName; // 命名占位符,实际用 ? 表示
@Override
public void visit(PlainSelect plainSelect) {
Expression where = plainSelect.getWhere();
EqualsTo tenantCondition = new EqualsTo(new Column(tenantColumn), new JdbcNamedParameter(tenantIdParamName));
if (where == null) {
plainSelect.setWhere(tenantCondition);
} else {
plainSelect.setWhere(new AndExpression(where, tenantCondition));
}
// 递归处理子查询
if (plainSelect.getFromItem() instanceof SubSelect) {
((SubSelect) plainSelect.getFromItem()).getSelectBody().accept(this);
}
// ...
}
}
最终生成的 SQL 为 ... AND tenant_id = ?,实际参数通过追加的 ParameterMapping 绑定。
6.3 参数映射动态追加实现
由于 MyBatis 的 BoundSql.parameterMappings 可能是不可变列表,需要用自定义子类 TenantBoundSql 包裹原 BoundSql,在内部持有额外的映射列表,并在 getParameterMappings() 时合并返回。
java
public class TenantBoundSql extends BoundSql {
private final BoundSql delegate;
private final List<ParameterMapping> extraMappings;
@Override
public List<ParameterMapping> getParameterMappings() {
List<ParameterMapping> merged = new ArrayList<>(delegate.getParameterMappings());
merged.addAll(extraMappings);
return merged;
}
@Override
public Object getParameterObject() { return delegate.getParameterObject(); }
// 其他方法代理...
}
然后通过反射,将 StatementHandler 内部的 delegate.boundSql 替换为 TenantBoundSql 实例,并设置 additionalParameters 中的租户 ID。
6.4 忽略租户的注解实现
java
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface IgnoreTenant { }
启动时扫描所有 Mapper 接口,建立 MapperMethod -> boolean 的缓存。在拦截器中快速判断。
6.5 配置与数据库自动识别
yaml
mybatis:
plugins:
tenant:
enabled: true
tenant-id-column: tenant_id
ignore-tables: t_dict,t_config # 全局表忽略
fail-on-no-tenant: false # 无租户ID时是否抛异常
七、插件链级联效应验证
7.1 级联效应分析的必要性
- SQL 改写顺序影响正确性:分页插件应在多租户插件之前,否则 COUNT 查询可能缺失租户条件。
- 监控耗时归属:慢 SQL 统计的是 Executor 层耗时,包含了所有 StatementHandler 和 ParameterHandler 的处理时间。开发人员需明确插件链带来的额外开销。
- 性能评估:多个插件同时拦截同一接口形成多层代理,增加反射调用开销。
7.2 性能追踪器实现
在每个插件的 intercept 中埋点:
java
long start = System.nanoTime();
try {
InterceptorChainTracker.push(pluginName());
return invocation.proceed();
} finally {
long cost = System.nanoTime() - start;
InterceptorChainTracker.pop(pluginName(), cost);
}
InterceptorChainTracker 内部结构:
java
public class InterceptorChainTracker {
private static final ThreadLocal<Deque<TraceFrame>> stack = ThreadLocal.withInitial(ArrayDeque::new);
private static final ConcurrentMap<String, List<TraceNode>> reportMap = new ConcurrentHashMap<>();
public static void push(String plugin) {
stack.get().push(new TraceFrame(plugin, System.nanoTime()));
}
public static void pop(String plugin, long totalCost) {
TraceFrame frame = stack.get().pop();
long exclusiveCost = totalCost - frame.childrenCost.get();
// 将节点加入当前SQL的追踪树
// ...
}
}
通过记录每个插件的独占耗时,生成与 SQL 指纹关联的调用树。
7.3 报告生成
java
ChainAnalysisReport report = ChainAnalysisReport.build();
System.out.println(report.renderText());
报告包含:
- 插件注册顺序表。
- 热点 SQL 的调用路径及各插件独占耗时。
- 不同插件顺序下的耗时对比(通过动态调整顺序测试)。
示例输出:
ini
[插件链级联效应分析]
SQL 指纹: SELECT * FROM user WHERE name = ?
插件顺序: SlowSqlMonitor(Executor) -> Pagination(StmtHandler) -> Tenant(StmtHandler)
- SlowSqlMonitor: total=32.1ms (exclusive=0.5ms)
- Pagination: exclusive=1.2ms (count改写耗时)
- Tenant: exclusive=0.8ms
- JDBC: 29.6ms
深度3层代理,额外反射成本约0.2ms (占总耗时0.6%)
7.4 级联影响最佳实践
- Executor 层拦截器始终在最外层,以获得完整耗时。
- StatementHandler 改写类插件顺序:分页 -> 多租户。
- ParameterHandler 脱敏插件独立,不影响其他层。
- 在性能敏感场景下,建议合并部分改写逻辑减少代理层数。
八、自动装配与顺序管理详解
8.1 Starter 模块结构
markdown
mybatis-plugins-spring-boot-starter
├── MybatisPluginsAutoConfiguration
├── properties
│ ├── PaginationProperties
│ ├── SlowSqlProperties
│ ├── DesensitizeProperties
│ └── TenantProperties
├── ordering
│ └── InterceptorOrderConfigurer
└── sqlProvider
└── SqlNormalizerProvider (从IOC容器中获取)
8.2 自动配置类
java
@Configuration
@EnableConfigurationProperties(MybatisPluginsProperties.class)
@AutoConfigureAfter(MybatisAutoConfiguration.class)
public class MybatisPluginsAutoConfiguration implements ApplicationContextAware {
@Autowired(required = false)
private List<SqlSessionFactory> sqlSessionFactories;
@PostConstruct
public void addInterceptorsToMybatis() {
List<Interceptor> plugins = collectPlugins();
plugins.sort(new PluginLayerComparator()); // 排序策略见下
for (SqlSessionFactory factory : sqlSessionFactories) {
Configuration config = factory.getConfiguration();
for (Interceptor plugin : plugins) {
config.addInterceptor(plugin);
}
}
}
private List<Interceptor> collectPlugins() {
// 根据配置创建各插件实例并返回
}
}
8.3 排序策略
PluginLayerComparator 先按拦截接口层级排序:
- Executor 层
- StatementHandler 层
- ParameterHandler 层
同层级内部按 order 属性排序,分页默认 order=10,多租户 order=20。用户可通过配置覆盖。
8.4 与 MyBatis 原生 InterceptorChain 交互
MyBatis 使用 InterceptorChain.pluginAll 包装对象,包装顺序与 interceptors 列表顺序相反。我们通过比较器保证 interceptors 列表的顺序正好对应我们期望的执行顺序,并在文档中注明包装从后往前,但最终执行顺序由列表顺序决定(先添加先拦截)。
九、监控端点与 Web 控制台
9.1 端点 /actuator/sql-stats
java
@Endpoint(id = "sql-stats")
public class SqlStatsEndpoint {
@ReadOperation
public SqlStatsResponse stats(@Selector String fingerprintFilter) {
// 返回所有统计或按指纹过滤
}
@WriteOperation
public void reset() { collector.reset(); }
}
返回数据结构:
json
{
"sqlStats": [
{
"fingerprint": "SELECT * FROM orders WHERE user_id = ?",
"count": 1200,
"avgMs": 45.2,
"maxMs": 230,
"p95Ms": 78,
"p99Ms": 150,
"slowCount": 12,
"latestSample": "2026-05-08T14:23:01"
}
],
"pluginChain": [
{"name": "SlowSqlMonitorInterceptor", "layer": "EXECUTOR"},
{"name": "PaginationInterceptor", "layer": "STATEMENT"},
{"name": "TenantPlugin", "layer": "STATEMENT"}
]
}
9.2 Web 控制台
使用 Spring Boot MVC + Thymeleaf,页面包含:
- 统计总览卡片(总执行次数、平均耗时、慢查询数量)。
- SQL 指纹排行表格,支持排序。
- 慢 SQL 历史列表。
- 插件链可视化(展示当前加载的拦截器及顺序)。
- ECharts 动态图表展示耗时趋势(基于内存中的最近30分钟采样)。
数据通过 Ajax 定时轮询端点获取。
9.3 存储与历史数据
SqlStatsCollector 持有 SqlStatsStorage 实例,定期将内存统计快照写入存储。查询历史功能委托存储实现。
十、边界场景与反模式防范
10.1 边界场景
| 场景 | 处理方式 |
|---|---|
| 批处理操作 | 分页和多租户插件跳过 BatchExecutor 相关的改写 |
| 存储过程调用 | 通过 MappedStatement 的 SqlCommandType 识别,跳过改写 |
| UNION 查询 | 多租户改写时对每个 SELECT 添加条件;分页仅在最外层添加 LIMIT |
| 子查询 | 多租户对内层查询也添加条件,避免数据泄露 |
| 表别名 | AST 改写时正确处理别名引用 |
| 数据源切换 | TenantContext 传播需配合 AbstractRoutingDataSource |
| 嵌套代理 | 插件链需保证 proceed() 仅调用一次,否则导致重复 SQL 执行 |
| SQL 解析异常 | 降级策略:输出告警但不影响业务,原 SQL 直接执行 |
10.2 反模式清单
- ❌ 在插件中发起新的 SqlSession 调用:可能导致循环代理,引发 StackOverflow。
- ❌ 直接字符串拼接 WHERE 条件:必须通过 AST 或使用参数化改写。
- ❌ 滞留 ThreadLocal:TenantContext 必须在 finally 块中清除。
- ❌ 忽略 Count SQL 的参数传递:必须复用原 BoundSql 的 ParameterMappings。
- ❌ 过早计算指纹:必须在去除环境特定子句(分页、租户)后再计算。
十一、测试策略
11.1 单元测试
- 使用 Mockito 模拟
Invocation,验证各插件拦截逻辑。 - 使用 H2 内存数据库验证方言检测与 SQL 改写正确性。
- 脱敏插件测试各种参数类型(基本类型、Map、Bean)的掩码效果。
11.2 集成测试
java
@SpringBootTest
@AutoConfigureTestDatabase(replace = Replace.ANY)
class MybatisPluginsIntegrationTest {
@Autowired private UserMapper userMapper;
@Test
void testPaginationAndTenant() {
TenantContext.setCurrentTenant("t1");
PageRequest page = PageRequest.of(1, 5);
PageContext.setPageRequest(page);
List<User> users = userMapper.findAll();
assertThat(users).hasSize(5);
// 验证SQL日志中出现了 tenant_id 和脱敏的 mobile
}
@Test
void testSlowSqlThresholdAnnotation() {
// 查询方法带有 @SlowSqlThreshold(100)
// 执行耗时小于100ms,不应产生慢查询事件
}
}
11.3 性能基准测试
使用 JMH 测试:
- 无插件 vs 单个插件 vs 4个插件叠加时的 TPS 差异。
- 模拟高并发场景下 T-Digest 和跟踪器的开销。
11.4 级联顺序交换测试
参数化测试注入不同顺序的拦截器列表,执行相同 SQL,断言最终 SQL 结构正确,并输出性能追踪报告。
总结: 本项目设计了一个生产级 MyBatis 通用插件库,提供分页、慢 SQL 指纹监控、日志脱敏、多租户隔离四大功能,并配套性能监控 Web 控制台。通过 AST 精准改写、级联效应量化与统计聚合,解决 SQL 注入、指纹冲突等深度问题