MyBatis启动流程与拦截器原理:从配置解析到动态代理

发布时间:2026/9/29 17:58:06
MyBatis启动流程与拦截器原理:从配置解析到动态代理 先说结论MyBatis 的启动流程本质上就是一次“把配置和接口变成可执行 SQL 映射”的装配过程。而拦截器Interceptor在这个流程里扮演的角色比很多人印象中要重要得多——它不只是“SQL 审计”或者“分页插件挂载点”它实际上是 MyBatis 在启动阶段对四大核心对象Executor、StatementHandler、ParameterHandler、ResultSetHandler进行动态代理的入口。理解了这一点再看 MyBatis 的源码就不会觉得它是一团乱麻面试被问到“MyBatis 启动流程”或者“拦截器原理”时也能讲出深层逻辑而不是只背几句结论。这篇文章我会按自己的理解完整拆一遍从 SqlSessionFactory 的构建开始到拦截器如何通过责任链模式织入核心组件再到一次 SQL 执行时拦截器链到底怎么发挥作用。文中的源码版本是 MyBatis 3.5.x这个版本是目前用得最多的主线版本逻辑和 3.4.x 基本一致你可以放心对照自己的项目去验证。1. 启动流程的入口SqlSessionFactoryBuilder 到底做了什么MyBatis 的启动入口非常直白几乎所有项目里都是同一行代码SqlSessionFactory factory new SqlSessionFactoryBuilder().build(inputStream);但这一行代码后面发生的事情比很多人想象中要多得多。SqlSessionFactoryBuilder本身只是个“引导类”它不负责真正干活核心动作是两步解析 XML 配置得到Configuration对象然后用这个Configuration去创建SqlSessionFactory。1.1 XMLConfigBuilder 加载配置文件的完整路径build(InputStream)方法内部会创建一个XMLConfigBuilder然后调用parse()方法。这个解析过程是按 XML 的节点顺序来的但很多人没注意到XMLConfigBuilder并不会一次性解析完所有配置而是分成两个阶段第一阶段是解析全局配置也就是settings、typeAliases、typeHandlers、plugins、mappers这些顶层节点。这里面有一个非常容易忽略的细节settings节点的属性解析是滞后于对象创建的。MyBatis 会先用默认值创建一个Configuration实例然后再把settings里的配置项反射塞进去。这意味着如果你在settings里配置了一个未知属性MyBatis 在启动阶段会直接报错而不是静默忽略——它用的是MetaClass的反射机制来校验属性是否存在。第二阶段是注册 mapper。mappers节点里的每个 mapper 文件会被解析成MapperAnnotationBuilder或者XMLMapperBuilder然后把里面的 SQL 语句解析成MappedStatement存放到Configuration.mappedStatements这个 Map 里。到这里启动流程的基本框架就算搭完了。我这里用一段代码帮你直观理解parse()的层次// XMLConfigBuilder.parse() 的简化逻辑 public Configuration parse() { if (parsed) { throw new BuilderException(Each XMLConfigBuilder must only be used once.); } parsed true; parseConfiguration(parser.evalNode(/configuration)); return configuration; } private void parseConfiguration(XNode root) { // 注意节点解析顺序是有讲究的properties 必须先于 settings propertiesElement(root.evalNode(properties)); typeAliasesElement(root.evalNode(typeAliases)); pluginElement(root.evalNode(plugins)); objectFactoryElement(root.evalNode(objectFactory)); objectWrapperFactoryElement(root.evalNode(objectWrapperFactory)); settingsElement(root.evalNode(settings)); typeHandlerElement(root.evalNode(typeHandlers)); mapperElement(root.evalNode(mappers)); }1.2 为什么 MappedStatement 是启动流程的核心产物如果你看 MyBatis 源码会发现启动流程绕来绕去最终目的就是构建Configuration里面那几个关键的 Map。其中最核心的就是mappedStatements它的 key 是 statementId默认是namespace.idvalue 是MappedStatement对象。MappedStatement里面封装了什么一条 SQL 的完整元信息SQL 语句本身SqlSource、参数类型parameterType、返回结果映射ResultMap、SQL 语句类型SqlCommandType、缓存配置Cache、以及statement超时和 fetchSize 等。这些信息不是从 XML 里拿到字符串就完了而是经过层层解析变成运行时可以直接使用的对象。有个细节值得注意MappedStatement在构建时会读取flushCache和useCache这两个属性它们默认值分别对应的是SELECT语句和其他语句的不同行为。这就是为什么你只写了一个简单的select标签MyBatis 也能正确判断这条语句是否要走二级缓存——这两个默认值是在MappedStatement.Builder构造的时候赋好的。2. 拦截器机制的前置知识四大核心对象的代理关系聊拦截器之前必须先说清楚 MyBatis 的四大核心对象是怎么创建出来的因为拦截器本质上就是给这四类对象做代理。这四个对象分别是Executor执行器负责 SQL 的总体执行、缓存维护、一级缓存查询。StatementHandler负责 JDBC Statement 的创建、参数绑定、SQL 执行。ParameterHandler负责把用户传入的参数对象转换为 JDBC 需要的参数并赋值给 PreparedStatement。ResultSetHandler负责把 JDBC 返回的 ResultSet 转换成 Java 对象列表。它们的创建时机和创建位置都在启动阶段就已经定下来了不是在每次执行 SQL 时才临时生成。2.1 创建时机哪些在启动期哪些在执行期Executor是在SqlSession打开时创建的也就是openSession()时会调用Configuration.newExecutor()创建。但创建逻辑里已经内置了拦截器代理判断。StatementHandler、ParameterHandler、ResultSetHandler则是在每次 SQL 执行时由Configuration.newStatementHandler()等方法创建的——注意它们的创建也会经过拦截器判断。这里的关键点是虽然另外三个对象是执行期创建的但“是否要代理”的规则在启动期已经确定。因为启动时会把拦截器保存在Configuration.interceptorChain里创建对象时直接调用interceptorChain.pluginAll(target)来做代理。所以拦截器的影响范围是在启动阶段通过“注册”确定的真正实施代理则发生在对象创建时。2.2 为什么默认是 JDK 动态代理MyBatis 的拦截器默认基于 JDK 动态代理这跟 Spring 的 AOP 有点像但实现方式完全不同。MyBatis 没有用 AspectJ也没有引入 CGLIB而是自己实现了一个非常巧妙的Plugin类它实现了InvocationHandler接口。这里有个很多人搞混的点MyBatis 的Intercepts注解里可以声明多个Signature每个Signature指定拦截的类、方法名、参数类型。代理创建时Plugin.wrap()方法会检查目标对象是否实现了签名中声明的接口方法如果匹配就生成代理如果完全不匹配就直接返回目标对象本身不生成代理。这意味着什么意味着拦截器的目标接口必须被目标对象实现。比如你拦截Executor.queryExecutor是个接口CachingExecutor和BaseExecutor的实现类都实现了这个接口所以可以做 JDK 代理。但如果你试图拦截一个具体类比如DefaultSqlSession而它没有对应的接口那 MyBatis 也不会报错只是拦截器永远不会生效——因为Plugin.wrap()发现目标对象没有实现对应接口就原样返回了。2.3 拦截器链的本质洋葱模型当一个目标对象同时被多个拦截器命中时interceptorChain.pluginAll()会依次调用每个拦截器的plugin()方法。这个过程会形成一层一层的嵌套代理就像洋葱一样。最后一个注册的拦截器在最外层第一个注册的反而最贴近目标对象。我们用代码来模拟一下这个过程// InterceptorChain.pluginAll 的简化逻辑 public Object pluginAll(Object target) { for (Interceptor interceptor : interceptors) { target interceptor.plugin(target); } return target; }假设你有两个拦截器 A 和 B都拦截Executor.query那么pluginAll的执行顺序是A.plugin(executor) 生成代理 P_A。B.plugin(P_A) 生成代理 P_B。最终得到 P_B调用 query 时先进入 B 的 intercept再进入 A 的 intercept最后到达真实 executor。这跟 Servlet 的 Filter 链非常像。你在intercept()方法里调用invocation.proceed()就是往下一层放行。如果某个拦截器不调用proceed()后续的拦截器和真实对象都不会执行——这也是很多插件能做“短路”操作的原因。3. 拦截器在启动流程中的注册与解析机制现在回到启动流程我们专门来看plugins标签的解析过程。很多初学者以为plugins里配置的拦截器只是被存起来而已实际上它还需要经过参数注入这一步。3.1 pluginElement 解析全过程在XMLConfigBuilder的parseConfiguration()方法里pluginElement(root.evalNode(plugins))负责解析拦截器配置。它的逻辑并不复杂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); } } }这里有几个细节值得展开。第一interceptor属性的值是一个全限定类名resolveClass()最终会调用Resources.classForName()进行反射加载。所以如果你配置的类没有实现Interceptor接口这里会在反射创建实例时抛出ClassCastException而不是等到执行 SQL 时才报错。第二setProperties()方法会在启动阶段被调用而且是在对象创建后立即调用。这意味着什么你可以在Interceptor实现类里重写setProperties()把 XML 中property子标签里的参数注入到实例字段中。比如分页插件的limit参数就是这么注入的。第三addInterceptor()会把拦截器追加到interceptorChain.interceptors这个 List 的末尾并且在这里就已经确定了拦截器的执行顺序——你配置在后面的拦截器反而先执行。3.2 接口定义与实现约束一个标准的 MyBatis 拦截器需要实现Interceptor接口它有三个方法public interface Interceptor { Object intercept(Invocation invocation) throws Throwable; default Object plugin(Object target) { return Plugin.wrap(target, this); } default void setProperties(Properties properties) { // 默认空实现 } }intercept是核心方法你的拦截逻辑写在这里。plugin是负责创建代理的方法默认实现就是Plugin.wrap一般不需要重写除非你有非常特殊的代理需求。setProperties是参数注入入口。注意一点从 3.x 开始plugin和setProperties都有默认实现所以你的拦截器可以只实现intercept方法。但如果你需要自定义代理方式比如要拦截的目标对象不是四大核心对象例如拦截ResultMap的创建过程那你可以重写plugin方法自己返回一个代理对象。这种操作虽然在常规开发中很少见但在一些深度定制框架里确实存在。3.3 拦截器与 Spring 整合时的特殊之处如果你的项目是 Spring Boot MyBatis spring boot starter启动流程会略有变化。SqlSessionFactoryBean中有一个plugins属性可以在配置类里手动注入Bean public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { SqlSessionFactoryBean factoryBean new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); factoryBean.setPlugins(new Interceptor[]{new MyPlugin()}); return factoryBean.getObject(); }注意这里的setPlugins最终会把拦截器添加到Configuration里和 XML 的plugins节点效果一样。但有个坑如果你同时通过 XML 配置了plugins又通过SqlSessionFactoryBean注入了 plugins那拦截器会重复注册同一个拦截器对象会被加入两次执行时也会被调用两次。这个问题排查起来相当隐蔽因为启动流程不会报错只有你打断点时才会发现调用栈里同一个类出现了两次。4. 拦截器代理创建的核心Plugin.wrap 的源码解析说到拦截器就不得不把Plugin类的wrap方法拉出来好好讲一遍。这个方法虽然只有几十行但它决定了拦截器的所有行为。4.1 Plugin.wrap 的完整流程直接看源码逻辑public static Object wrap(Object target, Interceptor interceptor) { MapClass?, SetMethod signatureMap getSignatureMap(interceptor); Class? type target.getClass(); SetClass? interfaces getAllInterfaces(type, signatureMap); if (interfaces ! null interfaces.size() 0) { return Proxy.newProxyInstance( type.getClassLoader(), interfaces.toArray(new Class?[interfaces.size()]), new Plugin(target, interceptor, signatureMap)); } return target; }第一步getSignatureMap会扫描Intercepts注解里的每个Signature构建一个 Mapkey 是拦截的接口类value 是接口中要被拦截的方法集合。如果你在Signature里写了一个不存在的方法名这里不会报错但要等到代理调用时才会发现找不到对应方法——实际上调用时是通过getSignatureMap里的方法集合来判断是否拦截如果方法不存在就不会命中拦截逻辑。第二步getAllInterfaces是关键中的关键。它会遍历目标对象实现的全部接口并检查这些接口是否出现在signatureMap中。只要目标对象实现了任何一个签名中声明过的接口就会返回这个接口的数组。但如果目标对象虽然没有实现接口却是一个 JDK 代理对象比如已经被前一个拦截器代理过那情况就复杂了。4.2 为什么能拦截多层代理当你写了一个分页拦截器又写了一个 SQL 审计拦截器它们同时命中Executor时pluginAll的执行过程是链式的。但如果你仔细观察第二次调用Plugin.wrap()时目标对象已经不是原始的Executor而是第一个拦截器的代理对象Proxy。这个代理对象同样实现了Executor接口所以第二个拦截器也能通过getAllInterfaces发现这个代理实现了Executor接口然后继续生成新的代理。这就是为什么多个拦截器能叠加的原因。而多层代理间的调用顺序完全取决于pluginAll里循环的顺序。这也是我在前面说的洋葱模型——理解了这个你写拦截器时就能很清楚地知道自己的intercept方法会在哪个阶段被调用。4.3 Invocation 对象与 proceed 的语义在intercept方法里你拿到的是Invocation对象。它内部封装了目标对象、被调用的方法、方法参数。invocation.proceed()的语义是“继续执行当前拦截器链的下一个环节”。这跟反射调用还不完全一样。proceed()方法默认实现是这样的public Object proceed() throws InvocationTargetException, IllegalAccessException { return method.invoke(target, args); }注意区分这里target是动态代理中的target字段它指向的是被代理的目标对象而不一定是最初的 Executor。在多层代理场景下A 的代理对象中target指向原始 Executor或者 B 的代理对象取决于注册顺序。所以从 A 的拦截器里调用proceed()会进入 B 的intercept()从 B 的拦截器里调用proceed()才会真正到达原始 Executor。如果你想提前结束链路不调用proceed()后面的拦截器和真实方法都不会执行。这个“短路”特性在有些场景下非常有用。比如你可以实现一个“只读拦截器”当检测到某个操作是被禁止的 SQL 时直接返回一个空结果而不真正执行 SQL。但这种操作要特别小心因为它会绕过数据库语义可能引发事务问题。5. 启动流程中 Mapper 解析与拦截器联动拦截器启动注册搞清楚了我们再看启动流程的另一条主线Mapper 的解析。为什么这条线和拦截器相关因为MapperMethod在执行时会通过SqlSession调用 Executor而 Executor 已经被代理过了所以你在拦截器里拿到的方法签名实际上跟 Mapper 接口方法是一一映射的。5.1 MappedStatement 的构建与 key 规则在XMLMapperBuilder解析 mapper 文件时每个select、insert、update、delete都会构建一个MappedStatement。MappedStatement的 id 默认就是namespace . id除非你设置了useGeneratedKeys或者cache-ref之类的特殊配置否则这个 id 在Configuration.mappedStatements里应该是唯一的。一个常见的问题是如果两个 mapper 文件的 namespace 指向同一个类但 XML 里没有重复 id那没问题如果重复了MyBatis 不会在启动阶段报错而是在第一次调用这个方法时抛出IllegalArgumentException。这不是 MyBatis 设计疏忽而是因为mappedStatements是一个StrictMap它允许添加重复值但获取时会检查一个conflict标记——这个标记是在buildAllStatements()阶段设置的。实际开发中你很难遇到这种情况因为 IDE 一般会直接帮你检查 XML 的 id 冲突但在动态生成 mapper 的场景下就可能踩坑。5.2 参数映射与 TypeHandler 在启动时的作用MyBatis 的TypeHandler注册也是在启动阶段完成的。typeHandlerElement()会解析typeHandlers节点中的每个typeHandler然后注册到Configuration.typeHandlerRegistry中。注册表本身是一个MapTypeHandlerKey, TypeHandlerkey 由 Java 类型和 JDBC 类型共同决定。这里跟拦截器有什么关系关系很大。因为ParameterHandler在给 PreparedStatement 赋值时会根据参数的 Java 类型从typeHandlerRegistry里查找对应的 TypeHandler。如果某个参数类型没有注册 TypeHandler启动阶段不会报错除非你配置了vfsImpl或者自定义类型处理器没找到而是要等到 SQL 执行、参数绑定时才抛出NoTypehandlerFoundException。这就导致一个隐蔽问题你写了一个自定义TypeHandler但忘了在 mybatis-config.xml 里配置或者忘了在 Spring Boot 的配置里指定type-handlers-package。启动时一切正常执行时却报找不到 TypeHandler。如果你对这个流程不熟悉排查半天可能也找不到原因。而如果你熟悉“TypeHandler 注册发生在启动阶段”这一点就能很快意识到是配置遗漏了。5.3 启动时校验ResultMap 和 SqlSource 的生成ResultMap的解析也发生在 mapper 解析阶段。MyBatis 会把resultMap节点解析成ResultMap对象并且如果配置了autoMapping会启用自动映射。这里有个很有意思的细节ResultMap在解析阶段就会尝试把类型别名解析成真实类如果别名不存在启动阶段会直接报错。SqlSource的生成更复杂。每个 SQL 语句节点会被解析为SqlSourceMyBatis 会根据是否包含${}占位符、是否包含script标签等条件创建不同的SqlSource实现。如果是动态 SQL包含if、where、foreach等标签MyBatis 会把它们解析成SqlNode树并在执行时通过DynamicContext动态评估。这一切的解析工作都是在启动阶段完成的也是在内存中构建了大量结构化的 SqlNode 对象。所以不要以为 MyBatis 启动慢只是因为反射加载类实际上它还构建了整棵 SQL 语法树。6. 拦截器在 SQL 执行阶段的实际运转过程启动流程讲完了但拦截器真正的“表演时间”是在 SQL 执行阶段。所以我再花一段篇幅把拦截器在运行时怎么工作讲透。这部分内容也是面试时最容易让面试官眼前一亮的点。6.1 Executor 代理后的调用链路当你调用sqlSession.selectList(statementId)时内部的调用路径大致是SqlSession获取到Executor这个 Executor 已经是经过interceptorChain.pluginAll处理后的代理对象。调用代理的query方法触发拦截器链。拦截器链逐步放行到真实的Executor.query通常是BaseExecutor.query。BaseExecutor.query内部会检查一级缓存如果没有缓存命中则调用doQuery。doQuery中会创建StatementHandler而StatementHandler的创建同样经过interceptorChain.pluginAll。执行StatementHandler.query进入下一层拦截器链。最终创建ParameterHandler和ResultSetHandler它们同样经过拦截器处理。很多人可能没意识到你在拦截Executor.query时如果修改了Invocation的 args比如改写了MappedStatement或者参数对象那后续的StatementHandler创建和 SQL 执行都会受到影响。这个机制是分页插件能做到“改写 SQL”的原理基础——分页插件拦截Executor.query收到参数后把原始 SQL 拼上LIMIT或ROWNUM子句然后把改写后的BoundSql设置回MappedStatement再放行。6.2 一个完整的自定义拦截器示例我们用一个实际可运行的例子来串联全文的各个知识点。下面这个拦截器可以在每次 SQL 执行前打印日志Intercepts({ Signature(type StatementHandler.class, method prepare, args {Connection.class, Integer.class}) }) public class SqlLogInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { StatementHandler statementHandler (StatementHandler) invocation.getTarget(); BoundSql boundSql statementHandler.getBoundSql(); String sql boundSql.getSql().replaceAll(\\s, ); System.out.println(Execute SQL: sql); long start System.currentTimeMillis(); Object result invocation.proceed(); long cost System.currentTimeMillis() - start; System.out.println(Execute cost: cost ms); return result; } }注意这里拦截的是StatementHandler.prepare而不是Executor.query。为什么选prepare因为这时BoundSql已经生成好了参数对象也确定了你既能拿到 SQL也不会影响查询结果的返回类型。拦截Executor.query也可以拿到MappedStatement但要拿到 SQL 还需要从MappedStatement.getBoundSql(parameterObject)里取其实也做得到但更麻烦的是Executor.query有两种常见签名MappedStatementObjectRowBoundsResultHandler或者再加上CacheKeyBoundSql所以拦截器要处理多个签名否则会漏拦截。这个示例很小但它覆盖了一个关键点拦截器不只是能“看” SQL还能“改” SQL。你可以在调用proceed()之前临时修改BoundSql里的 SQL 字符串或者在prepare返回之后修改Connection的设置。这些都是数据库中间件和分页插件的常用手段。6.3 拦截器执行顺序与会话变量传递拦截器链的执行顺序在启动阶段就排好了但你如果想要在拦截器之间传递数据可以参考 MyBatis 官方分页插件 PageHelper 的做法通过ThreadLocal或RequestDataHelper在 Spring Boot 3 中推荐使用TransmittableThreadLocal保存上下文。不要试图用一个静态变量来传递因为 MyBatis 的拦截器在一个应用里通常是单例的静态变量会在并发请求下互相污染。我自己在写一个“数据权限拦截器”时踩过一个坑我在拦截器里通过ThreadLocal保存当前用户的数据权限范围然后在intercept里把权限条件拼接进 SQL。当时为了省事直接用了一个static ThreadLocal结果在高并发下偶尔出现 A 用户查到 B 用户数据的情况。排查了好几个小时最后发现是线程池复用线程导致ThreadLocal没清理干净。后来改成在finally块里显式remove()问题才解决。这个教训就是拦截器无状态化是非常重要的一条设计原则非要传递上下文一定要管理好ThreadLocal的生命周期。7. 启动流程相关的常见问题与排查经验最后一部分把我实际开发中遇过的、和 MyBatis 启动流程相关的典型问题整理成清单。这些问题只要按“启动流程”的思路去排查基本都能很快定位。7.1 启动时卡死或报错ClassNotFound 与 BeanCreationException启动阶段如果报ClassNotFoundException九成是 mapper XML 里的resultType或parameterType写了不存在的类名或者typeAlias别名配置指向了错误的包路径。有个更隐蔽的情况在 Spring Boot 项目里如果mapper-locations配置的路径和实际资源路径不一致会导致 MapperFactoryBean 初始化时发现Configuration里没有对应的MappedStatement然后在启动阶段直接抛BeanCreationException。这个报错信息虽然很吓人但本质就是 mapper 文件没被解析到。7.2 拦截器不生效的几种原因拦截器不生效是出现频率最高的问题。我总结过几类原因你可以直接对照排查拦截的接口或方法签名与实际执行时不匹配。最常见的是拦截Executor.query时漏掉了CacheKeyBoundSql这个重载。拦截器没有正确注册。Spring Boot 项目里如果你用Bean方法返回Interceptor但忘记调用factoryBean.setPlugins拦截器并不会自动生效。Intercepts注解中的type写成了实现类而不是接口。比如写了BaseExecutor.class而不是Executor.class那getAllInterfaces就无法匹配代理不会生成。拦截器被注册了多次导致同一个拦截逻辑执行了两次。这种问题很难发现因为功能看起来“好像是正常的”只是性能变差了。7.3 启动流程与二级缓存的关系二级缓存在启动阶段也有一个重要步骤Cache对象的创建和注册。当你配置了cache节点后XMLMapperBuilder会解析并创建一个Cache对象然后绑定到Configuration的 caches Map 中。这里和拦截器相关的点在于二级缓存默认在Executor的外层套了一个CachingExecutor。这个CachingExecutor是装饰器模式不是代理但它和外层的拦截器代理共存。实际调用链是InterceptorProxy - CachingExecutor - BaseExecutor - doQuery。所以如果你在拦截器里通过invocation.getTarget()获取目标对象拿到的可能是CachingExecutor而不是BaseExecutor。如果你对CachingExecutor做了类型判断要小心这一点。7.4 修改 SQL 时应该拦截哪个对象根据我的经验修改 SQL 文本最稳定的是拦截StatementHandler.prepare因为你可以通过statementHandler.getBoundSql()拿到BoundSql然后使用反射修改其中的 SQL 字段。或者也可以拦截Executor.query通过MappedStatement.getBoundSql(parameterObject)获取BoundSql并修改。但要注意BoundSql里的 SQL 字段是私有的private String sql你要修改它必须用反射Field sqlField BoundSql.class.getDeclaredField(sql); sqlField.setAccessible(true); sqlField.set(boundSql, newSql);这个操作虽然有点“暴力”但它是分页插件和数据权限插件广泛使用的方案。如果你要修改参数值而不是 SQL 文本那拦截ParameterHandler.setParameters更合适可以直接获取ParameterObject并修改其中的值。7.5 如何验证启动流程是否正常最后分享一个验证思路在启动阶段打断点看Configuration.interceptorChain中的拦截器数量再看Configuration.mappedStatements里是否已经包含所有 mapper 语句。这两点确认了启动流程基本就是健康的。如果mappedStatements里缺少某个语句你可以立刻判断是 mapper 文件没有解析到而不是 SQL 执行时才发现问题。我个人很推荐在项目启动时打印一行日志输出来自Configuration的关键统计——比如 registry 里的 TypeHandler 数量、mappedStatements 数量、interceptor 数量。这些信息能让你在排查问题时省大量时间。8. 从启动流程角度看 MyBatis 的设计哲学如果只看一遍源码而不动手调试你可能对 MyBatis 的启动流程只有模糊印象。但如果你把整个流程串起来会发现它其实是一个“两阶段”架构启动阶段尽可能多地把静态配置转化成运行时对象执行阶段则利用这些对象快速完成 SQL 操作。拦截器是这个架构中非常优雅的一个扩展点。它没有侵入启动流程的主线而是通过Plugin.wrap在对象创建时做一个透明代理把扩展能力织入到执行链路中。这种设计比直接在Executor里写一堆 if-else 要干净得多也让第三方插件分页、数据权限、SQL 审计、慢SQL监控都能在不修改 MyBatis 源码的情况下实现深度扩展。从实际工程角度看理解启动流程最大的价值不是面试时背流程而是让你在遇到“启动阶段才报错”和“执行阶段才异常”这两种问题时能很快判断问题的大致方向。比如启动阶段报错基本都是配置解析问题XML 结构、类加载、别名、类型注册。执行阶段报错基本是运行时参数、数据库返回类型、TypeHandler 查找、SQL 语法等问题。我个人花了很多时间反复读XMLConfigBuilder和Plugin这两个类的源码每一次都会有新收获。如果你也想彻底搞懂 MyBatis建议你一定不要只看文章而是自己写一个最简单的 Demo然后启动时打断点跟一遍SqlSessionFactoryBuilder.build()的完整调用链。这个过程比任何教程都有效。