插件开发与拦截链——分页、脱敏、多租户实战

概述

衔接前文

在前文《MyBatis 架构全解》中,我们已经分析了 InterceptorChain.pluginAll 如何利用 JDK 动态代理将多个拦截器织入责任链,按序包裹 ExecutorStatementHandlerParameterHandlerResultSetHandler 四大核心对象。拦截链的门槛并不在于理解代理机制本身,而在于:如何依据业务需求精确选择拦截点?如何安全地改写即将执行的 SQL ?如何保证多个横切关注点在责任链中协调运作?本文将以三个高价值实战场景------分页、数据脱敏、多租户数据隔离------作为切入点,完整展示如何将 MyBatis 拦截链打造为企业级数据中间件的可靠通道。

总结性引言

MyBatis 的插件系统是它可扩展性的基石。与 Spring AOP 的 Bean 级横切不同,MyBatis 插件直接在数据库交互的最细粒度(Statement 准备、参数设置、结果集封装)施加影响。通过实现 Interceptor 接口并配合 @Intercepts 注解,开发者可以透明地改写 SQL、篡改参数、过滤结果集,而无需侵入任何业务代码。本文将深入分页方言的动态拼接、脱敏策略的模式封装、租户标识的 SQL 注入,揭示如何利用责任链模式"在最后一公里"干净利落地解决横切关注点,同时沉淀出一套可复用的拦截链开发方法论。

核心要点

  • 拦截点决策ExecutorStatementHandlerResultSetHandler 拦截时机与应用场景的全景决策树。
  • 分页插件 :在 StatementHandler.prepare 中利用 MetaObject 改写 BoundSql,动态适配多数据库方言,并处理 count 查询与缓存键隔离。
  • 脱敏插件 :通过 @Sensitive 注解与 MaskStrategy 策略接口,在 ResultSetHandler.handleResultSets 后自动对敏感字段进行脱敏。
  • 多租户插件 :基于 ThreadLocal 传递租户上下文,在 StatementHandler.prepare 阶段智能拼接 tenant_id 条件,防止全表泄露。
  • 拦截链管理:慢 SQL 监控、多拦截器顺序控制、异常传播与回滚策略。
  • 模式对比:MyBatis 拦截链与 Spring AOP 责任链在粒度、实现、适用场景上的本质区别。

文章组织架构图

flowchart TB n1["1. 拦截链回顾与拦截点选择策略"] n2["2. 分页插件实战: SQL重写与方言自动适配"] n3["3. 数据脱敏插件实战: 注解驱动与策略模式"] n4["4. 多租户数据隔离插件实战: SQL注入租户条件"] n5["5. 拦截链监控、顺序与异常处理"] n6["6. MyBatis 拦截链与 Spring AOP 的对比"] n7["7. 生产事故排查专题"] n8["8. 面试高频专题"] n1 --> n2 n1 --> n3 n1 --> n4 n2 --> n5 n3 --> n5 n4 --> n5 n5 --> n6 n6 --> n7 n7 --> n8 classDef topic fill:#f8f9fa,stroke:#333,stroke-width:2px,rx:5,color:#333; class n1,n2,n3,n4,n5,n6,n7,n8 topic;

架构图说明

  • 总览说明:本文8个模块以责任链的拦截点选择为起点,依次实现三个经典插件,再升级到拦截链的运营管理、与 Spring 体系的横向对比,最后以生产事故复盘与面试高频题收尾,形成"理论回顾→实战落地→可靠性提升→对比认知→避坑验证"的完整闭环。
  • 逐模块说明
    • 模块1 是决策基础,帮助开发者根据需求选择最合适的拦截对象。
    • 模块2-4 是核心战例,分别演示如何在 SQL 重写、结果过滤、条件注入三个维度运用拦截链。
    • 模块5 将拦截链从"能跑"提升到"可靠",覆盖监控与异常处理。
    • 模块6 在两个生态间做责任链模式对比,加深架构理解。
    • 模块7-8 落地排查与面试,保证知识的实战转化。
  • 关键结论MyBatis 插件是责任链模式的绝佳战场,掌握其对核心对象的拦截时机与改写技巧,是构建企业级数据中间件的必备技能。

1. 拦截链回顾与拦截点选择策略

1.1 责任链结构速览

InterceptorChain.pluginAll 遍历所有注册的拦截器,依次调用 Plugin.wrap(target, interceptor),利用 JDK 动态代理为目标对象生成代理。若目标对象实现了多个接口(如 Executor 同时实现了 Executor 及其父接口),则 Plugin 会为所有接口创建代理,拦截所有匹配的方法。该过程本质是一个静态责任链 :多个拦截器层层包裹同一个真实对象,形成一个"洋葱"结构;调用时从最外层代理进入,沿 interceptor.intercept(Invocation) 链式传递,最终抵达真实对象。

关键类与核心代码重温

  • org.apache.ibatis.plugin.Plugin.wrap(Object target, Interceptor interceptor) :根据 @Intercepts 注解判断是否包装目标,使用 Proxy.newProxyInstance 创建代理。
  • org.apache.ibatis.plugin.InterceptorChain.pluginAll(Object target) :循环调用 plugin.wrap,逐层叠加。
java 复制代码
// org.apache.ibatis.plugin.InterceptorChain
public Object pluginAll(Object target) {
  for (Interceptor interceptor : interceptors) {
    target = Plugin.wrap(target, interceptor);
  }
  return target;
}

该循环产生嵌套代理:proxy_n( ... proxy_2( proxy_1( target ) ) ),从而构成责任链。

责任链模式体现 :每个 Interceptorintercept 方法中可通过 invocation.proceed() 显式调用下一个拦截器或目标方法,形成完整的链式处理逻辑。与经典责任链不同,MyBatis 的链节点由动态代理自动串联,开发者只需关注业务逻辑,无须手动组装 next 指针。

1.2 拦截点决策表

MyBatis 可拦截的四大对象及其关键方法如下:

拦截对象 方法(常用) 参数特征 典型应用
Executor query(MappedStatement, Object, RowBounds, ResultHandler, CacheKey, BoundSql) 包含完整的执行上下文、分页参数 RowBounds 慢 SQL 监控、分页拦截(早期思路)、缓存键定制
Executor update(MappedStatement, Object) 更新操作,可获取 SQL 命令类型 租户条件注入(update 场景)、审计日志
StatementHandler prepare(Connection, Integer) 此时 BoundSql 已生成,但尚未创建 Statement 分页方言改写、租户条件注入(改写 SQL)
StatementHandler parameterize(Statement) 参数预编译之后,可获取 ParameterHandler 参数加密/掩码
ResultSetHandler handleResultSets(Statement) 结果集已从数据库返回,尚未映射为 Java 对象 数据脱敏、结果过滤
ParameterHandler setParameters(PreparedStatement) 参数设置阶段 参数脱敏(入库前)

1.3 拦截点选择原则

  • 最小影响原则 :优先选择离目标横切逻辑最近的拦截点。例如分页方言改写应在 SQL 构建完成后、预编译之前,因此 StatementHandler.prepare 是理想切入点;若选 Executor.query,则需额外解析 MappedStatement,且可能干扰一、二级缓存键。
  • 避免拦截过广 :仅对匹配的方法签名进行拦截,并在 intercept 中快速过滤无关操作(如只处理 SELECT 语句)。
  • 性能考量:拦截器处于热路径上,应避免在拦截方法内执行重操作(如同步远程调用、复杂正则解析)。慢 SQL 监控应使用异步或轻量计时。
  • 副作用隔离 :改写 BoundSql 时需保留原始 SQL,便于日志审计;为结果对象脱敏时建议生成新对象或就地修改,避免影响缓存中的原值。

2. 分页插件实战:SQL 重写与方言自动适配

2.1 拦截点选择与设计思路

最常用的分页插件(如 PageHelper)早期曾拦截 Executor.query,通过改写 BoundSql 来实现。但该方式需要处理 RowBounds 参数与 CacheKey 的联动,且 SQL 改写时机偏晚。更优雅的做法是拦截 StatementHandler.prepare,此时 BoundSql 已生成且尚未传到 PreparedStatement,我们可以安全地修改其 SQL 字符串和参数映射,实现物理分页。

核心流程

  1. ThreadLocal 获取分页参数(pageNumpageSize)。
  2. prepare 拦截中,通过 MetaObject 取出 delegate.boundSql
  3. 依据数据库方言,将原始 SQL 包装成分页 SQL(如 MySQL LIMIT offset, size)。
  4. 重新设置分页参数,剔除已消费的分页上下文,避免污染后续查询。
  5. 可选:在 Executor.query 拦截点生成 count 查询,以支持总条数返回。

2.2 分页插件实现:拦截 StatementHandler.prepare

序列图:分页插件改写 BoundSql

sequenceDiagram participant Business as 业务代码 participant SqlSession as SqlSession participant Executor as Executor participant SH as StatementHandler participant PagePlugin as PageInterceptor participant DB as 数据库 Business->>SqlSession: selectList("findUser", params) SqlSession->>Executor: query(ms, params, RowBounds.DEFAULT) Executor->>SH: prepare(connection, txTimeout) Note over SH: 原始BoundSql已生成
包含SQL: SELECT * FROM user SH-->>PagePlugin: 拦截prepare方法 PagePlugin->>PagePlugin: 从ThreadLocal获取
pageNum=2, pageSize=10 PagePlugin->>PagePlugin: 通过MetaObject获取BoundSql PagePlugin->>PagePlugin: 根据方言生成分页SQL
SELECT * FROM user LIMIT 10,10 PagePlugin->>SH: 将改写后的SQL设置回BoundSql SH->>DB: 执行预处理与查询 DB-->>Business: 返回分页结果

图表主旨概括 :展示分页插件如何在 StatementHandler.prepare 阶段截获 SQL,利用 MetaObject 动态改写为分页语句。 逐层/逐元素分解 :业务调用的正常路径为 SqlSession -> Executor -> StatementHandler.prepare;动态代理在 StatementHandler 外包裹了 PageInterceptor,拦截 prepare 调用。插件从 ThreadLocal 获取分页上下文,通过 MyBatis 的 MetaObject 反射机制修改 BoundSql.getSql 返回的 SQL 字符串。 设计原理映射 :这里使用代理模式 织入横切逻辑,策略模式 (方言适配)封装不同数据库的分页语法。ThreadLocal 用于解决参数传递的跨层问题,保证线程安全。 工程联系与关键结论分页插件的本质是在 SQL 执行"最后一公里"对语句进行无侵入改写,拦截 StatementHandler.prepare 可以获得未预编译的纯净 SQL,避免了缓存键和参数映射的复杂处理,是生产级插件的最佳实践。

自行实现的 PageInterceptor 代码

java 复制代码
// com.deepspring.mybatis.plugin.PageInterceptor
@Intercepts({
    @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class PageInterceptor implements Interceptor {

    // 分页参数上下文
    private static final ThreadLocal<PageInfo> PAGE_HOLDER = new ThreadLocal<>();

    // 方言策略映射
    private final Map<String, Dialect> dialectMap = new HashMap<>();

    public PageInterceptor() {
        dialectMap.put("MySQL", new MySqlDialect());
        dialectMap.put("PostgreSQL", new PostgreSqlDialect());
        // 可扩展 OracleDialect 等
    }

    /**
     * 业务侧调用此方法设置分页参数
     */
    public static void startPage(int pageNum, int pageSize) {
        PAGE_HOLDER.set(new PageInfo(pageNum, pageSize));
    }

    public static void clearPage() {
        PAGE_HOLDER.remove();
    }

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        PageInfo pageInfo = PAGE_HOLDER.get();
        if (pageInfo == null) {
            return invocation.proceed(); // 未设置分页,直接通过
        }

        try {
            StatementHandler handler = (StatementHandler) invocation.getTarget();
            MetaObject meta = MetaObject.forObject(handler,
                    SystemMetaObject.DEFAULT_OBJECT_FACTORY,
                    SystemMetaObject.DEFAULT_OBJECT_WRAPPER_FACTORY,
                    new DefaultReflectorFactory());
            // 获取原始 BoundSql
            BoundSql boundSql = (BoundSql) meta.getValue("delegate.boundSql");
            String originalSql = boundSql.getSql();
            // 根据数据库方言生成分页SQL
            String dialectKey = getDialectKey(handler);
            Dialect dialect = dialectMap.getOrDefault(dialectKey, new MySqlDialect());
            String pageSql = dialect.getPageSql(originalSql, pageInfo.getOffset(), pageInfo.getPageSize());
            // 设置改写后的SQL
            meta.setValue("delegate.boundSql.sql", pageSql);
            return invocation.proceed();
        } finally {
            clearPage(); // 务必清除,防止污染
        }
    }

    private String getDialectKey(StatementHandler handler) throws Throwable {
        // 简化:从数据源或配置中识别数据库类型,这里硬编码为 MySQL
        return "MySQL";
    }
}

设计解读

  • @Intercepts 明确拦截 StatementHandler.prepare
  • 参数通过 startPage 放入 ThreadLocal,确保同一线程内后续查询自动分页。
  • MetaObject 是 MyBatis 的"万能斧",通过 delegate.boundSql.sql 路径直达内部属性并修改,避免强耦合 RoutingStatementHandler 结构。
  • finally 块中 clearPage() 防止分页参数残留导致其他查询异常。
  • Dialect 接口封装方言差异,符合策略模式,新增数据库时仅需添加实现类即可。

Dialect 接口与 MySQL 实现

java 复制代码
// com.deepspring.mybatis.plugin.dialect.Dialect
public interface Dialect {
    String getPageSql(String sql, int offset, int limit);
}

// com.deepspring.mybatis.plugin.dialect.MySqlDialect
public class MySqlDialect implements Dialect {
    @Override
    public String getPageSql(String sql, int offset, int limit) {
        return sql + " LIMIT " + offset + ", " + limit;
    }
}

// com.deepspring.mybatis.plugin.dialect.PostgreSqlDialect
public class PostgreSqlDialect implements Dialect {
    @Override
    public String getPageSql(String sql, int offset, int limit) {
        return sql + " LIMIT " + limit + " OFFSET " + offset;
    }
}

2.3 count 查询的实现

为支持分页总数返回,可再增加一个拦截 Executor.query 的拦截器,在执行前判断是否携带分页标记,动态构造 SELECT COUNT(*) FROM (原始SQL) _count 查询。

java 复制代码
@Intercepts({
    @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class CountInterceptor implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // 判断是否需要执行 count,通过 ThreadLocal 标记
        if (PageThreadLocal.needCount()) {
            // 从 MappedStatement 获取 BoundSql 并改写为 count SQL,单独执行
            // 将 count 结果存入 PageThreadLocal,并阻止原查询重复执行
        }
        return invocation.proceed();
    }
}

实际应用时,CountInterceptor 可与 PageInterceptor 配合,通过 ThreadLocal 传递 total 结果。关键点 :count 查询必须独立于原始查询,不能影响原始 CacheKey,否则缓存将错乱。

2.4 与缓存的冲突及解决

MyBatis 的二级缓存以 CacheKey 作为查询结果标识。CacheKeyMappedStatement.idRowBounds 偏移量、BoundSql 及参数等组合生成。若分页参数未纳入 CacheKey,同一 SQL 的不同页码可能返回相同缓存结果,造成数据错乱。 解决方案

  • 在分页插件改写 SQL 后,BoundSql.getSql 已含有分页方言片段(如 LIMIT 10,10),该片段作为 CacheKey 的一部分自然隔离不同分页结果。但若插件仅通过修改 RowBounds(而不改写物理 SQL)进行逻辑分页,则必须自定义 CacheKey
  • 自行实现的 PageInterceptor 直接改写了 SQL,因此能够天然区分 CacheKey,无需额外处理。但需注意 count 查询应避免生成 CacheKey 或使用独立的缓存区域。

3. 数据脱敏插件实战:注解驱动与策略模式

3.1 需求与方案

业务中常需要对手机号、身份证、邮箱等敏感数据在返回前端前自动脱敏。MyBatis 插件可以在结果映射之后、返回调用者之前完成此任务。拦截点选 ResultSetHandler.handleResultSets,此时数据库原始值已经封装到 Java 对象中,插件遍历对象的字段,识别 @Sensitive 注解后调用相应脱敏策略,修改字段值。

核心序列图:脱敏插件处理结果集

sequenceDiagram participant Executor participant RSH as ResultSetHandler(代理) participant MaskPlugin as SensitiveInterceptor participant OriginalRSH as 真实ResultSetHandler participant DB as 数据库结果集 Executor->>RSH: handleResultSets(statement) RSH->>MaskPlugin: inv.intercept() MaskPlugin->>MaskPlugin: invocation.proceed() 先执行原方法 Note over OriginalRSH: 返回 List
字段含明文手机号 MaskPlugin->>MaskPlugin: 遍历返回集合的每个对象
用反射找到 @Sensitive 字段 MaskPlugin->>MaskPlugin: 根据注解的 MaskType 选择策略
phoneMask: 138****1234 MaskPlugin->>MaskPlugin: 调用策略的 mask 方法,写入新值 MaskPlugin-->>Executor: 返回脱敏后的结果列表

图表主旨概括 :展示脱敏插件在结果集已映射为对象后介入,通过注解和策略模式对敏感字段进行值替换。 逐层/逐元素分解RealResultSetHandler 完成数据库记录到 Java 对象的映射,返回 List。代理对象 MaskPlugininvoke 中先调用 proceed 拿到真实结果,再反射处理每个元素的字段。脱敏逻辑通过 MaskStrategy 策略接口实现,Map<MaskType, MaskStrategy> 完成枚举到策略的映射。 设计原理映射策略模式 解耦脱敏规则与字段识别;注解 提供声明式的字段标记;代理模式 在结果返回前插入后置处理逻辑。 工程联系与关键结论数据脱敏插件利用 ResultSetHandler 的拦截时机,将安全合规需求下沉到数据库交互底层,无需在 Service 或 Controller 层逐一手动掩码,极大降低了研发成本和遗漏风险。

3.2 脱敏注解与策略实现

java 复制代码
// com.deepspring.mybatis.plugin.sensitive.Sensitive
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Sensitive {
    MaskType value();
}

// com.deepspring.mybatis.plugin.sensitive.MaskType
public enum MaskType {
    PHONE, EMAIL, ID_CARD
}

// com.deepspring.mybatis.plugin.sensitive.MaskStrategy
public interface MaskStrategy {
    String mask(String original);
}

// com.deepspring.mybatis.plugin.sensitive.PhoneMaskStrategy
public class PhoneMaskStrategy implements MaskStrategy {
    @Override
    public String mask(String original) {
        if (original == null || original.length() < 7) return original;
        return original.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2");
    }
}
// 可继续实现 EmailMaskStrategy、IdCardMaskStrategy 等

3.3 脱敏拦截器实现

java 复制代码
// com.deepspring.mybatis.plugin.SensitiveInterceptor
@Intercepts({
    @Signature(type = ResultSetHandler.class, method = "handleResultSets", args = {Statement.class})
})
public class SensitiveInterceptor implements Interceptor {

    private final Map<MaskType, MaskStrategy> strategies = new HashMap<>();

    public SensitiveInterceptor() {
        strategies.put(MaskType.PHONE, new PhoneMaskStrategy());
        strategies.put(MaskType.EMAIL, new EmailMaskStrategy());
        strategies.put(MaskType.ID_CARD, new IdCardMaskStrategy());
    }

    @SuppressWarnings("unchecked")
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Object result = invocation.proceed(); // 先执行原方法获取结果集
        if (result instanceof List) {
            List<Object> list = (List<Object>) result;
            for (Object obj : list) {
                maskObject(obj);
            }
        } else if (result != null) {
            maskObject(result);
        }
        return result;
    }

    private void maskObject(Object obj) throws IllegalAccessException {
        Class<?> clazz = obj.getClass();
        for (Field field : clazz.getDeclaredFields()) {
            Sensitive sensitive = field.getAnnotation(Sensitive.class);
            if (sensitive != null) {
                field.setAccessible(true);
                Object originalValue = field.get(obj);
                if (originalValue instanceof String) {
                    MaskStrategy strategy = strategies.get(sensitive.value());
                    if (strategy != null) {
                        field.set(obj, strategy.mask((String) originalValue));
                    }
                }
            }
        }
    }
}

设计解析

  • 拦截handleResultSets,获取返回的 List 或单对象后,遍历字段并应用脱敏。
  • strategies 映射在构造时初始化,遵循策略模式,便于扩展新的掩码类型。
  • 脱敏发生在对象返回给调用者之前,数据库内存储仍是明文,不影响查询和写入。
  • 注意这里直接修改了原对象字段值,若对象被放入二级缓存,缓存中的值也变为脱敏后的值。一般建议脱敏插件独立于缓存或确保缓存内为明文、在序列化时脱敏;但本例体现了插件灵活度的一种取舍。

4. 多租户数据隔离插件实战:SQL 注入租户条件

4.1 租户架构与上下文传递

在多租户系统的共享表模式下,所有租户数据存在同一张表,通过 tenant_id 列隔离。业务代码不希望每一句 SQL 都手动拼接 WHERE tenant_id = ?,因此可通过 MyBatis 插件在 SQL 执行前自动添加该条件。 租户 ID 通过 ThreadLocal 从上游请求(如 Spring Security 过滤器)传递至拦截器。

java 复制代码
// com.deepspring.mybatis.plugin.tenant.TenantContextHolder
public class TenantContextHolder {
    private static final ThreadLocal<String> TENANT_ID = new ThreadLocal<>();

    public static void setTenantId(String tenantId) {
        TENANT_ID.set(tenantId);
    }
    public static String getTenantId() {
        return TENANT_ID.get();
    }
    public static void clear() {
        TENANT_ID.remove();
    }
}

4.2 拦截 StatementHandler.prepare 注入租户

序列图:租户插件注入租户条件

sequenceDiagram participant Filter as 请求过滤器 participant Biz as 业务Service participant SqlSession participant SH as StatementHandler(代理) participant TenantPlugin as TenantInterceptor participant RealSH as 真实StatementHandler participant DB Filter->>TenantContextHolder: setTenantId("t-001") Biz->>SqlSession: selectList("findAll") SqlSession->>SH: prepare(conn, timeout) SH->>TenantPlugin: 拦截prepare TenantPlugin->>TenantContextHolder: getTenantId() 获取 t-001 TenantPlugin->>TenantPlugin: 解析原有SQL "SELECT * FROM orders" TenantPlugin->>TenantPlugin: 智能添加租户条件
"SELECT * FROM orders WHERE tenant_id = 't-001'" TenantPlugin->>RealSH: 设置新的BoundSql RealSH->>DB: 执行仅包含租户数据的查询 DB-->>Biz: 返回隔离数据

图表主旨概括 :展示租户插件在 StatementHandler.prepare 阶段动态增加 tenant_id 条件,实现数据隔离。 逐层/逐元素分解 :上游通过 ThreadLocal 传递租户标识,拦截器从中获取。分析原始 SQL 是否有 WHERE 子句,决定使用 WHERE 还是 AND 追加条件。利用 BoundSql 的新增参数列表功能添加租户 ID 参数。 设计原理映射责任链模式 允许租户隔离逻辑无侵入地织入;ThreadLocal 解决跨层参数传输;MetaObject 动态修改 BoundSql 的参数集合。 工程联系与关键结论通过 SQL 重写实现的多租户隔离,可将安全边界下沉至持久层,避免因开发疏忽导致跨租户数据泄露。插件中务必识别并拒绝缺少租户条件的全表查询。

自行实现的 TenantInterceptor

java 复制代码
// com.deepspring.mybatis.plugin.TenantInterceptor
@Intercepts({
    @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class TenantInterceptor implements Interceptor {

    private static final String TENANT_COLUMN = "tenant_id";

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        StatementHandler handler = (StatementHandler) invocation.getTarget();
        MetaObject meta = MetaObject.forObject(handler,
                SystemMetaObject.DEFAULT_OBJECT_FACTORY,
                SystemMetaObject.DEFAULT_OBJECT_WRAPPER_FACTORY,
                new DefaultReflectorFactory());
        // 只处理查询和更新
        MappedStatement ms = (MappedStatement) meta.getValue("delegate.mappedStatement");
        if (ms.getSqlCommandType() != SqlCommandType.SELECT && ms.getSqlCommandType() != SqlCommandType.UPDATE) {
            return invocation.proceed();
        }
        String tenantId = TenantContextHolder.getTenantId();
        if (tenantId == null) {
            // 可配置为抛出异常或放行(管理端可能需要全表)
            throw new IllegalStateException("Tenant id is missing for multi-tenant query");
        }
        BoundSql boundSql = (BoundSql) meta.getValue("delegate.boundSql");
        String originalSql = boundSql.getSql();
        String newSql = appendTenantCondition(originalSql, tenantId);

        // 更新 SQL
        meta.setValue("delegate.boundSql.sql", newSql);
        // 添加 tenant_id 参数值
        meta.setValue("delegate.boundSql.additionalParameters", new HashMap<>());
        // 将租户ID作为额外参数加入
        BoundSql newBoundSql = (BoundSql) meta.getValue("delegate.boundSql");
        newBoundSql.getAdditionalParameters().put(TENANT_COLUMN, tenantId);
        return invocation.proceed();
    }

    private String appendTenantCondition(String sql, String tenantId) {
        // 简易判断,生产需用SQL解析器如JSQLParser
        if (sql.toLowerCase().contains("where")) {
            return sql + " AND " + TENANT_COLUMN + " = '" + tenantId + "'";
        } else {
            return sql + " WHERE " + TENANT_COLUMN + " = '" + tenantId + "'";
        }
    }
}

可能的复杂场景

  • 子查询、JOIN 中已有 tenant_id 条件的处理:简易字符串拼接可能引发错误,推荐使用 jsqlparser 等 SQL 解析库进行 AST 改写。
  • 防止 SQL 注入:租户 ID 优先作为参数传递,而非拼接字符串,可使用 ? 占位符,配合 BoundSqlparameterMappings 增加参数映射。
  • 管理端"上帝视角"可通过注解标记不拦截,或在 TenantContextHolder 中设置特殊值 "_GOD_",拦截器检测后跳过追加。

以下为原文章第5、6、7、8部分的详细扩展,在保持原有主旨与风格的基础上,进一步深挖原理、扩展实战细节,并丰富了图表、案例与面试题,使论述更显专业与厚重。


5. 拦截链监控、顺序与异常处理

5.1 慢 SQL 监控拦截器:从记录到治理的闭环

一个合格的慢 SQL 监控插件不能仅停留在日志记录,还应具备阈值动态调整调用链追踪执行计划抓取 以及熔断降级的潜力。在 MyBatis 插件的热路径上,任何额外开销都会被放大,因此实现时必须非常精炼。

5.1.1 核心实现:基于 Executor.querySystem.nanoTime

java 复制代码
// com.deepspring.mybatis.plugin.SlowSqlInterceptor
@Intercepts({
    @Signature(type = Executor.class, method = "query",
               args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class SlowSqlInterceptor implements Interceptor {

    private static final Logger LOGGER = LoggerFactory.getLogger(SlowSqlInterceptor.class);
    private static final long MILLIS_TO_NANOS = 1_000_000L;
    private volatile long thresholdNanos = 500 * MILLIS_TO_NANOS; // 默认500ms,支持动态修改

    /**
     * 可通过 JMX 或配置中心调用的动态设置方法
     */
    public void setThresholdMillis(long millis) {
        this.thresholdNanos = millis * MILLIS_TO_NANOS;
    }

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        long start = System.nanoTime();
        try {
            return invocation.proceed();
        } finally {
            long nanos = System.nanoTime() - start;
            if (nanos > thresholdNanos) {
                recordSlowQuery(invocation, nanos);
            }
        }
    }

    private void recordSlowQuery(Invocation invocation, long nanos) {
        MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
        Object parameter = invocation.getArgs()[1];
        BoundSql boundSql = ms.getBoundSql(parameter);
        String sql = boundSql.getSql();
        // 参数脱敏后的摘要信息
        String paramSummary = summarizeParameters(parameter);
        long millis = nanos / MILLIS_TO_NANOS;

        // 使用 SLF4J 的 Fluent API,配合 MDC 注入 traceId
        MDC.put("slowSql", "true");
        LOGGER.warn("Slow SQL detected [{}ms] | StatementId={} | SQL={} | Params={}",
                     millis, ms.getId(), sql, paramSummary);
        MDC.remove("slowSql");

        // 可选:将慢SQL事件发送到监控系统(如 Kafka/Datadog),这里用异步线程池避免阻塞
        asyncReport(ms.getId(), sql, paramSummary, millis);
    }

    private String summarizeParameters(Object parameter) {
        // 避免序列化大对象导致 OOM,只截取前 200 个字符
        if (parameter == null) return "null";
        String s = parameter.toString();
        return s.length() > 200 ? s.substring(0, 200) + "..." : s;
    }

    private void asyncReport(String sqlId, String sql, String params, long millis) {
        // 提交到容量有限的线程池,避免背压
    }
}

设计要点

  • 使用 System.nanoTime() 而非 currentTimeMillis,避免系统时钟回拨带来的负耗时。
  • finally 块确保即使发生异常也能记录耗时,且不会吞没原始异常。
  • 参数摘要截断是防止大参数(如批量插入集合)产生海量日志,引起磁盘 IO 飙升。
  • 异步上报与主业务线程解耦,确保监控开销不过度影响查询吞吐。

5.1.2 动态阈值与执行计划抓取

动态阈值可通过 JMXNacosApollo 等配置中心实时下发。例如 Spring 环境中,可通过 @RefreshScope 或监听 EnvironmentChangeEvent 更新 thresholdNanos,从而在运行时无感调节。

更进一步,当 SQL 超过严重阈值(如 5 秒),可自动触发 EXPLAIN 抓取执行计划。但这需要拦截 StatementHandler.prepare 后的连接对象,属于高危操作,务必在单独的连接上执行,避免干扰原事务。

5.2 多拦截器顺序控制与责任链织入原理

MyBatis 插件链的嵌套结构是理解执行顺序的关键。我们以两个拦截器 PageInterceptorTenantInterceptor 同时拦截 StatementHandler 为例,剖析代理创建过程与调用顺序。

5.2.1 Plugin.wrap 的嵌套逻辑

InterceptorChain.pluginAll 循环调用 Plugin.wrap

java 复制代码
public Object pluginAll(Object target) {
  for (Interceptor interceptor : interceptors) {
    target = Plugin.wrap(target, interceptor);
  }
  return target;
}

每次 Plugin.wrap 都会创建一个新的动态代理对象,以当前 target 为真实对象。因此最终形成: ProxyTenant( ProxyPage( RealStatementHandler ) )

若注册顺序为:先 PageInterceptorTenantInterceptor,则调用 prepare 时路径为:

  1. 进入 ProxyTenantinvokeTenantInterceptor.intercept()
  2. 在执行 invocation.proceed() 时,它调用的是下层 ProxyPage 的同一方法
  3. ProxyPage.invokePageInterceptor.intercept()invocation.proceed() 调用 RealStatementHandler.prepare

因此,后添加的拦截器逻辑最靠近真实对象 。在这个顺序下,TenantInterceptor 的前置逻辑先执行,但最终 SQL 改写效果受两者共同影响:分页的 LIMIT 被加在租户条件之后,这通常符合预期。如果要反转,调整插件的注册顺序即可。

5.2.2 Spring 集成中的顺序控制

在 Spring Boot 中,MyBatis 拦截器通常通过以下方式注册:

java 复制代码
@Bean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
    SqlSessionFactoryBean factory = new SqlSessionFactoryBean();
    factory.setDataSource(dataSource);
    // 设置拦截器列表顺序
    factory.setPlugins(new Interceptor[]{
        new PageInterceptor(),      // 第一个被添加,外层代理
        new TenantInterceptor()     // 第二个被添加,内层代理
    });
    return factory.getObject();
}

若需要实现 Ordered 接口自动排序,可自定义 InterceptorSorter,通过 BeanPostProcessor 收集所有 Interceptor Bean,按 @Order 值排序后统一设置。MyBatis 原生不支持 @Order,但在 Spring 容器下这是很自然的能力延伸。

5.2.3 多拦截器协同的序列图

sequenceDiagram participant Caller participant Proxy1 as TenantInterceptor代理 participant Proxy2 as PageInterceptor代理 participant Real as 真实StatementHandler Caller->>Proxy1: prepare(conn, timeout) Proxy1->>TenantInterceptor: intercept(inv1) Note over TenantInterceptor: 前置:追加 tenant_id 条件 TenantInterceptor->>Proxy2: inv1.proceed() Proxy2->>PageInterceptor: intercept(inv2) Note over PageInterceptor: 前置:改写分页 LIMIT PageInterceptor->>Real: inv2.proceed() 真实prepare Real-->>PageInterceptor: 返回 Statement Note over PageInterceptor: 后置:清除ThreadLocal分页 PageInterceptor-->>Proxy2: 返回 Note over TenantInterceptor: 后置:可记录日志 TenantInterceptor-->>Proxy1: 返回 Proxy1-->>Caller: 返回 Statement

图表主旨概括 :展示两个插件协同工作时,责任链的嵌套代理如何形成顺序执行的前置/后置切面。 逐层/逐元素分解 :外部最先接收调用的代理是最后添加的 TenantInterceptor,其拦截方法先执行租户注入,然后通过 proceed 触发第二个代理 PageInterceptor,在此处分页改写 SQL。最终真实对象被调用。 设计原理映射 :这是典型的 Decorator + Chain of Responsibility 模式,每个拦截器相当于一个装饰器,可以在委派前后添加行为,同时可以通过 proceed 打破或继续链。 工程联系与关键结论理解插件嵌套顺序是构建复合插件的基础。永远要确定"谁先拿到SQL"和"谁最终改写",否则会导致语法错误或逻辑紊乱。

5.3 异常处理策略:企业级实践的边界

拦截器处于 SQL 执行的关键路径上,其异常处理策略直接影响数据一致性与系统可用性。我们需要将异常场景分为三类,并采取不同对策。

5.3.1 核心业务必需中断的异常

这类异常意味着插件无法完成其基本职能,继续执行可能导致数据错误或安全漏洞。例如:

  • 分页插件检测到必须的分页参数未设置(程序逻辑错误)
  • 多租户插件获取不到租户 ID(缺少身份标识)

处理方式:直接在 intercept 中抛出 PersistenceException(MyBatis 统一异常),并附上清晰的错误消息。Spring 事务管理器会捕获该未检查异常,触发事务回滚。

java 复制代码
if (tenantId == null) {
    throw new PersistenceException("Tenant ID must not be null in multi-tenant query");
}

注意 :抛出异常后,仍需在 finally 块中清理 ThreadLocal,避免内存泄漏或污染线程池中的其他任务。

5.3.2 非核心降级处理的异常

数据脱敏、慢 SQL 监控等属于旁路功能,失败时不应阻挡主流程。这类拦截器应当捕获所有异常,记录日志后继续执行。

java 复制代码
public Object intercept(Invocation invocation) throws Throwable {
    try {
        Object result = invocation.proceed();
        try {
            maskSensitiveData(result);  // 脱敏处理
        } catch (Exception e) {
            LOGGER.warn("Data masking failed, returning original data", e);
        }
        return result;
    } finally {
        // ...
    }
}

这样即使脱敏反射失败,接口依然返回明文(需满足合规要求时,可改为返回占位符或默认值,但绝不能中断)。

5.3.3 异常包装与 Spring 事务的整合

MyBatis 原始异常通常是 org.apache.ibatis.exceptions.PersistenceException 及其子类,在 Spring 环境下会被 PersistenceExceptionTranslationAdvisor 转换成 DataAccessException 体系。若在拦截器中包装了异常,应保持这一链条,避免直接吞噬 SQLException 而丢失上下文。推荐做法:

java 复制代码
// 将底层 SQLException 包装,保留全部堆栈信息
catch (SQLException e) {
    throw new MyBatisSystemException("SQL rewrite failed", e);
}

这样 Spring 事务基础设施仍然可以正确识别数据访问异常,进行回滚。

5.3.4 防止异常吞没的检查清单

  • 严禁在 intercept 中使用空的 catch(Exception e) {}
  • 必须记录异常并至少打印 warn 日志
  • 核心插件异常必须向上抛出,确保事务边界正确
  • 使用 try-finally 保证 ThreadLocal 清理,异常时不会造成线程池污染

6. MyBatis 拦截链与 Spring AOP 的对比

6.1 织入层次与代理机制的本质差异

对比维度 MyBatis 插件 Spring AOP
代理目标 MyBatis 内部四大组件(ExecutorStatementHandler 等) Spring 容器中的任意 Bean
代理创建时机 MyBatis Configuration 构建时,通过 InterceptorChain.pluginAll 直接创建 JDK 动态代理 Spring 容器初始化后处理器,根据切点决定创建 CGLIB 或 JDK 动态代理
代理类型限制 仅 JDK 动态代理,要求目标必须实现接口 支持 JDK 动态代理(bean 有接口)和 CGLIB(无接口)
链结构 静态嵌套代理链,每个拦截器生成一层代理,最终形成多层代理对象 动态递归调用链 ,单个代理对象持有 List<MethodInterceptor>,通过索引递归 proceed
拦截粒度 方法级别,但不能细到方法内的某一步骤 方法级别,可通过切点表达式精确匹配包、类、注解
参数修改能力 可直接修改 BoundSqlMappedStatement 内部属性(反射),能力强大但危险 只能修改方法参数、返回值、抛出异常,不能修改字节码

核心源码对比

MyBatis Plugin.invoke

java 复制代码
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    Set<Method> methods = signatureMap.get(method.getDeclaringClass());
    if (methods != null && methods.contains(method)) {
        return interceptor.intercept(new Invocation(target, method, args));
    }
    return method.invoke(target, args);
}

每个 Interceptor 都被独立封装为 Plugin,形成一层代理。链的形成依赖于外部循环嵌套,代理对象之间完全独立。

Spring ReflectiveMethodInvocation.proceed(简化):

java 复制代码
public Object proceed() throws Throwable {
    if (this.currentInterceptorIndex == this.interceptorsAndDynamicMethodMatchers.size() - 1) {
        return invokeJoinpoint();
    }
    Object interceptorOrInterceptionAdvice = this.interceptorsAndDynamicMethodMatchers.get(++this.currentInterceptorIndex);
    return ((MethodInterceptor) interceptorOrInterceptionAdvice).invoke(this);
}

所有拦截器集中在一个列表内,通过共享的 currentInterceptorIndex 索引实现递归前进。这是典型的递归式责任链,比 MyBatis 的静态代理更加轻量且避免了层层代理反射的开销。

6.2 在单个业务调用中的协作时序

考虑这样一个业务:Service 层方法被 @Transactional 标注,同时系统中启用了分页插件。调用链路如下:

sequenceDiagram participant Controller participant TransactionAOP as Spring事务代理 participant Service participant SqlSession participant InterceptorChain as MyBatis拦截链 Controller->>TransactionAOP: 调用业务方法 TransactionAOP->>TransactionAOP: 开启事务 TransactionAOP->>Service: 执行方法体 Service->>SqlSession: selectList(...) SqlSession->>InterceptorChain: 进入Executor代理链 Note over InterceptorChain: 慢SQL监控、分页等依次执行 InterceptorChain->>SqlSession: 执行原生JDBC SqlSession-->>Service: 结果 Service-->>TransactionAOP: 返回 TransactionAOP->>TransactionAOP: 提交事务 TransactionAOP-->>Controller: 结果

结论 :MyBatis 拦截链工作在事务边界内部。如果分页插件抛出异常,将被 Spring 事务切面感知并触发回滚,这完全符合一致性的预期。正因为 MyBatis 插件在事务内执行,其改写的 SQL 也能受到数据库连接的事务隔离保护。

6.3 选型建议与协同使用

  • 数据层横切(分页、租户、脱敏、动态表名):优先使用 MyBatis 插件,因为它能直接操作 SQL 字符串和数据库底层对象,实现 Spring AOP 无法完成的改写。
  • 业务层横切(权限校验、操作日志、返回值包装):使用 Spring AOP,配合注解声明,与业务逻辑解耦。
  • 协同案例 :在 Service 方法上使用 @SensitiveData 注解,由 Spring AOP 标记此次查询需要脱敏,而 MyBatis 脱敏插件通过 ThreadLocal 从 AOP 切面接收标志位,在 ResultSetHandler 阶段完成脱敏。这样就将两个层面的拦截器串联起来,实现更灵活的架构。

7. 生产事故排查专题(扩展至三例)

事故一:分页插件与二级缓存冲突导致页面数据错误

现象:运营后台用户列表分页浏览时,切换页码后显示的数据仍为第一页内容。刷新页面后偶尔正常,问题随机出现。

排查过程

  1. 检查浏览器请求参数,确认 pageNumpageSize 正常传递。
  2. 开启 MyBatis 日志 org.apache.ibatis.logging.slf4j.Slf4jImpl,发现第一页请求打印了 SQL,翻页时无 SQL 输出,但返回了数据。
  3. 检查二级缓存配置:<cache/> 开启且 flushInterval 较长。
  4. 断点跟踪 CachingExecutor.query,发现 CacheKeyBaseExecutor.createCacheKey 生成,其内容包含 MappedStatement.idRowBounds(offset/limit)、BoundSql 等。但分页插件实现为拦截 Executor.query,仅从 ThreadLocal 获取页码后修改 RowBounds 参数,并未修改 BoundSql
  5. 由于 RowBounds 在未分页时默认为 RowBounds.DEFAULT(offset=0, limit=Integer.MAX_VALUE),插件将其修改为 new RowBounds(10, 10) 等,但 CacheKey 是在插件执行之前 由 MyBatis 生成的,因此 CacheKey 中的 RowBounds 仍是默认值。所有页码的查询都产生了相同的 CacheKey,二级缓存命中后直接返回第一页结果。

根因 :分页插件修改 RowBounds 的行为未反映在 CacheKey 的计算中,不同分页参数生成相同的缓存键。

解决方案

  • 方案一(推荐):将分页拦截点改为 StatementHandler.prepare,直接改写 BoundSql.getSql() 为物理分页 SQL,如 LIMIT 10,10。改写后的 SQL 字符串本身已区分分页,成为 CacheKey 的自然组成部分。
  • 方案二:如果必须使用 Executor.query 拦截,可在插件中调用 invocation.proceed 前手动构建新的 CacheKey,将页码、每页大小等参数添加进去,并替换 invocation 中的 CacheKey 参数。

最佳实践

  • 物理分页应通过改写 SQL 实现,确保缓存键差异化。
  • 开启二级缓存时,务必审查所有可能影响结果集大小的拦截器,确保其参数进入 CacheKey

事故二:多租户插件误拦截管理端 DDL,导致建表失败

现象 :DBA 在管理控制台执行建表 SQL CREATE TABLE ...,MyBatis 日志显示实际执行的 SQL 变为 CREATE TABLE ... WHERE tenant_id = 'admin',语法错误,任务失败。

排查过程

  1. 开发环境复现,发现任何通过 SqlSession 执行的 SQL 都会被追加租户条件。
  2. 检查 TenantInterceptor,其 intercept 方法中仅根据字符串含 WHERE 与否拼接条件,并未过滤 SQL 命令类型。
  3. 查看 MappedStatement,其 SqlCommandTypeUNKNOWN(DDL 无映射语句),但插件仍尝试改写,直接从 BoundSql 拿到原始 DDL。
  4. 进一步检查发现,管理后台的请求也是通过同一个 SqlSessionFactory,且 TenantContextHolder 中残留了租户 ID(因为管理端过滤器未清理)。

根因 :插件未区别 SqlCommandType,对所有执行的 SQL 无差别追加租户条件,同时管理端上下文被污染。

解决方案

  • intercept 开头增加命令类型判断:

    java 复制代码
    SqlCommandType type = ms.getSqlCommandType();
    if (type != SqlCommandType.SELECT && type != SqlCommandType.UPDATE && type != SqlCommandType.DELETE) {
        return invocation.proceed();
    }
  • 管理端专用的查询接口在调用前执行 TenantContextHolder.clear(),或使用特殊值 "__GOD__" 通知拦截器跳过追加。

最佳实践

  • 拦截 DML 类语句时,务必根据 SqlCommandType 白名单做过滤。
  • 为"超级管理员"视角设计清晰的不拦截协议,避免管理员操作受限。

事故三:慢SQL监控拦截器引发频繁 Full GC

现象:某次大促后,订单服务间隔性出现 Full GC,同时慢 SQL 报警频繁,系统吞吐下降。但数据库实际 CPU 并不高。

排查过程

  1. 分析 GC 日志,发现大量 char[]String 对象占据老年代,根引用来自日志框架。

  2. 慢 SQL 日志中打印了完整的参数对象,其中包含整个订单列表(批量查询)。拦截器代码:

    java 复制代码
    LOGGER.warn("SlowSQL: sql={}, params={}", sql, parameter.toString());
  3. parameter.toString() 触发了大集合的序列化,每次慢 SQL 产生数 MB 的字符串,日志追加导致内存飙升并触发 Full GC。

根因:慢 SQL 监控拦截器在记录参数时未做摘要处理,大对象序列化带来内存压力和 GC 开销。

解决方案

  • 实现参数摘要方法,截取前 200 字符,或使用 JSON 工具输出摘要后截断。

    java 复制代码
    private String safeString(Object obj) {
        String s = (obj == null) ? "null" : obj.toString();
        return s.length() > 200 ? s.substring(0, 200) + "..." : s;
    }
  • 将慢 SQL 事件的详细信息异步发送到监控系统,不在业务线程中完成大数据序列化。

最佳实践

  • 拦截器中打印日志务必限制参数大小,避免将整个业务对象输出。
  • 监控类拦截器应遵循"速进速出"原则,使用固定大小的环形缓冲区暂存事件,批量异步上报。

8. 面试高频专题

1. MyBatis 插件机制中 Interceptor 接口的三个方法各有什么作用?其调用的生命周期是怎样的?

  • Object intercept(Invocation invocation) :拦截逻辑的真正入口。框架通过动态代理将方法调用封装为 Invocation 对象传入。在此方法内开发者可以执行前置增强、调用 invocation.proceed() 传递责任链、后置增强,甚至通过不调 proceed 直接返回自定义结果。必须返回与原始方法签名兼容的值。
  • default Object plugin(Object target) :用于判断是否为当前目标对象创建代理。默认实现 Plugin.wrap(target, this) 会根据 @Intercepts 注解决定是否需要包装。开发者可覆盖该方法实现更复杂的包装逻辑,例如对多个不同接口的按需代理。
  • default void setProperties(Properties properties) :在 MyBatis 初始化阶段调用,将配置文件中 <plugin> 标签内的 <property> 注入拦截器,以便动态调整参数(如慢 SQL 阈值、分页方言)。此方法在整个生命周期中只会被调用一次。

生命周期 :在 XMLConfigBuilder.pluginElement 中解析 <plugins> 节点,反射实例化拦截器并调用 setProperties。随后在 ConfigurationInterceptorChain 中注册。当 SqlSessionFactory 构建完毕,通过 Configuration.newExecutor 等方法创建 ExecutorStatementHandler 等对象时,均调用 interceptorChain.pluginAll(target) 完成层层代理包装。每个拦截器的 plugin 方法在此阶段被触发。这意味着拦截器仅在组件创建时织入一次,后续对于该组件的所有调用都会经过拦截链,而非每次调用时重新构造链。


2. @Intercepts@Signature 是如何决定一个插件能否拦截某个方法的?请结合 Plugin.wrap 源码说明。

Plugin 类是 MyBatis 的 JDK 动态代理 InvocationHandler 实现,核心代码:

java 复制代码
public static Object wrap(Object target, Interceptor interceptor) {
    Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
    Class<?> type = target.getClass();
    Class<?>[] interfaces = getAllInterfaces(type, signatureMap);
    if (interfaces.length > 0) {
        return Proxy.newProxyInstance(
            type.getClassLoader(),
            interfaces,
            new Plugin(target, interceptor, signatureMap));
    }
    return target;
}

private static Map<Class<?>, Set<Method>> getSignatureMap(Interceptor interceptor) {
    Intercepts interceptsAnnotation = interceptor.getClass().getAnnotation(Intercepts.class);
    if (interceptsAnnotation == null) {
        throw new PluginException("No @Intercepts annotation was found in interceptor ...");
    }
    Signature[] sigs = interceptsAnnotation.value();
    Map<Class<?>, Set<Method>> signatureMap = new HashMap<>();
    for (Signature sig : sigs) {
        Set<Method> methods = signatureMap.computeIfAbsent(sig.type(), k -> new HashSet<>());
        try {
            Method method = sig.type().getMethod(sig.method(), sig.args());
            methods.add(method);
        } catch (NoSuchMethodException e) {
            throw new PluginException("Could not find method on " + sig.type(), e);
        }
    }
    return signatureMap;
}

决策流程

  1. 解析 @Intercepts 注解,构建 Map<Class<?>, Set<Method>>,Key 为要拦截的接口(如 Executor.class),Value 为该接口中声明的方法对象集合。
  2. 对于传入的目标对象 target,获取其所有实现的接口。
  3. 只保留那些在 signatureMap 中出现的接口作为代理接口。若目标对象未实现任何被声明拦截的接口,则 Plugin.wrap 直接返回原对象,不发生代理
  4. 当代理上的方法被调用时,Plugin.invoke 会检查当前方法是否包含在 signatureMap.get(method.getDeclaringClass()) 中:
    • 若匹配,则调用 interceptor.intercept(new Invocation(target, method, args))
    • 若不匹配,直接 method.invoke(target, args) 透传。

该机制保证拦截器只会作用于明确声明且目标对象真正实现了的接口方法,避免了代理所有方法的性能开销。


3. 为什么 MyBatis 插件不选择 CGLIB 而使用 JDK 动态代理?有没有可能集成 CGLIB?

根本原因在于设计目标与约束:

  • 接口导向架构 :MyBatis 核心组件 ExecutorStatementHandlerParameterHandlerResultSetHandler 全部定义为接口。MyBatis 内部引用和交互都以接口类型进行,因此基于接口的 JDK 动态代理足以满足所有拦截需求。
  • 透明性 :JDK 动态代理生成的对象虽然类型为 Proxy 子类,但可以被强制转换为目标接口,外部调用方无感知。如果用 CGLIB 通过子类化实现,目标对象将变成具体类的子类,若某些地方存在 instanceof 判断或直接字段访问,可能导致行为不一致。
  • 性能考量 :在早期 JDK 版本中,JDK 代理的性能弱于 CGLIB。但随着 JDK 8+ 的优化,反射调用被内联后差距已极小。此外,MyBatis 的拦截点仅在组件创建时包装一次,后续调用全部走代理链的 invoke 方法,不需要 CGLIB 的字节码生成能力。
  • 并非不可能 :如果开发者确实需要拦截一个没有接口的实现类(自定义 StatementHandler 实现),可以通过自定义 plugin 方法手工创建 CGLIB 增强。但从架构一致性出发,MyBatis 官方建议所有组件最终都应实现对应接口。

结论JDK 动态代理是 MyBatis 内建的最简洁、最安全的代理方案,完美契合基于接口的 SPI 设计,无需引入额外依赖。


4. 分页插件拦截 StatementHandler.prepareExecutor.query 的优劣对比?请从架构和缓存角度给出深度解释。

维度 StatementHandler.prepare 拦截 Executor.query 拦截
改写时机 SQL 构建完毕但未预编译,BoundSql 可被安全修改 SQL 已交至执行器,需通过 MappedStatementinvocation 参数获取 BoundSql
缓存影响 修改 BoundSql.sql 后,不同页码的 SQL 字符串不同(如 LIMIT 0,10 vs LIMIT 10,10),CacheKey 自动差异化,天然安全 CacheKey 在插件执行前默认已生成,若不手动修改 CacheKey 的参数,会导致所有页码命中同一缓存,极易出错
RowBounds 处理 无需官方 RowBounds 参与,直接在 SQL 中加入物理分页 通常需修改 RowBounds 参数,但 MyBatis 的 RowBounds 仅为逻辑分页标记,不能影响物理 SQL,实现时需要额外的逻辑映射
扩展能力 可获取 Connection 对象,理论上能进行方言检测 可拿到 MappedStatementResultHandler,便于 count 查询的独立执行
风险 若直接拼接字符串可能导致 SQL 注入或语法错误,需要可靠的方言检测 需要处理 ResultHandler 和嵌套查询的兼容问题

最佳实践 :物理分页的本质是改写 SQL,因此选择最接近 SQL 构建且早于预编译的 StatementHandler.prepare 是合理的。PageHelper 5.x 也采用了 Executor.query 拦截,但它通过自定义 CacheKey 和深入改写 BoundSql 代码解决了缓存问题,实现复杂度远高于 prepare 方案。面试中能够指出缓存键与拦截点的关联,即可展现对机制的透彻理解。


5. 如何在分页插件中处理 ORDER BYFOR UPDATE 子句的影响?请给出代码示例与最佳实践。

处理原则:分页子句必须附加在查询的最末尾,但要位于 FOR UPDATE 之前。例如:

  • 原始 SQL: SELECT * FROM orders ORDER BY create_time DESC FOR UPDATE
  • 应改写为: SELECT * FROM orders ORDER BY create_time DESC LIMIT 10, 20 FOR UPDATE

简单的字符串拼接难以可靠处理此逻辑,生产中推荐使用 JSQLParser 进行 AST 语法树改写。示例如下:

java 复制代码
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.statement.select.*;
import net.sf.jsqlparser.expression.JdbcParameter;

public String getPageSql(String originalSql, int offset, int limit) {
    Select select = (Select) CCJSqlParserUtil.parse(originalSql);
    PlainSelect plainSelect = (PlainSelect) select.getSelectBody();

    Limit limitClause = new Limit();
    limitClause.setOffset(new JdbcParameter()); // 使用参数占位符
    limitClause.setRowCount(new JdbcParameter());
    // 如果在 Oracle 等环境下需要处理 FOR UPDATE,需通过 Select.toString 控制输出顺序
    plainSelect.setLimit(limitClause);
    return select.toString();
}

若无第三方库,简单场景下可通过以下正则辅助:定位最终 FOR UPDATE 位置,插入分页子句。但任何正则方案都难以覆盖子查询、UNION 等复杂语句,面试中应明确指出其局限性。


6. 数据脱敏插件与序列化协作,如何确保展示层脱敏但数据库存储明文?

协作机制

  • 脱敏插件拦截 ResultSetHandler.handleResultSets,此时数据库驱动已将 ResultSet 的值填充到 Java 对象的字段中。
  • 插件通过注解识别敏感字段,调用脱敏策略对象修改字段值。由于此修改只发生在 JVM 堆内的对象上,数据库中的原始值完全不受影响。
  • 当 Spring MVC 或 Jackson 进行 JSON 序列化时,脱敏后的值被序列化并返回给前端。
  • 若此对象后续被用于再次更新数据库(如 updateUser),需特别注意脱敏值是否会被写回。通常脱敏只作用于 select 的返回,更新操作不应经过脱敏拦截。
  • 为保证这一点,拦截器内部应检查 MappedStatement.getSqlCommandType(),仅对 SELECT 语句执行脱敏,DEL/UPDATE 直接放行。

关键易错点 :脱敏后的对象若放入了二级缓存,下次查询会直接获得脱敏后的值。必须确保缓存中存储的是原始对象。可以通过:

  1. 在脱敏前进行对象拷贝,对拷贝进行脱敏,返回拷贝。
  2. 将脱敏步骤从插件移至 Service 层或 @JsonSerialize 中,避开缓存影响。

面试时画出"数据库->ResultSet->Java对象->脱敏策略->JSON"的数据流,可清晰展示边界。


7. 多租户插件如何彻底避免 SQL 注入?

关键在于永远不要将租户 ID 直接拼接到 SQL 字符串中。正确做法:

  1. BoundSql 的 SQL 字符串中使用 ? 占位符,如将 SELECT * FROM orders 改写为 SELECT * FROM orders WHERE tenant_id = ?
  2. 将租户 ID 作为参数值添加到 BoundSql 的参数映射列表中,或放入 additionalParameters。MyBatis 的 ParameterHandler 会使用 PreparedStatement.setXXX 设置参数,从根源上杜绝注入。
  3. 如果插件仅通过字符串拼接(如 "AND tenant_id = '" + tenantId + "'"),攻击者若控制 tenantId(如通过请求头篡改),则可构造 1' OR '1'='1 达成注入。
  4. 实现参考:
java 复制代码
meta.setValue("delegate.boundSql.sql", sqlWithPlaceholder);
BoundSql boundSql = (BoundSql) meta.getValue("delegate.boundSql");
boundSql.getAdditionalParameters().put("tenantId", TenantContextHolder.getTenantId());
// 同时需要确保 ParameterMapping 匹配

扩展 :当 SQL 已有其他参数时,需处理参数顺序和索引。建议使用 MyBatis 内部的 SqlSourceBuilder 或参数解析工具动态添加参数映射,确保正确性。


8. MyBatis 插件链的顺序是如何确定的?如果在 Spring 中如何通过配置控制?

原生 MyBatis :插件链顺序唯一取决于 interceptorChain.addInterceptor(interceptor) 的调用顺序。在 XML 配置中:

xml 复制代码
<plugins>
    <plugin interceptor="com.plugin.A"/>
    <plugin interceptor="com.plugin.B"/>
</plugins>

先解析 A 后 B,则 A 先被添加,B 后被添加。pluginAll 循环也是按添加顺序遍历,但需注意:由于 pluginAll 是顺序包装(for(Interceptor i : interceptors) { target = wrap(target, i); }),后被添加的插件会更靠近真实对象 ,其 intercept 方法在链上先执行前置逻辑。即调用链为 B.intercept -> A.intercept -> target。

Spring 集成 :当插件作为 Spring Bean 管理时,SqlSessionFactoryBean.setPlugins(Interceptor[]) 接收数组,顺序由数组元素顺序决定。可通过 Spring 的 @Order 注解结合 BeanPostProcessor 自动收集并排序所有 Interceptor Bean,然后注入 SqlSessionFactory。常见做法:

java 复制代码
@Configuration
public class MyBatisConfig {
    @Bean
    public SqlSessionFactory sqlSessionFactory(DataSource dataSource, List<Interceptor> interceptors) throws Exception {
        // interceptors 已按 @Order 排序
        SqlSessionFactoryBean factory = new SqlSessionFactoryBean();
        factory.setPlugins(interceptors.toArray(new Interceptor[0]));
        //...
    }
}

面试强调:理解包裹顺序对 SQL 改写至关重要,分页应在租户条件外层还是内层,将影响最终 SQL 合法性。


9. 拦截器中调用 invocation.proceed() 时到底发生了什么?

Invocation 对象封装了 targetmethodargs。其 proceed() 方法实现极其简单:

java 复制代码
public Object proceed() throws Throwable {
    return method.invoke(target, args);
}

关键在于 target 是谁:

  • 对于最内层的拦截器(即第一个被添加的),其 target 是真实的四大组件对象。
  • 对于外层拦截器,其 target 是内层拦截器生成的代理对象。 因此,proceed() 引起了链式调用: 外层代理.invoke -> 拦截器A.intercept(Invocation(内层代理, method, args)) -> invocation.proceed() -> 内层代理.method -> 拦截器B.intercept() -> invocation.proceed() -> 真实对象.method

如果在某个拦截器的 intercept 中不调用 proceed(),链将就此中断,后续拦截器和真实方法都不会执行,该拦截器直接返回自定义结果。这种权力是责任链模式的典型特征,但也容易引入隐蔽 Bug。


10. 如何设计既支持分页又支持多租户的复合插件?需要注意哪些细节?

不推荐合并在同一个 Interceptor 类中,应分离为两个独立插件,仅通过执行顺序协调。关键细节:

  1. 顺序决定 SQL 形态 :假设租户插件在外层(后注册),分页在内层(先注册),则租户插件先拦截到 SQL,追加 WHERE tenant_id = ?,然后传递给分页插件,分页插件在其后添加 LIMIT。最终得到:SELECT ... WHERE tenant_id = ? ORDER BY ... LIMIT ?, ?,结构正确。
  2. 子查询与 UNION 场景 :不能在子查询内部或错误的查询块中添加租户条件。若使用 JSQLParser,需遍历所有 FromItemJoin,找到主表添加条件。
  3. COUNT 查询 :分页插件生成 count SQL 时,租户条件必须同样被注入。因此 count 生成逻辑应在租户拦截器之后。若分页插件自行构造并执行了 COUNT,需确保 BoundSql 已经过租户修饰。
  4. 缓存键 :两者都会修改 BoundSql.sql,不同租户+不同分页会生成不同 SQL,二级缓存自动按租户和页码隔离。
  5. 异常处理:租户插件抛出的"缺失租户ID"异常应在分页插件之前触发,避免不必要的 SQL 改写。

11. MyBatis 插件抛出的异常会不会导致 Spring 事务不回滚?什么情况下会出现"吞没异常"导致数据不一致?

  • Spring 事务默认只对**运行时异常(RuntimeException及其子类)**和 Error 进行回滚。MyBatis 原生异常 PersistenceException 继承自 RuntimeException,因此分页、租户等插件抛出的运行时异常会触发回滚。
  • 吞没异常场景 :插件在 intercept 中使用了 try-catch(Exception e) { log.warn("..."); } 而不重新抛出,此时异常在插件层被消化,Spring 事务切面永远感知不到错误,会正常提交事务。这可能导致仅部分数据被写入(若后续操作依赖异常中断)。
  • 建议 :核心功能的异常必须向上传播,并用 finally 清理 ThreadLocal。旁路功能(如监控)可以在保证不影响主流程的前提下捕获并降级。

12. 解释 MetaObject 在插件开发中的作用及其设计原理。

MetaObject 是 MyBatis 内部的反射包装工具,基于 装饰器模式 封装了 ObjectWrapper 体系。它支持:

  • 以点分隔的路径访问对象属性:delegate.boundSql.sql
  • 自动识别 JavaBean、Map、Collection 等类型,选择合适的 ObjectWrapperBeanWrapperMapWrapper 等)
  • 动态获取/设置嵌套属性,处理中间层级为 null 的情况

在插件中,由于要修改的目标对象可能是 RoutingStatementHandlerdelegate 内部字段,而 BoundSql 又可能是 Executor 中的嵌套,使用 MetaObject 可以写出通用代码而不依赖具体实现类:

java 复制代码
MetaObject meta = SystemMetaObject.forObject(handler);
String sql = (String) meta.getValue("delegate.boundSql.sql");
meta.setValue("delegate.boundSql.sql", newSql);

它的实现利用 Reflector 解析类的 getter/setter,生成的 Invoker 通过方法调用而非字段直接访问,符合 JavaBean 规范。这体现了 MyBatis 对 "面向接口编程" 的极致追求。


13. 如何用 MyBatis 插件实现读写分离的自动路由?需要处理哪些事务层面的难题?

基本思路:

  • 拦截 Executor.query/update,根据 MappedStatement.getSqlCommandType() 判断读/写。
  • 读操作切换至从库数据源,写操作使用主库。
  • 数据源切换可使用 Spring 的 AbstractRoutingDataSource 结合 ThreadLocal 持有的 DataSourceType

事务难题

  • 在一个事务内,如果先写后读,为了保证读己之写,必须强制读主库。否则从库可能尚未同步数据。
  • 因此需要感知当前是否存在事务,如果存在且为读写混合事务,则所有操作都应走主库。这可以通过 Spring 的 TransactionSynchronizationManager.isActualTransactionActive() 判断。
  • 为避免同一事务内数据源切换,可在事务开始时将数据源类型固定,事务结束时清空。
  • 最终实现往往是 @Transactional + @ReadOnly 注解的联动,MyBatis 插件只作为执行层的动态路由执行者。

14. 请完整描述 Plugin.wrap 的实现逻辑,并用设计模式解析其为何能实现"按接口+方法签名"的精确拦截。

Plugin 类实现了 InvocationHandler,其构造函数接收 targetinterceptorsignatureMapwrap 静态方法:

  1. 调用 getSignatureMap(interceptor) 解析注解,构建接口->方法集合映射。
  2. 获取目标对象的所有实现接口。
  3. 取两个集合的交集:目标接口 ∩ 声明拦截的接口,若交集非空,创建 JDK 动态代理。
  4. invoke 方法中:
java 复制代码
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    Set<Method> methods = signatureMap.get(method.getDeclaringClass());
    if (methods != null && methods.contains(method)) {
        return interceptor.intercept(new Invocation(target, method, args));
    }
    return method.invoke(target, args);
}

设计模式

  • 代理模式 :为 target 生成代理控制访问。
  • 策略模式Interceptor 接口定义了拦截策略,Plugin 作为通用的代理逻辑载体,将拦截策略以组合形式注入。
  • 门面模式Plugin 简化了动态代理的创建与拦截映射,是底层 Proxy 与拦截器之间的门面。

精确拦截得益于 signatureMap 同时检查了接口类型方法对象,避免了仅按方法名匹配可能带来的歧义(不同接口可能存在同名方法)。


15. 系统设计题 :设计一个统一的数据访问中间件,整合分库分表、多租户、读写分离、慢 SQL 监控。请给出核心架构、各组件职责以及 MyBatis 插件的角色。

总体架构

sql 复制代码
业务层 → AOP(事务管理)→ MyBatis SqlSession
   ↓
拦截器链(按序):
   ① 租户拦截器(StatementHandler.prepare)
   ② 分表拦截器(StatementHandler.prepare)------改写表名
   ③ 读写分离路由拦截器(Executor.query/update)
   ④ 慢 SQL 监控拦截器(Executor.query/update)
   ↓
改写后的 SQL 经由 AbstractRoutingDataSource 获得对应数据源连接
   ↓
实际 JDBC 执行(底层可能是 ShardingSphere-JDBC 或自研分片引擎)

详细设计

  • 租户插件 :从 TenantContext 获取租户 ID,改写 SQL 添加 WHERE tenant_id = ?,只针对业务表。管理端通过白名单放行。
  • 分表插件 :根据分片键与分片策略,将逻辑表名替换为物理分表名(如 orderorder_202601)。可采用 @Sharding 注解标记分片键。
  • 读写分离 :基于 Spring AbstractRoutingDataSource + ThreadLocal。MyBatis 插件拦截时设置 DataSourceType.READ/WRITE,结合事务同步器确保事务内读写一致。注意分库分表场景下,各分片数据源的读写分离配置。
  • 慢 SQL 监控:记录执行耗时并异步上报,结合 SQL 指纹聚合并对慢查询进行 Explain 分析。
  • 协调与顺序控制 :通过 Spring 自动配置,利用 @Order 指定拦截器顺序,使用 InterceptorChain 管理。分表必须在租户之后?视业务而定,但租户一般最先注入,确保所有表都带租户维度。
  • 扩展性:利用 SPI 机制加载额外的方言策略、脱敏策略;结合配置中心实现动态阈值与插件开关。

MyBatis 插件的角色 :作为整个数据中间件的扩展主干,提供无侵入地 SQL 改写和执行监控能力,是横切关注点的主要承载。与 ShardingSphere 等重量级中间件相比,基于 MyBatis 插件的自研方案更加轻量、可定制,适合较小规模或定制化要求高的业务。


附录:MyBatis 插件开发速查表

拦截点 可改写内容 典型应用 注意事项
Executor.query MappedStatementBoundSqlRowBoundsResultHandler 慢 SQL 监控、缓存键增强、分页(早期方案) 需手动处理 CacheKeyRowBounds
Executor.update MappedStatement、参数 审计日志、租户改写(写操作) 区分 INSERT/UPDATE/DELETE 逻辑
StatementHandler.prepare BoundSql.sql、参数映射 分页方言改写、多租户 SQL 注入 保留下游参数一致性;注意 SQL 语法解析
StatementHandler.parameterize 参数值(通过 ParameterHandler 入库前参数加密 需了解 Statement 类型,可能影响参数顺序
ResultSetHandler.handleResultSets 返回的结果对象列表 数据脱敏、结果过滤 注意与二级缓存对象的共享问题
ParameterHandler.setParameters 参数赋值过程 参数掩码、类型强制转换 适用特定 JDBC 类型的修改

延伸阅读

  • MyBatis 官方文档 - Plugins 部分
  • 《MyBatis 3 源码深度解析》(南荣相余)
  • PageHelper 源码分析:其核心同样拦截 Executor.query 并重写 BoundSql
  • 本文系列前篇:
    • 第1篇:《MyBatis 核心架构全解:SqlSession、Executor、StatementHandler 与插件拦截链原理》
    • 第2篇:《映射器原理:MapperProxy、XML 解析与动态 SQL 引擎》
    • 第3篇:《一级/二级缓存深度揭秘与 Spring 整合原理》

本文通过三个完整的自研插件,展示了如何基于 MyBatis 的拦截链机制将分页、脱敏、多租户等横切关注点下沉到数据访问层。MyBatis 插件的强大之处在于它提供了一种接近数据库协议的扩展能力,理解其责任链的本质与各拦截点的特性,能够帮助架构师在复杂企业应用中打造稳健、合规的数据基础设施。

相关推荐
敖正炀2 小时前
MyBatis 架构全解:SqlSession、Executor 与 StatementHandler
mybatis
敖正炀2 小时前
一级/二级缓存深度:生命周期、脏读与生产最佳实践
mybatis
空中海5 小时前
MyBatis 基础认知、配置体系与核心映射
mybatis
空中海5 小时前
05 MyBatis 架构设计、渐进式综合项目与专家题库
mybatis
空中海7 小时前
03 MyBatis Spring Boot 集成、事务、测试与工程化体系
spring boot·后端·mybatis
Nicander2 天前
理解 mybatis 源码:vibe-coding一个mini-mybatis
后端·mybatis
庞轩px2 天前
致远互联实习复盘:一条SQL替代300次循环查询,组织架构选择器从5秒降到300毫秒
java·sql·mysql·mybatis·实习经历·n+1问题·join联表查询
952363 天前
MyBatis
后端·spring·mybatis
misL NITL4 天前
idea、mybatis报错Property ‘sqlSessionFactory‘ or ‘sqlSessionTemplate‘ are required
tomcat·intellij-idea·mybatis