
总有人在看了几天 MyBatis 源码之后问我启动流程到底该从哪儿看起我的建议一直很明确——先把拦截器这条线拎出来。拦截器在 MyBatis 里的位置非常特殊它既不参与 SQL 解析也不负责连接管理但它的注册、排序和代理编织恰恰把XMLConfigBuilder、Configuration、以及四大核心组件的创建过程串成了一条完整的链路。你顺着拦截器走一遍相当于把 MyBatis 启动流程的骨架摸了个遍。这篇文章就结合拦截器来拆解 MyBatis 启动流程。我会从SqlSessionFactoryBuilder开始讲到XMLConfigBuilder如何解析plugins配置再讲Configuration里InterceptorChain如何收集拦截器最后落到Executor、StatementHandler等组件的代理编织时机。适合正在啃 MyBatis 源码、准备面试题、或者想搞懂自定义拦截器为什么有时候生效有时候不生效的读者。1. 全景先行启动流程里拦截器到底参与了哪几步先不急着看代码我用一句话把 MyBatis 启动流程的核心链路讲清楚MyBatis 启动的本质是把 XML 配置、注解信息、Mapper 接口绑定等内容全部转译成一个Configuration对象再基于这个对象构建出SqlSessionFactory。至于拦截器它在这个转译过程里只做两件事第一被XMLConfigBuilder解析出来注册进Configuration.interceptorChain第二在后续创建Executor、StatementHandler、ParameterHandler、ResultSetHandler时通过interceptorChain.pluginAll(target)对目标对象进行代理包装。也就是说拦截器真正干活是在启动完成之后的执行期但它的身份和位置是在启动期被定死的。这就像新员工入职启动流程负责录入系统、分配工位、确定汇报关系真正开始产出是入职以后的事。如果你跟着new SqlSessionFactoryBuilder().build(inputStream)这行代码往下走实际会经过下面几个阶段XMLConfigBuilder创建并初始化XPathParser准备读取 XML 文件parse()方法开始解析configuration根节点按顺序解析properties、settings、typeAliases、typeHandlers、objectFactory、objectWrapperFactory、reflectorFactory、plugins、environments、databaseIdProvider、mappers解析完成后生成ConfigurationSqlSessionFactoryBuilder根据这个Configuration创建DefaultSqlSessionFactory。拦截器的身影出现在第 3 步的pluginsElement解析里但它的影响却贯穿第 5 步之后的所有 SQL 执行路径。所以当你把 MyBatis 启动流程和拦截器放在一起看时视角会非常独特你既能看到配置怎么变成对象又能看到对象怎么变成代理。2. 从 parse() 开始XMLConfigBuilder 是如何把插件配置转成拦截器对象的2.1 解析顺序里藏着依赖关系很多人以为 MyBatis 解析 XML 就是从头到尾一顺溜读完其实不是。XMLConfigBuilder.parse()里有一个明显的前后依赖顺序public Configuration parse() { if (this.parsed) { throw new BuilderException(Each XMLConfigBuilder can only be used once.); } this.parsed true; parseConfiguration(parser.evalNode(/configuration)); return configuration; }关键在于parseConfigurationprivate void parseConfiguration(XNode root) { try { propertiesElement(root.evalNode(properties)); Properties settings settingsAsProperties(root.evalNode(settings)); loadCustomVfs(settings); loadCustomLogImpl(settings); typeAliasesElement(root.evalNode(typeAliases)); pluginElement(root.evalNode(plugins)); objectFactoryElement(root.evalNode(objectFactory)); objectWrapperFactoryElement(root.evalNode(objectWrapperFactory)); reflectorFactoryElement(root.evalNode(reflectorFactory)); settingsElement(settings); environmentsElement(root.evalNode(environments)); databaseIdProviderElement(root.evalNode(databaseIdProvider)); typeHandlerElement(root.evalNode(typeHandlers)); mapperElement(root.evalNode(mappers)); } catch (Exception e) { throw new BuilderException(Error parsing SQL Mapper Configuration. Cause: e, e); } }你注意看pluginElement的执行时机是在typeAliases之后、objectFactory之前。这意味着什么意味着解析插件时拦截器类名刚刚能被别名机制解析完毕但objectFactory、objectWrapperFactory这些组件还没有注册。这个顺序不是随便排的——MyBatis 希望拦截器能尽早进入Configuration这样后面创建任何组件时拦截器链都已经准备就绪。2.2 pluginElement 内部到底做了什么pluginElement的逻辑相当简单核心只有三步private void pluginElement(XNode parent) throws Exception { if (parent ! null) { for (XNode child : parent.getChildren()) { String interceptor child.getStringAttribute(interceptor); Properties properties child.getChildrenAsProperties(); Interceptor interceptorInstance (Interceptor) resolveClass(interceptor).getDeclaredConstructor().newInstance(); interceptorInstance.setProperties(properties); configuration.addInterceptor(interceptorInstance); } } }逐行拆开看先通过resolveClass把配置里的interceptor属性值转换成Class?对象再用无参构造器newInstance()创建拦截器实例。所以你的拦截器类必须有无参构造器否则启动直接抛异常接着调用setProperties(properties)把property子标签注入进去最后调用configuration.addInterceptor(interceptorInstance)把拦截器塞进Configuration。这一步里最容易忽略的是child.getChildrenAsProperties()。它会把plugin标签下的所有property子标签解析成一个Properties对象。比如plugins plugin interceptorcom.example.MyInterceptor property nameprefix valuebiz_/ /plugin /plugins这样在setProperties里就能拿到prefixbiz_。我见过很多人写拦截器时把参数硬编码在代码里其实直接在 XML 里配property更灵活环境切换时不用重新编译。2.3 一个关键认知解析拦截器不等于创建代理很多刚接触源码的人会在这一步产生误解以为addInterceptor之后Executor就已经被包装好了。其实还没有。pluginElement阶段只是把拦截器对象放进一个待命列表。真正对Executor、StatementHandler等组件进行代理包装要等到DefaultSqlSessionFactory打开会话、组件被创建的那一瞬间。这就引出一个很实用的排查思路如果你想确认某个拦截器是否被 MyBatis 正确加载第一步应该去看Configuration.interceptorChain的interceptors列表大小而不是直接打断点在intercept方法上——因为如果配置阶段就出了问题intercept根本不会被触达。3. Configuration 中的工位图InterceptorChain 与 addInterceptor 的机制3.1 Configuration 持有的核心字段Configuration是启动流程的中央仓库几乎所有解析产物都会汇聚到这里。其中与拦截器直接相关的是protected final InterceptorChain interceptorChain new InterceptorChain();这个InterceptorChain内部维护了一个ListInterceptorpublic class InterceptorChain { private final ListInterceptor interceptors new ArrayList(); public Object pluginAll(Object target) { for (Interceptor interceptor : interceptors) { target interceptor.plugin(target); } return target; } public void addInterceptor(Interceptor interceptor) { interceptors.add(interceptor); } public ListInterceptor getInterceptors() { return Collections.unmodifiableList(interceptors); } }如果你是跟着启动流程走这里就是拦截器入册的最终位置。addInterceptor只是把拦截器追加到 List 尾部没有任何排序策略、没有优先级概念。3.2 两类注册路径XML 配置 vs 编程式配置除了通过XMLConfigBuilder.pluginElement自动注册还有很多项目在 Spring 环境下通过SqlSessionFactoryBean手动追加插件Bean public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { MybatisSqlSessionFactoryBean factoryBean new MybatisSqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); factoryBean.setPlugins(new Interceptor[]{new MyInterceptor()}); return factoryBean.getObject(); }无论走哪条路最终都会落到configuration.addInterceptor(interceptor)。这也是为什么我一直强调如果你在做框架集成与其纠结 MyBatis 配置文件的加载顺序不如直接去 SqlSessionFactoryBean 的实现里看它给 Configuration 挂了哪些钩子。3.3 顺序问题谁先被加入谁先执行代理InterceptorChain.pluginAll的循环逻辑决定了先注册的拦截器在嵌套代理的外层。这句话有点绕我换种方式解释。假设有两个拦截器 A 和 B注册顺序是 A 先、B 后。那么pluginAll执行时target A.plugin(target); // target 变成 A 的代理 target B.plugin(target); // target 变成 B 的代理且B包在A外面最终调用链看起来像B - A - 原始对象。也就是说外层拦截器先拿到执行机会也先决定是否proceed()内层拦截器只有在外层放行后才有机会执行。这个东西在启动流程里不容易暴露问题因为启动流程只负责按顺序把拦截器加进 List。但到了运行期只要两个拦截器的Signature有重叠顺序错误会直接导致结果不符合预期。比如分页拦截器和数据权限拦截器如果把分页放在最外层它拿到的BoundSql可能已经被内部拦截器改写过了分页 SQL 的拼接顺序就会很怪。3.4 为什么把 InterceptorChain 理解成待命列表很重要因为Configuration.interceptorChain在启动流程里的角色是静态注册表。它不关心目标对象是谁、什么时候创建只负责保存拦截器实例。等到newExecutor、newStatementHandler这些方法被触发时才把注册表里的拦截器逐一应用到具体目标上。这种设计有一个好处MyBatis 组件的创建路径非常统一只有有限的几个方法会 new 出核心对象。只要在这几个方法里统一调用interceptorChain.pluginAll(...)就能保证无论哪个入口创建组件拦截器都不会漏掉。4. 编织时刻四大核心组件的代理生成与 Plugin.wrap 的匹配逻辑4.1 启动后第一次触发Executor 的创建真正的代理编织发生在运行期第一次访问数据库的时候。以最经典的DefaultSqlSessionFactory.openSession()为例它会调用private SqlSession openSessionFromDataSource(ExecutorType execType, TransactionIsolationLevel level, boolean autoCommit) { final Environment environment configuration.getEnvironment(); final TransactionFactory transactionFactory getTransactionFactoryFromEnvironment(environment); tx transactionFactory.newTransaction(environment.getDataSource(), level, autoCommit); final Executor executor configuration.newExecutor(tx, execType); return new DefaultSqlSession(configuration, executor, autoCommit); }里面那句configuration.newExecutor(tx, execType)是启动流程和运行流程的交汇点。Configuration.newExecutor源码大致如下public Executor newExecutor(Transaction transaction, ExecutorType executorType) { executorType executorType null ? defaultExecutorType : executorType; executorType executorType null ? ExecutorType.SIMPLE : executorType; Executor executor; 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 (Executor) interceptorChain.pluginAll(executor); return executor; }注意这里有个容易忽略的点如果配置了二级缓存先包装CachingExecutor然后再交给拦截器链。这会导致拦截器包在CachingExecutor外层还是内层答案是CachingExecutor先被包好interceptorChain.pluginAll再对它做代理。所以拦截器能看到的是CachingExecutor的行为而CachingExecutor内部还会调用被包装的SimpleExecutor。4.2 StatementHandler、ParameterHandler、ResultSetHandler 的编织Executor创建完成之后MyBatis 开始执行 SQL会继续创建StatementHandler。同样是在Configuration里public StatementHandler newStatementHandler(Executor executor, MappedStatement mappedStatement, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) { StatementHandler statementHandler new RoutingStatementHandler(executor, mappedStatement, parameterObject, rowBounds, resultHandler, boundSql); statementHandler (StatementHandler) interceptorChain.pluginAll(statementHandler); return statementHandler; }ParameterHandler和ResultSetHandler的创建也类似分别在newParameterHandler和newResultSetHandler中调用了interceptorChain.pluginAll。所以MyBatis 被拦截器覆盖的目标对象一共有四个Executor、StatementHandler、ParameterHandler、ResultSetHandler。你用Intercepts指定type时也只能在这四个类型里选不能拦截别的类。4.3 Plugin.wrap 的签名匹配细节Interceptor.plugin(Object target)通常返回Plugin.wrap(target, this)。Plugin.wrap做的事情是拿到拦截器上的Intercepts注解解析出Signature数组然后判断当前target的类型是否匹配某个Signature指定的type。public static Object wrap(Object target, Interceptor interceptor) { MapClass?, SetMethod signatureMap getSignatureMap(interceptor.getClass()); Class? type target.getClass(); SetMethod methods signatureMap.get(type); if (methods ! null methods.size() 0) { return Proxy.newProxyInstance( type.getClassLoader(), new Class?[]{type}, new Plugin(target, interceptor, methods)); } return target; }这里有两个关键点如果target的类型不在Signature里wrap直接返回原始 target不生成代理如果匹配则用 JDK 动态代理生成一个实现了 target 接口的代理对象。注意 MyBatis 没有使用 CGLIB所以Executor、StatementHandler 等都必须声明为接口类型这也解释了为什么pluginAll的返回值经常需要强转成接口。对于想深挖的人Plugin.invoke里还有一个细节它会根据方法名和参数类型去匹配签名。匹配成功就调用interceptor.intercept(Invocation)否则直接method.invoke(target, args)。这意味着你的Signature里method和args必须和实际调用的方法完全一致否则拦截器看着注册了实际根本不触发。4.4 一个实际案例通过拦截器观察启动是否完整我在实际项目中做过一个SQL 审计拦截器只拦截StatementHandler.prepare。结果发现当某个 Mapper 方法走的是MyBatis二级缓存命中路径时我的拦截器根本不会触发因为命中缓存后不会走到StatementHandler创建流程。这个现象恰恰印证了上文说的编织时机拦截器是在组件创建时被代理的如果某个组件压根不创建拦截器自然无从插手。所以你设计拦截器时必须想清楚自己到底要拦截哪一层、什么时候才会真正创建那一层。5. 顺着启动流程补齐两块拼图缓存配置与 TypeHandler 的初始化5.1 cacheEnabled 和 CachingExecutor 的关系如果只盯着拦截器你可能会漏掉启动流程里另一个关键字段cacheEnabled。它来自settings里的cacheEnabled配置默认值是 true。在newExecutor里正是这个cacheEnabled决定了executor是否被包装为CachingExecutorif (cacheEnabled) { executor new CachingExecutor(executor); } executor (Executor) interceptorChain.pluginAll(executor);从启动流程角度理解settingsElement配置的解析发生在pluginElement之后但cacheEnabled的值最终反映到 Configuration 的字段上只有等到newExecutor才会生效。如果你想判断一个自定义拦截器是否会被CachingExecutor影响必须清楚这个包装顺序。5.2 mapper.xml 里的cache解析除了全局cacheEnabled每个 Mapper 是否开启二级缓存取决于 Mapper XML 文件里的cache标签。这一步在XMLMapperBuilder中被解析它属于mapperElement阶段的一部分。注意mapperElement在整个启动流程里排在最后也就是说只有当拦截器、环境、TypeHandler 都配置好之后Mapper 才会被加载。这个顺序带来的实际体验是你在写 Mapper XML 时如果namespace写错或者cache-ref引用了不存在的 namespace启动流程会一路查到这里才报错。很多人在报错日志里看到BuilderException时一脸懵其实只要清楚 MyBatis 是先全局配置、后 Mapper 绑定的顺序定位起来就很快。5.3 TypeHandler 注册与拦截器之间的潜在坑typeHandlerElement会把自定义 TypeHandler 注册进Configuration.typeHandlerRegistry。它属于启动流程中不那么起眼、但非常重要的一环。因为等到ParameterHandler给 SQL 参数赋值、ResultSetHandler从结果集映射字段时都要通过typeHandlerRegistry.getTypeHandler(...)找到对应的 TypeHandler。这里和拦截器有一个容易踩的坑当你自定义 TypeHandler 并希望它也参与结果映射时启动顺序必须是TypeHandler 先注册Mapper 后解析。因为XMLMapperBuilder解析 Mapper 时会直接根据 Java 类型去 TypeHandlerRegistry 找已经注册的 handler。如果顺序反了Mapper 解析阶段拿不到对应的 TypeHandler启动虽然不会报错但映射结果会不符合预期。更隐蔽的是如果你在拦截器里修改了ParameterHandler的参数对象但你自定义 TypeHandler 没有覆盖该类型的转换逻辑执行时仍然会走默认 StringTypeHandler 之类的逻辑改了半天参数映射出来的值还是不对。这类问题的排查思路通常不是盯着拦截器而是回头看 TypeHandler 注册有没有生效。5.4 拦截器调试的日志入口启动流程里还有一个容易被忽略的设置settings里的logImpl。如果你在排查拦截器问题时发现intercept方法一直没被调用可以先设置setting namelogImpl valueSTDOUT_LOGGING/然后观察 MyBatis 打印的 SQL 执行日志。日志里能看到Preparing: ...这样的输出配合打断点就能确认StatementHandler.createCacheKey、prepare等方法的调用时机。我一度以为是拦截器没注册最后发现是Signature的method名称写错了日志里 PREPARING 语句一直在走但拦截器就是不触发。6. 自定义 Configuration想在启动时动脚手可以从哪里突破6.1 XMLConfigBuilder 里的 configuration 类型定制MyBatis 允许你提供一个自定义Configuration子类。XMLConfigBuilder在构造时支持传入已有 ConfigurationXMLConfigBuilder parser new XMLConfigBuilder(inputStream, null, null, configuration);如果你不用这种构造方式MyBatis 会默认创建Configuration实例。如果想在启动流程中启用自己的 Configuration一般两种做法在SqlSessionFactoryBean里设置setConfiguration(configuration)或在源码集成时手动创建XMLConfigBuilder并传入自定义对象。自定义 Configuration 的价值在于你可以覆盖newExecutor、newStatementHandler等方法在 MyBatis 启动流程的组件加工厂层面插入自定义逻辑。比如public class CustomConfiguration extends Configuration { Override public Executor newExecutor(Transaction transaction, ExecutorType executorType) { Executor executor super.newExecutor(transaction, executorType); return new MyExecutorProxy(executor); } }这样就不需要靠Interceptor接口来切面直接在组件创建源头做包装。当然这种方式侵入性更强需要你有足够把握不建议第一版就玩这么花。6.2 自定义 Configuration 和拦截器的协同有一种很实用的组合自定义 Configuration 里重写addInterceptor可以在拦截器入册时打日志或者做校验。public class CustomConfiguration extends Configuration { Override public void addInterceptor(Interceptor interceptor) { System.out.println(interceptor registered: interceptor.getClass().getName()); super.addInterceptor(interceptor); } }这能帮你在启动流程中确认真实注册顺序。尤其是当你的工程里既有 XML 配置又有编程式插件时通过重写addInterceptor观察调用顺序比在 XML 里反复试配置要高效得多。只不过要注意这种方式依赖DefaultSqlSessionFactory能正确接到你的自定义 Configuration。如果你是 Spring Boot 环境注意不要同时设置configuration和configLocation两者同时出现时有的版本会直接忽略其中一个排查起来相当费劲。6.3 常见的启动失败原因与排查点位结合拦截器和启动流程我总结一下我实际遇到过的启动失败场景拦截器类没有无参构造器。因为pluginElement使用newInstance()一旦构造器带参数启动直接抛异常Signature里的type写成了具体实现类而不是接口。由于Plugin.wrap判断的是接口类型写成SimpleExecutor.class很可能根本不匹配因为newExecutor返回等会经过强转代理实际实现的是Executor接口setProperties里接收到的 property 值全是字符串。如果你要转 int、boolean 等类型需要自己做转换MyBatis 不会帮你做多个拦截器嵌套后target被包成了代理有些拦截器再用instanceof判断类型会失真。如果你在一个拦截器里判断parameterHandler是不是某个自定义实现类很可能因为外层代理而判断失败。这几条里最隐蔽的是第三条和第四条。我见过一个团队在拦截器里读取properties的batchSize属性直接用Integer.parseInt(properties.getProperty(batchSize))没问题但有人偷懒写成String batchSize properties.getProperty(batchSize)然后和数字比较结果怎么都不对。6.4 我的调试习惯如果你也想从启动流程的角度去验证拦截器我建议你在自己的项目里做一次最小化实验写一个最简单的拦截器只拦截StatementHandler.prepare在拦截器intercept方法里打印invocation.getTarget().getClass()在Configuration.newExecutor和Configuration.newStatementHandler两处打断点分别观察interceptorChain.pluginAll(...)前后的对象类型注意查看CachingExecutor被加入时的包装顺序。跑通这一步你对拦截器和启动流程如何串联的理解会比看十遍源码都深刻。之后遇到任何拦截器不生效启动时插件报错多个插件顺序不对的问题都能从这条链路上找到对应环节而不是靠瞎试。我个人在实际操作中最受用的一个习惯是永远先确认启动流程把拦截器注册到了哪个 List再确认代理包装的顺序最后才去查 Signature 是否匹配。按这个顺序排查基本没有解不开的拦截器问题。