MyBatis 插件机制的原理是什么?能拦截哪些对象?
简化版
MyBatis 插件通过 Interceptor 和 JDK 动态代理,包装 Executor、StatementHandler、ParameterHandler、ResultSetHandler 四类核心对象的指定方法。@Intercepts/@Signature 声明拦截类型、方法和参数,intercept 执行增强;它不是任意方法 AOP,改写 SQL 时还要同步考虑参数映射、缓存键和插件顺序。
详细版
Configuration 创建四类对象时,会依次经过 InterceptorChain 的 pluginAll,匹配签名的插件返回代理。方法调用进入 Invocation 后,插件可查看 target、method、args,调用 invocation.proceed() 继续执行,或在前后增加分页、审计、脱敏等逻辑。
拦截层次决定可见信息:Executor 层能看到 MappedStatement、参数、RowBounds 和缓存流程;StatementHandler 层更接近最终 BoundSql/JDBC Statement;ParameterHandler 负责入参;ResultSetHandler 负责结果集。选择过晚的 SQL 改写点可能让 CacheKey 与真实 SQL 不一致。
完整版教学
一、一个最小拦截器
@Intercepts({
@Signature(
type = StatementHandler.class,
method = "prepare",
args = {Connection.class, Integer.class}
)
})
class SqlTimingInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
long start = System.nanoTime();
try {
return invocation.proceed();
} finally {
record(System.nanoTime() - start);
}
}
}
Signature 必须与目标接口方法准确对应,否则插件不会按预期拦截或启动时报配置错误。
| 可拦截对象 | 典型方法 | 更适合做什么 | 不适合做什么 |
|---|---|---|---|
Executor | query、update | 高层查询更新、缓存相关增强 | 只关心 JDBC Statement 细节 |
StatementHandler | prepare、query、update | 分页 SQL、SQL 计时、方言处理 | 忽略 CacheKey 直接改 SQL |
ParameterHandler | setParameters | 参数审计、特殊类型处理 | 大范围改写 SQL 结构 |
ResultSetHandler | handleResultSets | 结果脱敏、映射后增强 | 影响查询执行计划 |
记忆钩子:插件不是万能 AOP,它只能站在 MyBatis 允许的四个路口;站得越靠前,越影响缓存和执行计划,站得越靠后,越接近 JDBC 或结果对象。
二、代理是如何套上的
每当 MyBatis 创建 Executor 等对象,InterceptorChain 依注册顺序调用每个插件的 plugin。默认实现通常使用 Plugin.wrap(target, this),只为匹配接口生成 JDK 代理。
多个插件会形成代理链,注册顺序影响调用嵌套和前后置执行顺序。插件之间都修改 BoundSql 时尤其需要集成测试,不能依赖偶然顺序。
原始 StatementHandler
-> PluginA.wrap
-> PluginB.wrap
-> PluginC.wrap
调用时进入顺序可能是 C -> B -> A -> 原始对象
返回时再按相反方向退出
如果一个分页插件改了 SQL,另一个审计插件也读取 SQL,注册顺序会决定审计看到的是原 SQL 还是分页后的 SQL。一个项目里有 3 个插件时,至少要用集成测试覆盖组合顺序,而不是只测每个插件单独工作。
三、四类拦截点如何选择
- Executor:查询、更新、提交、缓存等较高层行为。
- StatementHandler:SQL prepare、parameterize、query/update 等 JDBC 前后行为。
- ParameterHandler:把参数设置到 PreparedStatement。
- ResultSetHandler:结果集与存储过程输出参数处理。
分页、租户条件等需要改 SQL 的插件,必须同时理解 Count 查询、参数映射、方言和缓存;结果脱敏则更接近 ResultSetHandler 或业务层。
四、SQL 改写的风险
仅替换 SQL 字符串却不更新 ParameterMapping,会造成占位符数量与参数不一致。新增参数还要放入 BoundSql 可访问的附加参数或原参数对象中,并使用正确 TypeHandler。
Executor 可能已经根据 statement、分页、SQL 和参数生成 CacheKey;若之后才在 StatementHandler 修改 SQL,两个不同真实查询可能错误共享缓存键。成熟分页插件会选择合适层次并协调缓存与参数。
带数字例子:原 SQL 是 SELECT * FROM orders WHERE status = ?,只有 1 个参数。租户插件若改成 ... WHERE status = ? AND tenant_id = ?,SQL 里变成 2 个占位符,但 ParameterMapping 仍只有 1 个,就会在绑定阶段报错或参数错位。即使补了参数,如果缓存键仍按原 SQL 生成,租户 1 和租户 2 可能错误共享查询缓存,这是严重数据隔离问题。
五、不要用插件承载业务逻辑
插件影响所有匹配语句,排错范围大,适合通用基础设施能力。复杂权限、订单规则等业务逻辑放进 SQL 插件,会隐藏执行条件并增加误更新风险。
多租户插件也必须处理 JOIN、子查询、批处理、绕过规则和管理员场景,不能靠简单字符串拼接 WHERE。
六、性能与可观测性
插件处于数据库热路径,应避免每次反射解析大对象、打印完整结果集或同步调用远程服务。SQL 日志要参数脱敏并限制长度,计时还要区分等待连接、JDBC 执行和结果映射阶段。
七、常见误区与追问
- 误区:MyBatis 插件能拦截任意 Mapper 方法。 它只能拦截 Executor、StatementHandler、ParameterHandler、ResultSetHandler 这四类对象的指定方法。
- 误区:改 SQL 只要替换 BoundSql 的字符串。 新增或删除占位符时必须同步 ParameterMapping、附加参数和 TypeHandler,否则参数绑定会错。
- 追问:分页插件常拦截哪一层? 常见做法是在 Executor 或 StatementHandler 层处理,既要能拿到 SQL,又要考虑 RowBounds、Count 查询和缓存键。
- 追问:插件顺序为什么重要? 多个代理嵌套执行,谁先改 SQL、谁后记录日志会影响最终行为和可观测结果。
- 误区:业务权限规则很适合塞进 MyBatis 插件。 插件影响范围广且隐蔽,复杂业务规则放进去会增加误更新和排查成本。
- 追问:为什么 SQL 改写要考虑 CacheKey? 如果缓存键按旧 SQL 或旧参数生成,不同真实查询可能命中同一缓存,造成错误结果。
八、加强记忆
MyBatis 插件只代理四大对象的指定方法:Executor 高层调度,StatementHandler 靠近 SQL,ParameterHandler 绑参数,ResultSetHandler 映射结果。改 SQL 必须同步参数和 CacheKey,插件顺序也要测试,通用增强才适合放插件。