MyBatis启动流程与拦截器机制:从源码到实战的面试组合拳

发布时间:2026/9/29 17:58:06
MyBatis启动流程与拦截器机制:从源码到实战的面试组合拳 1. 为什么把“启动流程”和“拦截器”放在一起讲一套面试组合拳1.1 一个高频面试题卡住一半人的关键在哪兄弟先问你个问题MyBatis的启动流程你能顺口说出几条线再追问一层你自己写的拦截器在启动流程里到底是被谁、在哪一步、怎么注册进去的两个问题分开答不少人都能说个七七八八但面试官要是把这两个揉成一个问题——“结合拦截器描述一下MyBatis的启动流程”当场能打断一半人的思路。这个组合之所以刁钻是因为它考的不是死记硬背而是你对MyBatis生命周期的整体理解。启动流程是“配置加载→对象装配→工厂产出”的阶段拦截器机制则是“启动时注册、运行时生效”的横切点。两者在时间轴上是一前一后的关系但代码层面却咬合得很紧解析plugins节点的逻辑就嵌在启动流程的一长串配置解析步骤中间。你把拦截器的注册时机搞清楚了等于在启动流程的时间线上钉下了一颗钉子前后顺序自然就串起来了。这篇文章就以拦截器为观察视角先走一遍启动流程的主干代码再落到pluginsElement()这个关键方法上最后给出一套可以直接跑的实验代码用自定义拦截器把“启动阶段”和“执行阶段”分别打点让你从日志里反推出整个生命周期。适合正在准备MyBatis面试的Java开发也适合读过源码但总觉得“差点意思”的朋友。1.2 拦截器是启动流程里唯一能“反向观察”的钩子为啥非要用拦截器来观察启动流程而不是直接打断点因为启动流程里的那些类——XMLConfigBuilder、Configuration、TypeAliasRegistry——大多是启动时一次性使用完就丢弃的对象断点打一堆不说还得反复重启。而拦截器给了你三个天然的观察点。第一个观察点在setProperties()。注意这个方法的名字它是在启动流程解析到plugins节点、实例化拦截器之后立刻被调用的。只要你的拦截器在启动时打出了这段日志就证明启动流程已经走到了“插件解析”这一步而且Configuration对象已经存在了。第二个观察点在plugin(Object target)。这个方法是在运行期每次创建Executor、StatementHandler、ParameterHandler、ResultSetHandler这四个核心对象时被调用的。你在重写它的时候可以打印target.getClass()就能精确看到某一个核心对象是在什么时候被创建、被谁创建的。第三个观察点在intercept(Invocation)。它记录的是SQL真正执行的调用链。把这三个点的日志拼起来你得到的就是一幅完整的生命周期图启动阶段注册、会话阶段建对象、执行阶段走调用。这才是“结合拦截器描述启动流程”这道题的正确打开方式。2. MyBatis启动流程主线从XML到SqlSessionFactoryBuilder到Configuration2.1 入口链路SqlSessionFactoryBuilder.build()到底干了什么所有MyBatis应用的启动几乎都从下面这行代码开始String resource mybatis-config.xml; InputStream inputStream Resources.getResourceAsStream(resource); SqlSessionFactory sqlSessionFactory new SqlSessionFactoryBuilder().build(inputStream);顺着build(InputStream)往里追核心链路是这样的我刻意省略了异常包装和 finally 关闭输入流的代码实际源码里这两块是有的public SqlSessionFactory build(InputStream inputStream, String environment, Properties properties) { XMLConfigBuilder parser new XMLConfigBuilder(inputStream, environment, properties); Configuration configuration parser.parse(); return new DefaultSqlSessionFactory(configuration); }这里有个很多人没注意到的细节SqlSessionFactoryBuilder本身是一次性的它的职责就是把“输入流”转换成“配置对象”再交给DefaultSqlSessionFactory之后这个Builder就可以被回收了。真正干活的是XMLConfigBuilder.parse()它会解析mybatis-config.xml的根节点configuration把里面的每一项配置转换成Configuration对象上的字段。而DefaultSqlSessionFactory拿到Configuration之后本身并没有做太多事启动流程的主体工作到这里已经结束了。所以面试的时候你把SqlSessionFactoryBuilder比作“入口保安”把XMLConfigBuilder比作“实际施工队”把Configuration比作“施工图纸”这条链路就非常好记了。2.2 parseConfiguration()里的十几步解析顺序为什么不能乱XMLConfigBuilder.parse()最终会调到parseConfiguration(XNode root)这个方法就是整个启动流程的地图。看源码以MyBatis 3.5.x为例private void parseConfiguration(XNode root) { try { propertiesElement(root.evalNode(properties)); Properties settings settingsAsProperties(root.evalNode(settings)); loadCustomVfs(settings); loadCustomLogImpl(settings); typeAliasesElement(root.evalNode(typeAliases)); pluginsElement(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)); typeHandlersElement(root.evalNode(typeHandlers)); mappersElement(root.evalNode(mappers)); } catch (Exception e) { throw new BuilderException(Error parsing SQL Mapper Configuration. Cause: e, e); } }一共十几步按顺序分别是加载外部properties、读取settings、加载自定义VFS和日志实现、注册别名、解析插件拦截器、配置对象工厂、对象包装工厂、反射工厂、应用settings、解析环境数据源事务、识别数据库方言、注册类型处理器、最后加载Mapper映射。这里有一个值得注意的细节pluginsElement排在typeAliasesElement之后、objectFactoryElement之前并且早于environmentsElement和mappersElement。这个顺序是有依赖关系在里面的。pluginsElement里要用resolveClass(interceptor)加载拦截器类而resolveClass会先查TypeAliasRegistry。如果你在interceptor属性里写的是别名而不是全限定类名前面的别名注册就必须已经完成。反过来拦截器又不依赖数据源和Mapper映射所以它被放在 environments 和 mappers 前面也是很合理的。2.3 Configuration启动流程产出的核心容器拦截器链就挂在上面经过上面十几步解析产出的是一个Configuration实例。你可以把它理解成一个巨大的“注册中心”数据源、事务工厂、类型处理器、别名、Mapper映射、以及我们要重点关注的InterceptorChain全都挂在它身上。Configuration里和拦截器相关的是这段代码protected final InterceptorChain interceptorChain new InterceptorChain(); public void addInterceptor(Interceptor interceptor) { interceptorChain.addInterceptor(interceptor); }InterceptorChain内部就是一个ArrayListInterceptor方法也只有三个addInterceptor往列表里追加pluginAll遍历列表对目标对象逐个包装getInterceptors返回只读列表。看清楚这个数据结构你就知道“注册”这件事的本质了启动流程解析到plugins节点时拦截器被实例化、注入属性、放进InterceptorChain的列表里仅此而已。pluginAll虽然重要但它不在启动流程里被调用而是在每次创建核心对象时被调用。这个时间差就是理解启动流程和拦截器关系的关键。3. 拦截器在启动流程中的注册与装配pluginsElement()源码拆解3.1 pluginsElement()的四行代码对应四个考点再来看pluginsElement的具体实现private void pluginsElement(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)。它先用TypeAliasRegistry解析类名所以interceptor属性既可以写com.example.MyInterceptor这样的全限定名也可以写已经注册的别名。解析失败会在这里直接抛异常启动失败而不是运行期失败。第二getDeclaredConstructor().newInstance()。这是反射调用无参构造方法意味着你的拦截器必须有默认构造器。如果只有一个带参构造启动时这里就会报NoSuchMethodException。这个坑我早期踩过后面细说。第三setProperties(properties)。plugin标签里的每个property子标签都会被转成Properties注入进来。注意时机这是在启动流程里执行的你可以用这个时机做初始化比如读取开关、打印启动日志。第四configuration.addInterceptor(interceptorInstance)。把实例追加到InterceptorChain的列表里完成注册。提示启动阶段只注册拦截器不做任何代理包装。很多人误以为配置完拦截器之后MyBatis启动时就会把四大对象全部代理一遍这是不对的。代理是懒创建的要等到运行期真正new对象时才发生。3.2 自定义拦截器三件套接口、注解、配置一个能真正跑起来的自定义拦截器需要三个部分配合。第一部分是Interceptor接口。它有三个方法intercept(Invocation)是拦截逻辑plugin(Object target)是包装逻辑默认实现是Plugin.wrap(target, this)setProperties(Properties)是属性注入。最低限度要实现intercept其余两个用默认实现就行。第二部分是Intercepts和Signature注解。这是MyBatis插件机制的“瞄准镜”声明你要拦哪个接口、哪个方法、参数列表是什么Intercepts({ Signature(type StatementHandler.class, method prepare, args {Connection.class, Integer.class}) })第三部分是配置文件里的注册。在mybatis-config.xml里加plugins节点或者在Spring/Spring Boot的配置类里注册为Bean。两种方式的原理一致最终都会走进Configuration.addInterceptor()。3.3 后注册的先执行拦截器顺序与代理链方向前面说了InterceptorChain.pluginAll是遍历列表逐个包装那多个拦截器被包装出来之后谁的代理在最外层答案是列表里靠后的拦截器因为pluginAll是这么写的public Object pluginAll(Object target) { for (Interceptor interceptor : interceptors) { target interceptor.plugin(target); } return target; }第一轮包装把target变成代理A第二轮包装把代理A当成新target再包一层代理B。最后的对象结构是“代理B包着代理A包着原始对象”。调用方法时从外往里走所以B先执行A后执行。也就是说在plugins里写在后面的拦截器优先级更高、先执行。这个结论和很多人的直觉相反面试里专门问这个细节的人不少。放到Spring Boot场景里也一样多个Interceptor Bean的注册顺序取决于Bean的创建顺序而Bean的创建顺序又受配置类里方法声明顺序、自动配置顺序、Order等影响。想精确控制拦截器生效顺序最稳妥的办法是在自己的配置类里用一个方法显式addInterceptor不依赖Spring的隐性装配。4. 实操写一个启动流程跟踪拦截器用日志反推生命周期4.1 拦截器代码实现在三个方法里打点理论说再多不如直接跑一个。我写了一个LifecycleTraceInterceptor在setProperties、plugin、intercept三个方法里分别打印日志用来观察MyBatis从启动到执行的全过程。Intercepts({ Signature(type Executor.class, method query, args {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}), Signature(type Executor.class, method update, args {MappedStatement.class, Object.class}), Signature(type StatementHandler.class, method prepare, args {Connection.class, Integer.class}), Signature(type ParameterHandler.class, method setParameters, args {PreparedStatement.class}), Signature(type ResultSetHandler.class, method handleResultSets, args {Statement.class}) }) public class LifecycleTraceInterceptor implements Interceptor { private String name; Override public Object intercept(Invocation invocation) throws Throwable { Object target invocation.getTarget(); System.out.println([intercept] name 拦住 target.getClass().getSimpleName() . invocation.getMethod().getName()); return invocation.proceed(); } Override public Object plugin(Object target) { System.out.println([plugin] name 正在包装 target.getClass().getSimpleName()); return Plugin.wrap(target, this); } Override public void setProperties(Properties properties) { this.name properties.getProperty(name, trace); System.out.println([setProperties] name 实例化完成 说明启动流程已解析到 plugins 节点); } }然后在mybatis-config.xml里注册plugins plugin interceptorcom.demo.LifecycleTraceInterceptor property namename valuelifecycle-trace/ /plugin /plugins这里我故意重写了plugin方法而不是直接依赖默认实现。原因很简单plugin被调用的时机就是核心对象被包装的时机打印出 target 的类型你就能精确知道每个核心对象是什么时候被new出来的。默认实现把包装逻辑封装死了反而看不到这个过程。4.2 运行一次查询看看启动与执行的先后顺序跑一个最简单的查询控制台会依次出现这些日志[setProperties] lifecycle-trace 实例化完成说明启动流程已解析到 plugins 节点 [plugin] lifecycle-trace 正在包装 CachingExecutor [intercept] lifecycle-trace 拦住 CachingExecutor.query [plugin] lifecycle-trace 正在包装 DefaultParameterHandler [plugin] lifecycle-trace 正在包装 DefaultResultSetHandler [plugin] lifecycle-trace 正在包装 RoutingStatementHandler [intercept] lifecycle-trace 拦住 RoutingStatementHandler.prepare [intercept] lifecycle-trace 拦住 DefaultParameterHandler.setParameters [intercept] lifecycle-trace 拦住 DefaultResultSetHandler.handleResultSets对照日志解读一下setProperties只有一行发生在启动阶段对应parseConfiguration里的pluginsElement。这一行之后启动流程继续走完 environments、typeHandlers、mappers 这些步骤但拦截器的日志不会再出现因为启动阶段确实只做注册。真正密集的日志在第一次执行查询时出现。CachingExecutor被包装说明我开了二级缓存如果你没开这里target会变成SimpleExecutor。从这一行能看出打开SqlSession时才会创建Executor而不是启动时创建。紧接着Executor.query被拦截这是SQL执行的入口。进到SimpleExecutor.doQuery之后要建StatementHandler它在构造过程中又顺手创建并包装了DefaultParameterHandler和DefaultResultSetHandler最后RoutingStatementHandler本身也被包装。这几个 plugin 日志连续出现表示“创建映射器语句处理器”这一步正在发生。然后调用链往下走prepare预编译SQL、setParameters绑定参数、handleResultSets映射结果。这三行对应的是执行阶段顺序固定从日志里一眼就能看出来。4.3 一张表看懂启动阶段与运行阶段的边界把上面的日志整理成一张对照表启动和执行的边界就非常清楚了日志出现点发生阶段对应环节说明setProperties启动阶段pluginsElement()解析plugins拦截器实例化 属性注入 注册到拦截器链plugin包装CachingExecutor运行阶段openSession()创建 Executor二级缓存开启时 target 是 CachingExecutor否则是 SimpleExecutorinterceptExecutor.query/update运行阶段sqlSession 调用SQL入口拦截器在代理层先于真实方法执行plugin包装ParameterHandler运行阶段创建 StatementHandler 的内部构造在BaseStatementHandler构造时触发plugin包装ResultSetHandler运行阶段同上结果集处理器也被纳入拦截链plugin包装RoutingStatementHandler运行阶段newStatementHandler()尾部最外层 StatementHandler 才被包装interceptprepare/setParameters/handleResultSets运行阶段SQL预编译、参数绑定、结果映射三大执行环节的拦截点这张表拿去应付面试其实已经够了。但把这套日志放在实际项目里跑一遍比背十遍源码都记得牢。我建议你自己照着这个拦截器改一改把System.out换成日志框架然后把二级缓存开关分别打开、关闭各跑一次对比CachingExecutor和SimpleExecutor的差异对MyBatis整个生命周期会有一个非常直观的认识。5. 拦截器代理链与四大核心对象的插桩机制5.1 Plugin.wrap为什么拦截器只能拦这四个接口为什么拦截器只能拦那四个接口答案在Plugin.wrap的实现里。wrap做的事情是解析Signature注解拿到方法签名表然后遍历target的接口找到与签名类型匹配的接口做JDK动态代理。public static Object wrap(Object target, Interceptor interceptor) { MapClass?, SetMethod signatureMap getSignatureMap(interceptor); Class? type target.getClass(); SetClass? interfaces getAllInterfaces(type, signatureMap); if (interfaces.size() 0) { return Proxy.newProxyInstance( type.getClassLoader(), interfaces.toArray(new Class?[interfaces.size()]), new Plugin(target, interceptor, signatureMap)); } return target; }注意getAllInterfaces的返回条件只有同时满足“target实现了该接口”和“签名声明了该接口”两个条件的接口才会被纳入代理。如果某个拦截器声明了根本不存在的接口组合或者target压根不实现签名里的接口interfaces列表为空wrap会原样返回target——拦截器静默失效但启动流程不会报错。这就是很多人“拦截器配了但没生效”的根本原因之一。启动时一切正常运行时也没有异常但那个方法就是没被拦截。排查方向就是检查target类型和签名类型是否匹配。Plugin实现了InvocationHandler它的invoke逻辑也很简单当前被调用的方法如果命中签名表就走interceptor.intercept(new Invocation(target, method, args))没命中就反射调用原方法。这个“命中才拦、没命中放行”的策略保证了拦截器不会干扰MyBatis内部的正常调用。5.2 Executor与StatementHandler两个关键插桩时机XML配置里注册的拦截器到底在哪一行代码被用上这个问题的答案藏在Configuration的两个工厂方法里。第一个是newExecutor它在SqlSession打开时被调用public Executor newExecutor(Transaction transaction, ExecutorType executorType) { // ... 根据 executorType 创建 SimpleExecutor / ReuseExecutor / BatchExecutor if (cacheEnabled) { executor new CachingExecutor(executor); } executor (Executor) interceptorChain.pluginAll(executor); return executor; }注意顺序先根据ExecutorType创建基础执行器如果启用了二级缓存就包一层CachingExecutor最后才调用pluginAll把整个对象链再包装一次。所以拦截器看到的target经常是CachingExecutor而不是SimpleExecutor。第二个是newStatementHandler它在每次SQL执行时被调用public StatementHandler newStatementHandler(Executor executor, MappedStatement mappedStatement, ...) { StatementHandler statementHandler new RoutingStatementHandler(...); statementHandler (StatementHandler) interceptorChain.pluginAll(statementHandler); return statementHandler; }RoutingStatementHandler只是一个路由壳子构造时会根据SQL类型创建PreparedStatementHandler等实际处理器。真正干活的方法都在这些实际处理器里但它们本身不参与pluginAll代理只套在RoutingStatementHandler外面。这也是为什么很多面试题会问“拦截器拦的是RoutingStatementHandler还是PreparedStatementHandler”——答案是前者。5.3 ParameterHandler与ResultSetHandler藏在构造函数里的包装很多人以为四大对象是分别独立包装的其实ParameterHandler和ResultSetHandler的包装是在BaseStatementHandler的构造函数里顺手完成的// BaseStatementHandler 构造方法内部 this.parameterHandler configuration.newParameterHandler(mappedStatement, parameterObject, boundSql); this.resultSetHandler configuration.newResultSetHandler(executor, mappedStatement, rowBounds, parameterHandler, resultHandler, boundSql);而newParameterHandler和newResultSetHandler内部同样调用了interceptorChain.pluginAll(...)。所以当你看到RoutingStatementHandler被包装的日志之前会先看到DefaultParameterHandler和DefaultResultSetHandler被包装的日志——这个先后关系正是代码执行顺序的忠实镜像。到这里可以总结一个记忆口诀启动时注册运行期包装构造期生成子处理器调用期命中即拦。面试官问你“拦截器什么时候生效”标准答案不是“启动时”而是“首次创建四大对象时通过 interceptorChain.pluginAll 逐个包装生效”。6. 拦截器没生效常见问题与排查技巧实录6.1 三大排查方向签名、注册、顺序我把这些年见过的拦截器问题归成三类按出现频率排一下。第一类Signature 方法签名不匹配。这是最高频的坑。Executor.query在接口里有两个重载一个带CacheKey和BoundSql一个不带。你只拦了四参的query但MyBatis内部实际走的是六参的query拦截器自然不触发。解法是把Executor接口源码打开逐个对着签名抄参数类型一个都不能差。记住MyBatis校验签名用的是equals多一个参数、少一个参数、类型顺序不对全部不命中。第二类拦截器注册位置不对。在Spring Boot项目里如果你同时用了mybatis-config.xml和配置类注入Interceptor可能出现重复注册或者注册顺序错乱。我的建议是二选一纯XML项目用pluginsSpring Boot项目直接用配置类把Interceptor显式add进去。第三类多个拦截器之间的执行顺序反了。后注册的先执行这个规则在前面已经讲透了。分页插件、审计插件这种对执行顺序敏感的场景一旦顺序错了表现就是分页数据不对或者审计日志漏记。排查时先打印全部注册的拦截器列表再根据业务需要调整注册顺序。现象可能原因排查方法拦截器完全没触发签名不匹配 / 没注册 / 接口没实现打印InterceptorChain.getInterceptors()核对注解执行了但日志没打命中签名但方法内部条件不满足在plugin方法里打点确认包装发生多拦截器执行顺序反了后注册的先执行调整plugins声明顺序或Bean创建顺序启动直接抛异常拦截器没有无参构造 / 类名写错检查默认构造器检查interceptor属性全限定名6.2 踩过的坑拦截器内部递归与生产环境日志有一个坑特别值得单独拎出来说如果你的拦截器拦截了Executor.query而拦截逻辑内部又通过SqlSession去执行了一次SQL就会触发二次拦截。这种代码看着没问题运行起来却可能栈溢出或者逻辑混乱。我自己处理这类需求时习惯在拦截器里加一个ThreadLocal标志位进入拦截器时置位执行完真实方法后复位内部二次查询命中标志位就直接放行。这个技巧在写“操作审计”“数据权限”这类拦截器时非常实用。另外生产环境的拦截器日志一定要用日志框架千万别用System.out。上面演示代码用System.out是为了教学直观但线上拦截器往往在热门SQL上高频触发打印到标准输出会严重拖垮性能。日志里尽量不要打印整个MappedStatement或BoundSql对象SQL文本、参数列表这些信息量很大字段又多容易把日志刷爆。我一般只打印方法名、目标类型、耗时必要时才输出截断后的SQL。6.3 把拦截器思路复用到框架排查上理解了拦截器在启动流程里的注册机制之后排查其他框架问题会顺手很多。比如MyBatis-Plus的分页插件PaginationInnerInterceptor、乐观锁插件OptimisticLockerInnerInterceptor本质都是Interceptor实现走的是同一套启动注册流程。你在Spring Boot里看到“分页失效”这类问题第一反应就应该是去检查拦截器是否注册成功、注册顺序是否被别的拦截器影响而不是去翻分页代码。再比如想在项目里统一打印慢SQL日志完全可以用上面这套思路写一个拦截StatementHandler的拦截器在prepare前后记录耗时超过阈值就告警。因为prepare里面有真实的Connection预编译调用这个位置的耗时能反映数据库连接获取和SQL预编译的实际情况比在业务代码里手动打点可靠得多。最后再分享一个实际操作中的体会拦截器这套机制代码量不大但牵扯的知识点非常集中——动态代理、责任链、反射、启动顺序、运行期对象创建时机全串在一起了。我当初为了彻底吃透它照着上面的实验代码改了不下十版拦截器从只拦Executor到四个对象全部打点再到加ThreadLocal防重入、加耗时统计。把这一套跑顺了之后再去看MyBatis源码里那些工厂方法基本就是按图索骥。你要是也在准备面试别急着背答案先把这个拦截器跑起来日志会教你一切。