MyBatis 通用插件库与性能监控平台

一、项目背景与技术目标

1.1 业务痛点

  • 分页逻辑侵入业务代码:每个查询都需要手工编写 LIMIT 与 OFFSET 拼接,以及对 COUNT 查询的手动实现,导致代码重复、易出错。
  • 慢 SQL 发现滞后:缺少基于指纹的聚合统计,相同的 SQL 模板因参数不同被淹没在海量日志中;告警阈值一刀切,无法针对特定方法微调。
  • 日志泄露敏感数据:应用日志直接输出 SQL 和参数,手机号、身份证、密码等明文存储,违反数据安全合规要求。
  • 多租户隔离实现复杂:业务代码中手写 tenant_id 过滤条件,不仅污染业务逻辑,还容易因遗漏导致跨租户数据泄露。
  • 多插件共存时的行为不确定性:多个插件同时注册,SQL 改写顺序、监控耗时归属、代理嵌套影响等问题缺乏量化分析,生产调优缺少依据。

1.2 设计目标

  1. 插件职责单一、低耦合:每个插件只处理一个横切关注点,可独立启用或组合。
  2. SQL 改写基于 AST,安全可靠:杜绝字符串拼接可能引入的 SQL 注入风险,精准处理各种 SQL 结构。
  3. 性能可观测、可量化:提供毫秒级粒度的执行耗时分解,精确计算插件级联带来的额外开销。
  4. 生产级监控体系:基于指纹的聚合统计、分位值展示、告警通知,与现有监控系统无缝对接。
  5. 兼容性与扩展性:支持多种数据库方言,允许用户自定义方言、脱敏规则、告警通道。

二、整体架构

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 BYLIMIT 等非必要子句,保留 FROMWHERE 及参数位置不变。

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); // 兜底
    }
}

关键点BoundSqlparameterMappings 列表与原 SQL 的 ? 占位符一一对应,改写 COUNT SQL 后必须确保占位符数量及顺序不变,因此直接复用原 BoundSql 对象的 parameterMappingsadditionalParameters 生成新的 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:

  • 移除 LIMITOFFSET
  • 移除 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 敏感字段识别

通过正则表达式匹配 ParameterMappingproperty 名称:

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 核心流程

  1. TenantContext 获取当前租户 ID。
  2. 检查 Mapper 方法是否有 @IgnoreTenant 注解,有则跳过。
  3. 使用 JSqlParser 解析 SQL,对主查询和子查询中添加 AND tenant_id = ?
  4. BoundSql 中动态追加参数映射,并设置实际值。
  5. 确保改写不会破坏原有参数绑定。

6.2 复杂 SQL 的改写规则

  • Simple SELECTSELECT * 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 级联影响最佳实践

  1. Executor 层拦截器始终在最外层,以获得完整耗时。
  2. StatementHandler 改写类插件顺序:分页 -> 多租户
  3. ParameterHandler 脱敏插件独立,不影响其他层
  4. 在性能敏感场景下,建议合并部分改写逻辑减少代理层数。

八、自动装配与顺序管理详解

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 先按拦截接口层级排序:

  1. Executor 层
  2. StatementHandler 层
  3. 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 注入、指纹冲突等深度问题

相关推荐
敖正炀2 小时前
手写简易 MyBatis 框架(mini-mybatis)—— 完善版架构设计与核心实现
后端·mybatis
敖正炀2 小时前
反模式与排查宝典:MyBatis 常见陷阱与排错指南
mybatis
_Evan_Yao4 小时前
return 的迷途:try-catch-finally 中 return 的诡异顺序与 Spring 事务暗坑
java·后端·spring·mybatis
Java成神之路-1 天前
MyBatis工作原理
mybatis
敖正炀2 天前
MyBatis 性能调优:批处理、流式查询与 SQL 优化
mybatis
敖正炀2 天前
初始化流程的完整串联:从 XML 到 SqlSessionFactory
mybatis
2301_771717212 天前
Spring Boot 自动配置核心注解
java·spring boot·mybatis
MegaDataFlowers2 天前
使用MyBatisX快速生成CRUD
mybatis
敖正炀2 天前
插件开发与拦截链——分页、脱敏、多租户实战
mybatis