资讯动态

MyBatis启动流程与拦截器代理机制全解析

发布时间:2026/10/16 10:49:42 来源:尧图企业网站定制
MyBatis的启动流程一直是面试的高频题目而拦截器Interceptor又是在实际项目中用得最多、出问题也最隐蔽的机制之一。很多同学单独背启动流程能背得滚瓜烂熟单独看拦截器源码也能看懂个大概但一旦被问到“拦截器是何时被装载的”“代理是什么时候产生的”“多个拦截器的执行顺序是怎么定的”就容易卡壳。这篇东西我准备从启动流程这条主线出发把拦截器在启动阶段的装配时机、代理生成机制、以及配置踩坑全部串起来讲一遍全文以实际可运行的代码和验证步骤为辅尽量做到看完就能在项目里复现。1. 启动流程的整体脉络MyBatis启动时到底干了哪些事1.1 从一次面试连环问说起先模拟一个常见的面试场景面试官问“MyBatis启动流程是怎样的”很多人会回答“读取配置文件创建SqlSessionFactory”。但这个答案太粗了面试官接着就会追问配置文件是谁读的解析出来的东西放到了哪里Mapper接口是什么时候注册的Executor是什么时候创建的如果你答不上来说明你对启动流程的理解还停留在背结论阶段。实际上MyBatis的启动是一个把“XML配置 映射文件 注解信息”逐步转换成Configuration对象配置中心的过程。后续所有的SQL执行、事务管理、二级缓存、拦截器代理全部围绕这个Configuration实例展开。所以理解启动流程本质上就是理解Configuration对象是如何被一步一步填充完整的。1.2 启动阶段的五个关键动作我把MyBatis启动流程拆成五个关键动作对照着类名去看会清晰很多。第一步SqlSessionFactoryBuilder读取配置文件输入流。它接收一个InputStream或者Reader然后把它交给XMLConfigBuilder去解析。这一步很多人忽略实际上一开始读到的只是字节流还没有任何结构化数据。第二步XMLConfigBuilder解析全局配置。XMLConfigBuilder会把mybatis-config.xml里的各个节点settings、typeAliases、typeHandlers、environments、mappers等逐一解析解析结果全部塞进一个Configuration实例。比如settings节点里的缓存开关、日志实现typeAliases节点里的别名注册environments节点里的事务工厂和数据源都会被转换成Configuration上的属性。第三步XMLMapperBuilder解析映射文件。这一步在解析mappers节点时触发每个mapper XML文件会被XMLMapperBuilder读取里面的cache、resultMap、sql、select|update|insert|delete等节点会被解析成MappedStatement、ResultMap等对象注册到Configuration的mappedStatements一个Map结构和resultMaps中。第四步MapperRegistry注册Mapper接口。XMLMapperBuilder在解析完XML后还会调用MapperRegistry.addMapper()把对应的Mapper接口注册进去。注册的过程是把Mapper接口的Class对象和MapperProxyFactory关联起来存到knownMappers这个Map里。这一步决定了后续sqlSession.getMapper(UserMapper.class)能不能找到对应的代理工厂。第五步构建SqlSessionFactory并准备SqlSession。SqlSessionFactoryBuilder最终new一个DefaultSqlSessionFactory把Configuration交出去。之后调用sqlSessionFactory.openSession()时才会创建SqlSession并同时创建Executor等核心运行时对象。这五步是整个MyBatis生命周期的基础。注意前面四步都在“启动阶段”完成第五步的Executor创建虽然发生在openSession时但它的构建规则比如缓存执行器要不要包一层、拦截器怎么介入是在Configuration内部就定好的。2. 拦截器在这个流程中藏在哪一层2.1 InterceptorChain一个看起来不起眼的关键类很多人在读Configuration源码时会注意到里面有个InterceptorChain类型的字段protected final InterceptorChain interceptorChain new InterceptorChain();这个字段既不显眼又没有在XML解析阶段被直接赋值。那么它的值是从哪来的答案是当XMLConfigBuilder解析到plugins节点时会对每个plugin标签创建一个拦截器实例然后调用configuration.addInterceptor(interceptor)最终进入interceptorChain.addInterceptor()。注意这个动作发生在启动阶段。也就是说拦截器在MyBatis启动时就已经被实例化并加入了Configuration的拦截器链但此时它还没有对任何目标对象产生代理效果。真正的代理生成要等到Executor、ParameterHandler、ResultSetHandler、StatementHandler这四个接口的实现类被创建时才通过interceptorChain统一处理。这个设计有点像一个门卫名单在开工前就录入系统了但门卫真正上岗检视每个进入大楼的人是后续运行时的事。搞清楚“注册”和“生效”两个时间点的区别是理解整个机制的关键。2.2 四大被拦截对象的创建与代理时机MyBatis允许我们拦截的只有四个接口Executor、StatementHandler、ParameterHandler、ResultSetHandler。它们的创建位置分别在Executor在Configuration.newExecutor()方法中创建默认是SimpleExecutor如果开了二级缓存会再用CachingExecutor包一层StatementHandler在Configuration.newStatementHandler()方法中创建实际是RoutingStatementHandler根据MapperStatement的配置路由到SimpleStatementHandler、PreparedStatementHandler或CallableStatementHandlerParameterHandler在Configuration.newParameterHandler()方法中创建默认是DefaultParameterHandlerResultSetHandler在Configuration.newResultSetHandler()方法中创建默认是DefaultResultSetHandler。这四个方法的内部几乎都调用了同样的收尾代码在new完目标对象之后调用interceptorChain.pluginAll(target)返回可能的代理对象。比如newExecutor的核心逻辑public Executor newExecutor(Transaction transaction, ExecutorType executorType) { executorType executorType null ? ExecutorType.SIMPLE : executorType; Executor executor null; if (ExecutorType.BATCH executorType) { executor new BatchExecutor(this, transaction); } else if (ExecutorType.REUSE executorType) { executor new ReuseExecutor(this, transaction); } else { executor new SimpleExecutor(this, transaction); } if (cacheEnabled) { executor new CachingExecutor(executor); } Executor wrapper (Executor) interceptorChain.pluginAll(executor); return wrapper; }pluginAll做的事情很简单遍历interceptorChain内部的所有拦截器逐个调用interceptor.plugin(target)把上一步返回的结果继续作为下一步的target传入最终返回一个被层层包装的对象。这就解释了为什么启动阶段看不到代理、运行阶段一创建Executor就出现代理代理生成的时机是运行时四大对象被创建的瞬间。3. 动手实践写一个自定义拦截器验证启动即生效3.1 拦截器类的标准写法先写一个非常实用且容易验证的SQL执行耗时拦截器顺便解决团队里经常遇到的“SQL跑了多久”问题。代码如下Intercepts({ Signature( type Executor.class, method update, args {MappedStatement.class, Object.class} ) }) public class SqlCostInterceptor implements Interceptor { private static final Logger log LoggerFactory.getLogger(SqlCostInterceptor.class); Override public Object intercept(Invocation invocation) throws Throwable { long start System.currentTimeMillis(); try { return invocation.proceed(); } finally { long cost System.currentTimeMillis() - start; log.info(SQL execute cost: {} ms, cost); } } Override public Object plugin(Object target) { return Plugin.wrap(target, this); } Override public void setProperties(Properties properties) { // 这里可以读取 property 子标签配置 } }这里有几个细节值得留意。第一Signature里的type必须是四大接口之一写错类的话拦截器永远不生效而且MyBatis启动时不会报任何错误排查起来非常头疼。第二method要跟接口方法名完全一致args的类型顺序也要跟接口定义一致。比如Executor.update有两个参数顺序是MappedStatement、Object不能写反否则参数匹配会失败。第三plugin()方法直接调用Plugin.wrap(target, this)是标准写法不建议自己搞一套代理逻辑因为Plugin.wrap内部实现了基于注解的智能匹配。3.2 XML配置与启动日志验证在mybatis-config.xml里加这样一段配置configuration plugins plugin interceptorcom.example.interceptor.SqlCostInterceptor property namelogLevel valueINFO/ /plugin /plugins /configuration注意plugins标签的位置它必须是configuration根节点的直接子节点而且需要在environments之前。如果放在environments后面解析顺序上虽然不至于报错但有些解析器实现会因为你多次调用addInterceptor而改变某些行为逻辑最好还是严格按DTD顺序来。配置完成后启动项目然后在日志里观察初始化阶段。你会看到MyBatis打印的Parsed mapper文件等日志但此时拦截器还没有输出任何内容。接着调用一次Mapper的update方法日志中就会出现SQL execute cost: xx ms。这证明了拦截器在启动阶段被装载在运行阶段被触发。我建议你在这个拦截器的intercept方法里打一个断点然后查看调用栈。你会发现调用链是这样的MapperProxy.invoke - Executor.update - SqlCostInterceptor.intercept - SimpleExecutor.update。也就是说代理对象确实包裹在真实Executor外层MyBatis在创建Executor的时候就用pluginAll把它包装好了。4. 源码解剖代理链是如何一层一层织出来的4.1 JDK动态代理与Plugin.wrap的实现逻辑很多人以为MyBatis的拦截器是用了什么神秘框架其实底层就是JDK动态代理只是包了一层匹配逻辑。Plugin类实现了InvocationHandler持有两个字段target被代理对象和interceptor自定义拦截器实例。Plugin.wrap(target, this)做的事大概是这样的读取自定义拦截器上的Intercepts注解解析出允许拦截的方法集合然后判断target的接口是否匹配这些方法签名。如果匹配就通过Proxy.newProxyInstance生成代理对象如果不匹配直接返回原对象。这个设计非常聪明它避免了对所有方法做无意义的动态代理性能损耗降到最低。看下面的代码就很直观了public static Object wrap(Object target, Interceptor interceptor) { MapClass?, SetMethod 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; }getAllInterfaces会检查target实现的接口是否在signatureMap中所以就算你拦截的type是Executor但目标对象实际是CachingExecutor只要CachingExecutor实现了Executor接口就能被代理。4.2 pluginAll的包装顺序与执行顺序当Configuration.newExecutor创建完Executor后执行interceptorChain.pluginAll(executor)内部代码是public Object pluginAll(Object target) { for (Interceptor interceptor : interceptors) { target interceptor.plugin(target); } return target; }这个循环决定了两个关键特性第一后配置的拦截器代理在外层。假设先配置了A拦截器再配置了B拦截器那么第一次循环A把executor包装成proxyA第二次循环B把proxyA继续包装成proxyB。最终调用的是proxyBproxyB先进入B.intercept再进入A.intercept最后才进入真正的Executor。这跟Spring AOP的“先配置先执行”不同很多人在这里踩坑。第二每个拦截器的intercept方法里必须手动调用invocation.proceed()。如果你忘了调用proceed整个调用链就会断掉SQL永远执行不了而且很难排查。proceed方法的实现就是继续反射调用下一个对象的方法如果当前对象是代理就进入下一个拦截器如果已经是真实对象就执行真实方法。你可以把整个拦截链想象成洋葱。用户调用最外层每一层拦截器可以决定在剥洋葱前做点什么、剥到哪一层、剥完再做点什么。调用proceed就是“继续往里剥”。4.3 四大对象如何形成一条完整的SQL执行链路既然是结合启动流程讲拦截器那就有必要把四大对象在运行时的协作关系搞清楚。一条普通查询SQL的执行链路大致是SqlSession.selectList - Executor.query - StatementHandler.query - ParameterHandler.setParameters - JDBC执行 - ResultSetHandler.handleResultSets - 返回结果其中Executor负责缓存判断、一级缓存命中、事务协调和调用StatementHandlerStatementHandler负责SQL语句准备、参数绑定和执行ParameterHandler负责把用户传入的参数设置到PreparedStatement上ResultSetHandler负责把JDBC返回的ResultSet映射成POJO。这四层都可以被拦截但是拦截时机不同。启动流程中Configuration对象持有这四个对象的创建工厂方法运行时才真正new目标对象并pluginAll。所以如果你想拦截ResultSetHandler做结果脱敏或者拦截ParameterHandler做参数加密写法和Executor拦截器完全一样只需要改Signature里的type和method即可。5. 干活记录多拦截器场景下的踩坑与验证5.1 例子组合两个拦截器看执行顺序我曾在某项目中同时配置了SQL耗时拦截器和SQL参数打印拦截器配置顺序是耗时拦截器在前参数打印拦截器在后plugins plugin interceptorcom.example.interceptor.SqlCostInterceptor/ plugin interceptorcom.example.interceptor.SqlParameterInterceptor/ /plugins然后执行了一次insert操作观察到的调用顺序是SqlParameterInterceptor.intercept先执行然后才进入SqlCostInterceptor.intercept最后才是Executor.update。这个结果让当时团队里的一个新手很困惑因为XML里明明是耗时拦截器写在前面。原因就在上面讲的pluginAll循环里。第一个被包装的目标是真实Executor耗时拦截器先包一层得到proxyA然后参数拦截器再包proxyA得到proxyB。运行时先走到proxyB的InvocationHandler自然先触发参数拦截器。所以XML里越靠后的拦截器越先执行intercept方法。这个结论我建议你亲手验证一下。方法是两个拦截器里各打一行日志然后观察输出顺序。理解了这个顺序机制以后配置多个拦截器时就不会被表象骗了。5.2 失败案例参数加密拦截器不生效还有一个真实经历。当时想对update方法的参数做加密处理于是写了如下签名Signature( type Executor.class, method update, args {MappedStatement.class, Object.class} )结果死活不触发。查了很久发现业务代码里执行的是sqlSession.update(namespace.updateId, paramObject)这个调用确实是走Executor.update的但项目里配置了一个分页拦截器它内部在Executor的query方法上做了代理。本来两者不冲突问题出在我把加密拦截器写成了Intercepts({})注解解析到的签名是空的Plugin.wrap判断没有可拦截的方法直接返回了原始target。这个坑值得记下来Intercepts注解里的内容如果写错或为空拦截器会静默失效。系统不会给你任何报错业务正常运行只是你的逻辑没被执行。所以排查拦截器不生效时第一件事就是打开DEBUG日志看Configuration对象里interceptorChain.interceptors列表的长度。如果长度正确再检查Signature里的type、method、args是否与目标接口完全匹配。6. 常见问题与排查技巧实录下面这组问题基本都是我在实际项目和技术社区里高频看到的问题整理成速查表方便大家对应排查。问题现象核心原因处理建议拦截器不生效日志无输出Signature签名与目标接口不匹配或注解为空检查type、method、args是否完全一致XML配置了plugin但启动报错plugins标签位置不对或类名写错确保在configuration根下、environments之前多个拦截器顺序与预期相反pluginAll后添加的在最外层明确“XML后面配置的先执行”规则执行update时被拦截两次拦截了Executor.update同时分页插件也代理Executor检查是否多个拦截器同时匹配同一方法使用Intercepts注解但代理未生成目标对象没有实现四大接口确认拦截的type限定在四个接口范围内启动时解析DTD失败离线环境访问mybatis-3-config.dtd超时本地DTD或使用mybatis-config.xml的标准起始声明排查时还有一个通用技巧在自定义拦截器构造方法里打点日志。因为启动阶段会new拦截器实例构造方法里的日志能帮你确认拦截器有没有被MyBatis正确加载。如果构造方法没执行那就是XML配置里类名没找到如果构造方法执行了但intercept没执行那问题基本锁定在Signature匹配上。另一个值得分享的经验是拦截器内部尽量只做无状态的处理不要保存过多成员变量。因为四大对象在运行时可能被多个线程共享尤其Executor是每次SqlSession创建时都会新建但缓存Executor在开启二级缓存时可能被复用。如果你在拦截器里放了一个非线程安全的成员变量就可能出现并发问题。我个人在实际排查中一般是先复现问题然后分别在启动时和运行时确认拦截器状态。如果确认拦截器已被加载但未生效就在Plugin.wrap方法上打个条件断点看target的类型和signatureMap的匹配结果。这一步基本能解决所有拦截器相关的疑难杂症。最后再分享一个小技巧如果你想通过拦截器给所有SQL自动加一个逻辑删除条件或者做租户ID隔离可以拦截Executor.query方法在调用proceed之前修改MappedStatement的BoundSql。但这个操作比较微妙因为要同时兼容多种查询形态建议先在拦截器里打印出MappedStatement的SQL和参数确认改造点之后再动手。拦截器是MyBatis留给我们的少数几个稳定扩展点用好了能解决很多框架层面才方便处理的横切需求。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑