ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MyBatis插件机制源码详解:四大对象拦截与慢SQL拦截器实战

MyBatis插件机制源码详解:四大对象拦截与慢SQL拦截器实战 我接手过一个内网系统的性能优化进去第一件事就是翻MyBatis相关代码。当时有个自定义拦截器怎么都不生效代码上的Intercepts注解看着也没问题就是不走。后来追到源码里才发现MyBatis的插件模块远比我想象的克制——它只给你留了四个对象、十几个方法作为拦截点剩下的全都碰不了。如果你也遇到过PageHelper失效、或者想自己写一个SQL监控拦截器却无从下手这篇文章应该能帮到你。我会把插件模块的完整运行机制、一个能直接抄走的慢SQL拦截器写法、以及我在排查中踩过的几个坑一次性讲透。1. 插件模块的触点一次SQL执行中的可插拔位置1.1 一条select从Mapper接口到数据库的完整经过先不急着看Interceptor接口长什么样。我们得知道插件到底插在哪里。你平时写的UserMapper.selectById(1)调用链大致是这样MapperProxy拦截到接口方法调用转为SqlSession的方法调用比如selectOne或selectList。SqlSession把请求转交给核心执行器Executor执行器可能是SimpleExecutor、ReuseExecutor或BatchExecutor。如果开启了二级缓存这里其实是一个CachingExecutor装饰器。Executor继续调用StatementHandler比如PreparedStatementHandler由它负责预编译SQL、执行SQL等。在执行前StatementHandler会调用ParameterHandler.setParameters()把参数绑定到JDBC的PreparedStatement上。SQL执行完返回的ResultSet会交给ResultSetHandler.handleResultSets()转换成Java对象或Map。这四个角色——Executor、StatementHandler、ParameterHandler、ResultSetHandler——就是MyBatis插件模块的全部拦截对象。我们常说的MyBatis四大对象指的就是它们。这个链路有点像一条自动化流水线原料参数进去经过分拣StatementHandler、装箱ParameterHandler、加工JDBC、质检打包ResultSetHandler最后变成你想要的对象。插件机制允许你在流水线的几个关键工位旁边加装监控探头或质量干预装置但你不能把这套流水线整体拆了重装。1.2 插件允许拦截的方法白名单MyBatis对每个对象可以拦截的方法有明确的限制如果你去看源码中Plugin类的构造和签名校验会发现它要求被拦截对象必须是上述四大接口的实例并且方法名和参数列表必须和实际方法匹配。为了方便查阅我把官方允许拦截的方法整理成了下面的表对象可拦截方法典型时机Executorupdate、query、flushStatements、commit、rollback、getTransaction、close、isClosedSQL执行入口最外层可拿到MappedStatement和参数StatementHandlerprepare、parameterize、batch、update、query准备预编译语句时可在此改写SQLParameterHandlersetParameters设置SQL参数值时可修改参数映射ResultSetHandlerhandleResultSets、handleOutputParameters结果集转换前后可修改返回结果这里有一个很容易被忽略的点方法签名中的参数列表必须严格匹配。比如你想拦截Executor.query就必须写成method query, args {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}因为这是Executor接口中query方法的真实参数顺序。如果你漏掉一个RowBounds或者把ResultHandler放前面签名匹配不上插件就会静默失效。这是很多自定义拦截器不生效的第一个隐藏原因。1.3 插件模块能做的事情和边界理解了触点就能明白插件模块的价值边界。它能做的是围绕上述方法的调用前后执行自定义逻辑常见的应用包括统计SQL执行耗时做慢SQL告警改写SQL比如分页插件往原SQL后面拼接LIMIT劫持结果集比如给返回的Map自动加个字段修改参数比如统一给租户ID相关的SQL填充条件。但它不能拦截Mapper接口的任意方法不能拦截Configuration也不能像Spring AOP那样拦截任意Service Bean。MyBatis的插件是高度收敛的这是一种设计上的取舍只暴露有限扩展点避免用户把核心执行逻辑改得面目全非。2. 从InterceptorChain到Plugin.wrap插件为什么能钩住四大对象2.1 插件在启动阶段是怎么被装进MyBatis的当MyBatis通过XML配置启动时XMLConfigBuilder会解析plugins标签。每一个plugin节点对应一个拦截器加载interceptor类反射创建实例读取子标签property填充Properties最后调用interceptor.setProperties(properties)初始化参数再交给Configuration.addInterceptor()。Configuration内部维护了一个InterceptorChain。这个类非常简单核心就是一个ListInterceptor和两个方法addInterceptor把拦截器加入列表pluginAll遍历列表中的每个拦截器依次对目标对象做包装。如果你在Spring Boot中使用MybatisPlusInterceptor这类全局插件本质也是通过Configuration.addInterceptor注入只不过走的是SqlSessionFactoryBean或MybatisPlusAutoConfiguration的封装流程。搞懂这条注入链你就能理解为什么有的项目里XML配置和Spring Boot配置看起来两套并行的插件系统实际上最后都会汇入同一个InterceptorChain。2.2 Plugin包装代理的源码之路Interceptor接口有三个方法intercept、plugin、setProperties。我们最常见的plugin实现就是return Plugin.wrap(target, this)。Plugin.wrap是插件模块的核心它做的事情分三步解析当前拦截器Intercepts注解里的签名构建一个MapClass?, SetMethod相当于一份拦截点清单判断传入的target对象是否是清单中某个类的实例并且该类的接口中存在至少一个签名匹配的方法如果匹配就用Proxy.newProxyInstance创建一个JDK动态代理如果不匹配直接返回原对象。这里尤其要注意第3步——如果签名完全不匹配Plugin.wrap不会抛异常而是返回原对象。这意味着拦截器虽然在拦截器链里却等于一个透明人。JDK动态代理有一个前提目标对象必须实现了接口。MyBatis的四大对象恰好都是接口类型所以插件模块直接使用了JDK自带的能力而没有引入CGLIB或字节码增强框架。这也是为什么你不能拦截Configuration、MapperRegistry这些类的原因之一——它们不在这四个接口的范畴内更不是代理工厂约定处理的类型。2.3 Invocation对象与proceed()的责任链传递拦截器的intercept(Invocation invocation)方法入参是一个Invocation对象它保存了三样东西目标对象target、被调用方法method、方法参数args。往下传递的唯一正确方式就是调用invocation.proceed()。proceed()内部执行的是method.invoke(target, args)。这里是一个非常典型的责任链模式。每个拦截器的intercept方法可以选择在调用proceed()之前做前置增强在之后做后置增强也可以完全代替原方法执行——如果某个拦截器不调proceed()后面的拦截器和真实的方法都不会执行。多个拦截器叠加时顺序也和老牌中间件一样有讲究。InterceptorChain.pluginAll从第一个拦截器开始调用plugin(target)返回的代理对象成为下一个拦截器的target。所以最后一个注册的拦截器包装在最外层它会最先执行自己的前置逻辑最后执行自己的后置逻辑第一个注册的拦截器则离真实目标最近。设两个拦截器A、BA先注册、B后注册那么实际执行顺序是B的pre、A的pre、真实方法、A的post、B的post。2.4 签名匹配的严格性我把签名匹配单独拿出来说是因为这是插件开发里最常见的坑。Intercepts注解里的Signature有三个属性type、method、args。args是一个Class?[]必须与目标方法声明的参数顺序一模一样。举个例子StatementHandler.prepare(Connection connection, Integer transactionTimeout)的参数类型数组是{Connection.class, Integer.class}。如果你的签名写成{Integer.class, Connection.class}或者干脆只写{Connection.class}那么插件永远不会触发。再比如Executor.query有多个重载真正会被拦截的是org.apache.ibatis.executor.Executor中那一个带RowBounds和ResultHandler的方法如果你去拦截query(MappedStatement statement, Object parameter)这种不存在的重载自然也就匹配不到。这个严苛的匹配逻辑从源码角度很好理解Plugin.getSignatureMap把注解解析完之后最终匹配时会把目标接口中所有方法都用Method#getParameterTypes拿到类型数组和args逐个比较全部一致才算命中。3. 实战自己写一个慢SQL拦截器和简化分页拦截器3.1 慢SQL拦截器的需求与选型在真实项目里监控SQL执行耗时是最高频的插件需求之一。选型上有两条路拦截Executor的query/update或者拦截StatementHandler。我一般推荐拦截Executor理由是它最接近真实调用入口能拿到MappedStatement和参数对象而且不会因为缓存命中情况导致统计口径太复杂。如果想要统计真正的数据库执行耗时那就得拦截到StatementHandler之后因为走一级缓存时Executor.query内部可能直接返回不会触发底层数据库调用但大多数慢SQL治理场景关心的是MappedStatement到SQL的完整耗时所以拦截Executor是够用的。下面是一个比较简洁的实现可以直接抄走import org.apache.ibatis.executor.Executor; import org.apache.ibatis.mapping.BoundSql; import org.apache.ibatis.mapping.MappedStatement; import org.apache.ibatis.plugin.*; import org.apache.ibatis.session.ResultHandler; import org.apache.ibatis.session.RowBounds; import java.util.Properties; 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}) }) public class SlowSqlInterceptor implements Interceptor { private long thresholdMs 500; Override public Object intercept(Invocation invocation) throws Throwable { long start System.nanoTime(); try { return invocation.proceed(); } finally { long costMs (System.nanoTime() - start) / 1_000_000; if (costMs thresholdMs) { return; } Object[] args invocation.getArgs(); if (args null || args.length 0 || !(args[0] instanceof MappedStatement)) { return; } MappedStatement ms (MappedStatement) args[0]; Object param args.length 1 ? args[1] : null; BoundSql boundSql ms.getBoundSql(param); String sql boundSql.getSql().replaceAll(\\s, ).trim(); // 这里只截取SQL前200个字符避免一条大SQL把日志刷爆 String shortSql sql.length() 200 ? sql.substring(0, 200) ... : sql; // 实际项目中这里应该走统一的日志框架 System.err.println(String.format([SlowSql] cost%sms | sql%s, costMs, shortSql)); } } Override public Object plugin(Object target) { return Plugin.wrap(target, this); } Override public void setProperties(Properties properties) { // 从properties里读阈值例如 property namethresholdMs value800/ if (properties ! null properties.getProperty(thresholdMs) ! null) { thresholdMs Long.parseLong(properties.getProperty(thresholdMs)); } } }这里要注意几个细节日志记录放在finally块里不管SQL是否抛异常都能打印耗时ms.getBoundSql(param)是拿到BoundSql的可靠方式不同方法的参数对象可能不同但BoundSql始终可以通过MappedStatement和参数对象重新构建出来不要输出完整SQL中的参数值拼出来的字符串除非你有脱敏处理。先输出占位符SQL再加参数列表是最保险的做法。3.2 把这个拦截器注册到项目里XML配置方式很直观在mybatis-config.xml中把plugin放在environments之前configuration plugins plugin interceptorcom.example.plugin.SlowSqlInterceptor property namethresholdMs value800/ /plugin /plugins environments defaultdevelopment !-- ... -- /environments /configuration如果是Spring Boot项目且你用的是mybatis-spring-boot-starter有两种常见注册方式。第一种是向SqlSessionFactory的Configuration里加Configuration public class MybatisConfig { Bean public ConfigurationCustomizer mybatisConfigurationCustomizer() { return configuration - configuration.addInterceptor(new SlowSqlInterceptor()); } }第二种是更古老的写法直接用Configuration.addInterceptorBean public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { SqlSessionFactoryBean factoryBean new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); org.apache.ibatis.session.Configuration config new org.apache.ibatis.session.Configuration(); config.addInterceptor(new SlowSqlInterceptor()); factoryBean.setConfiguration(config); return factoryBean.getObject(); }我个人建议优先用ConfigurationCustomizer。它保留了对MyBatis配置的整体控制能力而且不容易踩到Spring Boot自动配置顺序导致的插件加载被覆盖问题。3.3 简化分页拦截器的核心思路既然上了插件实战分页插件是绕不开的话题。你不需要真的从零写一个完整的PageHelper但理解它的核心改造思路能让你以后再遇到分页不生效时自己排查。一个最简单的实现思路是拦截Executor.query方法。在方法执行前先看看本次查询参数里是否带上了页码和每页条数如果有就把BoundSql里的原始SQL拼接成SELECT ... LIMIT offset, size这里假设是MySQL方言再把这个新SQL塞回BoundSql。关键在两步获取当前BoundSql可以用MappedStatement.getBoundSql(parameterObject)修改BoundSql内部的sql字段值需要借助MetaObject。MetaObject metaObject SystemMetaObject.forObject(boundSql); metaObject.setValue(sql, wrappedSql);为什么不能直接boundSql.setSql(...)因为BoundSql没有公共的setSql方法只有package-private或受保护级别的构造逻辑。MetaObject是MyBatis内置的反射工具可以安全地读写内部属性而SystemMetaObject.forObject能返回一个默认的MetaObject实例。不过请注意这里只是最简示意。真正的分页插件还要处理parameterMappings一旦SQL中新增了LIMIT占位符你还需要额外生成两个ParameterMapping并同步到BoundSql.parameterMappings里同时把limit和offset的值放进参数对象。这一步不做MyBatis在执行预编译时会发现占位符数量和参数映射数量对不上直接报错。这也是为什么像PageHelper这样的分页插件会选择更底层的拦截方式在StatementHandler阶段就把分页参数处理好而不是只改一个SQL字符串那么简单。3.4 写拦截器的通用小细节结合前文我再给几条通用的实操提示Interceptor实现类在应用生命周期内是单例的所以不要在intercept方法里保存可变状态尤其不能把某一次请求的临时数据挂在拦截器字段上。高并发下这是线程安全灾难。如果确实要在拦截器和被拦截方法之间传递数据优先考虑把额外参数放进parameterObject或者使用ThreadLocal并务必在finally中清理。我见过因为ThreadLocal忘记清理导致分页参数泄漏到普通查询里的案例表现就是某个查询突然莫名其妙多了LIMIT。在插件里如果要修改结果集不要去改原对象最好复制一份再改。因为你不知道后续的缓存逻辑会不会把这个对象直接塞进一级缓存改了原对象可能污染缓存数据。plugin方法里的Plugin.wrap(target, this)是通用写法不要在这里做太重的逻辑比如每次调用都做字符串解析或者创建新对象因为它对每一个被拦截对象都会执行一次。4. 拦截顺序、缓存边界与源码排查插件模块的隐藏坑4.1 为什么我的拦截器不生效完整排查链路我把拦截器不生效的排查步骤按顺序列出来你按这个顺序查大多数问题都能定位确认类上有没有Intercepts注解且Intercepts里的Signature是否至少有一个。没有注解Plugin.wrap拿到的签名表就是空的wrap方法会直接返回原对象。确认Signature中type是不是四大对象接口之一。你只能写Executor.class、StatementHandler.class、ParameterHandler.class、ResultSetHandler.class。确认method名称和args参数顺序跟接口原始方法完全一致。我给你的最快校验办法是直接打开IDE里的类文件从接口定义里复制方法签名。确认拦截器已经加入Configuration。在Spring Boot项目中如果用了MapperScan要注意SqlSessionFactory是否是你自己定义的是否把插件加到了你手动创建的Configuration上。有时候两个SqlSessionFactory并存插件只加在了一个上面。在Plugin.wrap方法上打一个断点看返回对象是不是Proxy类型。如果返回的是原对象说明签名匹配失败如果返回的是Proxy那intercept没走的唯一原因就是方法没被调用。第5步是终极大法也是我从那次Bug中学到的最实用的技巧。别靠猜直接断点看代理对象生成没生成十秒钟就能定位。4.2 插件和MyBatis缓存的边界在哪插件模块和缓存的关系是面试官最喜欢问的细节之一。MyBatis的一级缓存是BaseExecutor里的localCache二级缓存是CachingExecutor装饰器。关键问题来了你的拦截器包装在缓存装饰器外面还是里面我们回到Configuration.newExecutor的源码。它会根据ExecutorType创建对应的基础Executor然后如果设置了二级缓存开关就会用CachingExecutor包一层最后才执行interceptorChain.pluginAll(executor)。这一步意味着所有插件拦截器包装在最外层缓存装饰器在内层。所以当你拦截Executor.query时拦截器先于缓存逻辑执行。你统计的耗时包含了一级缓存命中、二级缓存命中的时间。如果你的目的是监听真正打到数据库上的SQL那应该去拦截更内层的对象比如StatementHandler或者等到Executor.query内部判断完缓存后再执行的那条路径。但要拿到Executor层参数信息方便这也是慢SQL插件常见的取舍点。另外一个常见的坑是插件返回的结果集有可能会被缓存复用。如果你在拦截器里改动结果集对象之后的相同查询可能会拿到被你改过的缓存数据导致结果串号。所以前面我反复强调改结果集时要十分谨慎。4.3 多插件叠加时的执行顺序与冲突解决实际项目中很少只有一个插件分页插件、数据权限插件、SQL日志插件常常挤在一起。由于责任链是后添加的在外层所以配置顺序直接决定执行顺序。比如你想让分页插件先改写SQL再让监控插件记录这个SQL的耗时那分页插件应该先注册、监控插件后注册否则监控插件统计到的SQL可能还是未分页的原始SQL。这里还要提一嘴MybatisPlusInterceptor。它本身是一个Interceptor内部用一条ListInnerInterceptor维护多个内部拦截器。它的执行顺序由内部列表的添加顺序决定并且它内部使用的是InterceptorChain类似的链式调用。所以在MyBatis-Plus项目里你自定义的传统Interceptor和它的内部InnerInterceptor混用时要留意整体顺序避免出现同一种增强逻辑执行两次。我处理过的一个真实案例项目里既有旧的自定义分页拦截器又引入了MybatisPlusInterceptor结果分页逻辑重复拼接LIMITSQL直接变成LIMIT 10, 10 LIMIT 10, 10。后来统一收敛到一套分页插件才解决问题。4.4 性能与线程安全插件不是免费的插件虽然好用但它给每次SQL执行都引入了至少一层动态代理调用。在核心高频接口上如果你再叠加多个拦截器动态代理的反射调用开销是真实存在的。实测过简单的无状态日志拦截器在高QPS查询场景下性能损耗大概在个位数百分比还能接受但如果拦截器里有复杂的字符串解析、正则匹配或者反射操作损耗会明显上升。优化方向有两个能用args数组直接拿数据就不要用MetaObject做属性遍历intercept方法里尽量早判断、早返回对不关心的操作直接proceed()不要进入额外逻辑。比如慢SQL拦截器可以只对MappedStatement的ID做前缀匹配只统计业务模块的某几个Mapper其他Mapper直接跳过。这样既保留了监控能力又把性能损耗控制在极小范围。4.5 常见面试问题角度回到这块知识本身有面试官会问MyBatis插件为什么不能拦截任意类的方法这个问题可以这样回答MyBatis的插件机制本质是基于JDK动态代理而JDK动态代理要求被代理对象必须实现接口同时MyBatis在架构上刻意只暴露了四大对象的特定方法作为扩展点这是为了在扩展性和稳定性之间取得平衡。你允许用户拦任何东西后果就是谁也说不清你的ORM框架在复杂的拦截需求下还能不能保证一致性。如果再追问拦截器链的顺序是正序还是逆序回答是InterceptorChain.pluginAll按注册顺序依次调用plugin(target)后注册的拦截器包装在最外层所以责任链的入站顺序是后注册先执行出站顺序是后注册后执行。一个很实用的技巧是打印插件执行顺序随便写两个拦截器一个打印before-A和after-A另一个打印before-B和after-B配置两个插件后看控制台输出顺序瞬间理解责任链模型。最后再分享一个排查工具说实话插件模块我第一次读源码时绕了不少弯路。当时我为了确认一个拦截器到底有没有生效在代码里加满临时日志重启了一次又一次。后来发现最有效的东西其实是一个极简的探针拦截器只需要在intercept里打一行System.out.println(hit method: invocation.getMethod().getName())再配合断点看Plugin.wrap返回的是不是Proxy就够了。把探针拦截器加到项目中能立刻判断出目标方法是否被正确代理。另外一个小技巧如果你想在插件里拿到最终要执行的SQL不要自己拼参数。直接打BoundSql.getSql()加boundSql.getParameterObject()或者使用ParameterHandler的机制输出参数列表。拼参数这种事看起来简单实际上会遇到日期格式、字符串引号、动态SQL临时变量等各种边角拼错了不仅误导排查还有可能把参数值泄露进日志。我自己的习惯是只打印占位符SQL加参数对象toString能定位99%的问题。这篇文章从触点、源码、实战和避坑四个方面把MyBatis插件模块盘了一遍。希望下次你看到分页插件不生效、自定义拦截器没反应的时候能先想到签名匹配、拦截器链顺序、缓存装饰器边界这三个核心检查点少走我当时那些弯路。
返回列表