
1. 从一次线上慢查询说起为什么我们要深入Mybatis源码那天下午监控系统突然告警一个核心接口的响应时间从平时的50ms飙升至2秒以上。团队立刻进入战斗状态数据库监控显示CPU使用率正常但慢查询日志里多出了几条执行时间超过1秒的SQL。奇怪的是这些SQL的查询条件看起来并不复杂而且索引也是齐全的。我们第一时间怀疑是Mybatis的映射文件写错了或者参数绑定出了问题。经过一番紧张的排查最终定位到问题出在一个不起眼的动态SQL标签if上它内部的条件判断逻辑在特定参数组合下生成了我们意料之外的SQL片段导致全表扫描。这次经历让我深刻意识到仅仅会使用Mybatis的API和XML配置是远远不够的。当系统复杂度上升遇到性能瓶颈、诡异的N1查询问题、动态SQL的“灵异”行为或者需要深度定制插件来满足审计、分页等非功能性需求时对Mybatis核心运行机制的理解就成了解决问题的关键。网上零散的“面试宝典”和“配置教程”只能解决表面问题真正要驾驭它必须深入到源码层面理解它的设计哲学和运行脉络。Mybatis作为一个优秀的半自动化ORM框架其核心价值在于将SQL的灵活性与Java对象的便捷操作相结合。但这份灵活性也带来了复杂性。它的源码就像一张精密的地图指引着我们理解SQL是如何从一串XML文本或注解变成在数据库里执行的命令参数是如何安全地绑定结果又是如何神奇地填充到我们定义的Java对象中的。接下来我将结合自己多次阅读源码和解决实际问题的经验带你一起拆解Mybatis的核心骨架我们不仅看它“是什么”更要弄明白它“为什么”这样设计。2. 骨架初探Mybatis的核心运行流程与三大件要理解Mybatis不能一上来就钻进某个类的细节里那样很容易迷失。我们必须先站在高处俯瞰它的核心工作流程。简单来说Mybatis完成一次数据库操作可以概括为以下几个核心阶段配置加载与解析阶段这是一切的起点。Mybatis通过SqlSessionFactoryBuilder读取mybatis-config.xml全局配置文件以及所有的Mapper.xml文件。这个过程会构建出整个框架运行所需的“世界模型”——Configuration对象。这个对象是单例的是Mybatis的配置信息中心包含了数据源、事务管理器、所有映射语句MappedStatement、缓存等一切信息。会话创建阶段每次数据库操作都需要通过SqlSessionFactory创建一个SqlSession。你可以把SqlSession理解为一次数据库会话或一次工作单元。它提供了执行SQL、获取Mapper接口代理对象、管理事务等核心方法。这里有个关键点SqlSession只是一个门面真正的执行逻辑委托给了Executor执行器。SQL执行阶段这是最复杂也最精彩的部分。当我们调用SqlSession.selectOne()或通过Mapper接口调用一个方法时Mybatis会找到对应的MappedStatement然后交给Executor去执行。Executor会先查询缓存如果开启然后通过StatementHandler来创建PreparedStatement对象接着由ParameterHandler处理参数映射执行SQL最后由ResultSetHandler将结果集映射成Java对象。这个流程中的Executor、StatementHandler、ParameterHandler、ResultSetHandler它们被称为Mybatis的“四大组件”。但在我看来Configuration、SqlSession和Executor才是支撑起整个流程的“三大件”。Configuration配置的宇宙所有在XML和注解里写的东西最终都会解析到Configuration对象里。它内部有几个非常重要的MapMapString, MappedStatement mappedStatements: 键是namespace.id值是封装了一条SQL所有信息的MappedStatement对象它是执行时的蓝图。MapString, ResultMap resultMaps: 存储所有的结果集映射规则。MapString, Cache caches: 存储命名空间级别的缓存。 这个对象在初始化后基本是只读的保证了线程安全。理解Configuration你就拿到了Mybatis的全局配置清单。SqlSession用户交互的门面我们平时打交道最多的就是它。但DefaultSqlSession本身并不干活它内部持有一个Executor引用所有方法如selectList、insert都是转调Executor的对应方法。这种门面模式简化了用户接口将复杂的执行逻辑隐藏在后面。一个常见的误区是认为SqlSession是线程不安全的所以要在方法内用完即关。其实不安全的根本原因是它内部持有的Executor可能关联着数据库连接和事务状态在并发环境下混用会导致数据混乱。因此最佳实践是确保每个线程使用独立的SqlSession通常通过SqlSessionTemplate与Spring集成来自动管理。Executor真正的执行引擎这是Mybatis的核心调度者。它有几种实现SimpleExecutor最简单的实现每次执行都会创建一个新的Statement对象用完后关闭。ReuseExecutor会重用预处理语句PreparedStatement以减少创建和解析的开销。BatchExecutor用于批量操作。CachingExecutor装饰器模式为其他Executor添加二级缓存功能。默认情况下如果不配置Mybatis使用的是SimpleExecutor。Executor的职责链包括获取连接、准备语句、参数处理、执行SQL、结果处理、事务提交/回滚等。插件Plugin机制也正是通过动态代理拦截Executor以及另外三个组件的方法来实现功能的。提示在阅读源码时我建议以一次简单的查询为线索用调试模式跟着程序走一遍这个流程。从SqlSessionFactory.openSession()开始到sqlSession.selectList(“namespace.id”)一步步跟进。你会清晰地看到Configuration如何被查找Executor如何被调用StatementHandler如何被创建。这个过程是理解Mybatis源码最有效的捷径。3. 映射语句的诞生从XML/注解到MappedStatement我们写在Mapper接口和XML文件里的那些Select注解或者select标签最终是如何变成Mybatis能够执行的指令的呢这个过程发生在配置加载阶段核心类是XMLConfigBuilder和XMLMapperBuilder。配置文件的解析之旅SqlSessionFactoryBuilder.build()方法会创建一个XMLConfigBuilder来解析全局配置文件。这个解析过程是递归的解析properties处理占位符。解析settings这决定了Mybatis的运行时行为比如是否启用缓存、是否使用延迟加载、日志实现等。这里隐藏了一个性能关键点lazyLoadingEnabled延迟加载和aggressiveLazyLoading侵略性延迟加载的设置直接影响关联查询的SQL发送策略设置不当会导致著名的“N1查询问题”。解析environments建立起数据源(DataSource)和事务管理器(TransactionFactory)。解析mappers这是重头戏。对于每个Mapper会创建一个XMLMapperBuilder来专门解析对应的XML映射文件。Mapper XML的深度解析XMLMapperBuilder.parse()方法负责将我们熟悉的mapper节点转化为Configuration中的元数据。对于每一个select|insert|update|delete节点解析SQL命令类型、语句ID、参数类型、返回类型等基础属性。解析SQL脚本这是动态SQL发挥作用的地方。节点内的文本和子标签如if,where,foreach并不会被直接当作SQL字符串。Mybatis会使用XMLScriptBuilder来解析这些节点构建出一个SqlSource对象。SqlSource可以理解为“SQL源”它知道如何根据传入的参数对象产出一个可执行的SQL字符串BoundSql。对于静态SQL没有if等标签它是RawSqlSource对于动态SQL它是DynamicSqlSource。DynamicSqlSource内部包含了一个SqlNode的根节点通常是MixedSqlNode它组合了各种动态标签对应的SqlNode如IfSqlNode,TrimSqlNode,ForEachSqlNode。在运行时根据参数值这些SqlNode会决定是否将自身包含的SQL片段拼接到最终的SQL中。解析结果映射resultMap标签会被解析成ResultMap对象它包含一个ListResultMapping定义了数据库列名到Java对象属性的复杂映射关系包括嵌套查询association,collection和自动映射。构建MappedStatement最后将上面解析出的所有元素——SqlSource、ResultMap、语句ID、配置等——封装进一个MappedStatement对象并以namespace.id为键存入Configuration.mappedStatements这个大的注册表中。关于Mapper接口的绑定你可能会有疑问XML解析完了那Mapper接口呢这个过程是通过MapperRegistry和MapperAnnotationBuilder完成的。在解析Mapper XML的后期XMLMapperBuilder会尝试绑定对应的Mapper接口。绑定过程主要是为接口中的每个方法在Configuration中找到对应的MappedStatement通过namespace.methodName匹配。如果方法使用了注解如SelectMapperAnnotationBuilder会直接根据注解内容创建MappedStatement。最终通过JDK动态代理为Mapper接口生成一个代理对象。当我们调用mapper.selectUser(1)时实际上调用的是代理对象的invoke方法该方法会转而调用SqlSession的对应方法如selectOne并传入之前存储好的MappedStatement的ID。注意很多人对#{}和${}的区别停留在“防SQL注入”的层面。从源码角度看它们的处理时机和方式截然不同。#{}在SqlSource被解析为BoundSql时会被替换成?占位符其参数值由ParameterHandler在后续阶段通过PreparedStatement.setXXX()来设置这是安全的。而${}在SQL解析阶段就会被直接替换成对应的参数值字符串是简单的文本替换因此存在注入风险通常仅用于动态指定表名、列名等非值参数。4. 执行引擎的奥秘Executor与插件机制当SqlSession拿到一个方法调用请求和对应的MappedStatementID后它就退居二线把舞台交给了Executor。Executor是执行过程的指挥官它的设计充分体现了责任链和模板方法模式。一次查询的微观旅程以CachingExecutor.query()为例这是最常用的场景因为二级缓存默认是启用的缓存查询首先根据MappedStatement的ID、查询参数、行边界等生成一个缓存Key。然后去二级缓存TransactionalCache中查找。如果命中直接返回。委托执行如果缓存未命中则调用被装饰的BaseExecutor比如SimpleExecutor的query()方法。BaseExecutor会先查询一级缓存本地缓存PerpetualCache如果命中则返回。数据库查询如果一二级缓存均未命中则执行queryFromDatabase()。这是一个模板方法其中调用了抽象方法doQuery()。以SimpleExecutor.doQuery()为例 a.创建StatementHandler根据MappedStatement的StatementTypeSTATEMENT,PREPARED,CALLABLE创建对应的StatementHandlerSimpleStatementHandler,PreparedStatementHandler,CallableStatementHandler。 b.准备语句调用StatementHandler.prepare()获取Connection并创建Statement/PreparedStatement。 c.参数处理调用StatementHandler.parameterize()-ParameterHandler.setParameters()这里会遍历BoundSql中的参数映射列表使用TypeHandler将Java类型的参数值设置到PreparedStatement的占位符上。 d.执行与结果映射调用StatementHandler.query()执行SQL然后通过ResultSetHandler.handleResultSets()将返回的ResultSet转换成我们指定的结果对象ListObject或单个Object。这个过程涉及复杂的嵌套查询和延迟加载判断。缓存写入查询结果返回后BaseExecutor会将其写入一级缓存。CachingExecutor在事务提交后才会将结果真正提交到二级缓存。插件机制无侵入的扩展点Mybatis的插件是其框架设计中最精妙的部分之一它基于JDK动态代理实现可以在不修改核心代码的情况下拦截四大组件Executor,StatementHandler,ParameterHandler,ResultSetHandler的方法。实现原理所有插件都需要实现Interceptor接口并标注Intercepts和Signature注解指定要拦截的组件类型、方法及参数。在配置加载时InterceptorChain会收集所有插件。代理包装当Mybatis创建上述四大组件的实例时会调用InterceptorChain.pluginAll()方法。这个方法会遍历所有插件对当前对象进行层层包装Plugin.wrap(target, interceptor)。最终得到的对象是一个被多个代理层层包裹的目标对象。执行拦截当调用被代理对象的方法时会经过插件链。每个插件的intercept()方法可以决定是否执行目标方法、何时执行、以及如何处理执行结果。经典的PageHelper分页插件就是通过拦截Executor的查询方法在原有SQL上动态添加LIMIT子句实现的。踩坑实录插件执行顺序与代理链我曾遇到一个自定义插件失效的问题。插件意图在SQL执行前后打印日志但配置后毫无反应。调试后发现问题出在插件的Signature注解上我指定拦截的Executor方法不够精确。更深层的原因是插件的执行顺序与它们在配置文件中的声明顺序相反类似栈后配置的先执行。如果多个插件拦截同一个方法它们会形成一个代理链。理解这个链式调用顺序对于编写复杂的插件至关重要。例如如果你的插件需要修改SQL它最好在分页插件之后执行这样才能对已经添加了分页条件的SQL进行修改。5. 结果映射的魔法ResultSetHandler与延迟加载将ResultSet转换成Java对象是ORM框架的灵魂。Mybatis的DefaultResultSetHandler承担了这个繁重的任务它的handleResultSets()方法是结果映射的入口。自动映射与ResultMap结果映射有两种方式自动映射和基于ResultMap的显式映射。自动映射当查询的列名或别名与Java对象的属性名驼峰转换后匹配时Mybatis会自动调用属性的setter方法进行填充。这背后是MetaObject在起作用它通过反射和缓存机制高效地操作对象属性。显式映射通过resultMap定义可以处理更复杂的场景如列名与属性名不一致、处理枚举类型、进行类型转换通过TypeHandler以及处理关联关系一对一association、一对多collection。嵌套查询与“N1”问题association和collection标签可以通过select属性指定另一个查询的ID来实现嵌套查询。例如查询Blog时通过author_id再去查Author。这是“N1”问题的典型来源如果查询出N篇博客就会触发N次对作者的查询。Mybatis提供了两种解决方案嵌套结果映射使用association的resultMap属性通过JOIN SQL一次性查出所有数据然后在结果映射层面进行对象的组装。这需要编写复杂的SQL和ResultMap但性能最优。延迟加载在全局设置中开启lazyLoadingEnabled并在嵌套映射中设置fetchType“lazy”。这样关联对象只有在被真正访问时才会触发查询。延迟加载的源码实现延迟加载是Mybatis高级特性之一其实现非常巧妙。它并不是在ResultSetHandler里直接返回真实对象而是返回一个由Javassist或CGLIB生成的代理对象。当DefaultResultSetHandler创建结果对象时如果发现某个属性配置了延迟加载它不会立即执行嵌套查询。它会为该属性创建一个ProxyFactory默认是JavassistProxyFactory生成一个代理对象并将触发加载所需的信息如Executor、MappedStatement、参数等封装到一个MethodInvoker或类似的触发器中存入代理对象。当程序第一次调用代理对象的getter方法时拦截器会触发执行之前保存的嵌套查询并将真实对象加载回来替换掉代理对象本身或填充其属性。注意延迟加载虽然方便但需要警惕两个坑。第一是“序列化陷阱”被代理的对象在序列化/反序列化后代理会失效可能导致延迟加载触发异常。第二是作用域问题延迟加载的执行需要SqlSession仍然处于打开状态。在Web应用中如果采用“每次请求一个Session”的模式在视图层如JSP渲染时尝试延迟加载而此时SqlSession已经关闭就会抛出异常。这就是为什么常使用OpenSessionInView模式或在Service层就完成所有必要数据的加载。6. 动态SQL的编译与执行SqlSource与SqlNode动态SQL是Mybatis区别于其他ORM框架的一大特色。它允许我们在XML中编写带有逻辑判断的SQL片段。理解其原理能帮助我们写出更高效、更安全的动态SQL。从XML标签到SqlNode树在配置解析阶段XMLScriptBuilder会将select节点下的所有内容解析成一棵SqlNode树。每个动态标签对应一种SqlNode实现StaticTextSqlNode: 代表纯文本SQL片段。IfSqlNode: 包含test表达式根据OGNL表达式结果决定是否包含其子节点。TrimSqlNode/WhereSqlNode/SetSqlNode: 用于智能处理SQL片段的前缀、后缀和多余连接词如AND, OR, 逗号。ForEachSqlNode: 处理循环生成(item1, item2, ...)或item1, item2这样的片段。VarDeclSqlNode: 变量声明。运行时从SqlNode到BoundSql当执行查询时DynamicSqlSource.getBoundSql()方法会被调用。它做了两件核心事创建DynamicContext这是一个上下文对象持有参数对象parameterObject和一个StringJoiner用于拼接SQL。应用SqlNode树从根SqlNode通常是MixedSqlNode开始调用其apply(DynamicContext context)方法。每个SqlNode根据当前参数和自身逻辑决定是否向context的StringJoiner中追加SQL文本片段。例如IfSqlNode会使用OGNL引擎评估test表达式如果为真则执行其子节点的apply方法。生成BoundSql遍历完整个SqlNode树后DynamicContext中就得到了完整的、已进行过文本替换处理了${}的SQL字符串。同时在遍历过程中所有遇到的#{}占位符信息参数映射ParameterMapping也被收集起来。最终用SQL字符串和参数映射列表构建出BoundSql对象。性能考量与最佳实践动态SQL的解析是在运行时每次执行都会发生的吗对于DynamicSqlSource是的因为每次参数不同生成的SQL可能不同。但对于RawSqlSource静态SQL其BoundSql在框架初始化时就创建好了可以被复用性能更高。 因此一个重要的优化建议是尽量减少动态SQL的复杂度将能静态化的部分尽量静态化。例如避免在循环中嵌套大量的if判断。对于极其复杂且多变的查询条件可以考虑使用choosewhen.../choose结构或者评估是否适合用Provider注解SelectProvider在Java代码中动态构建SQL以获得更强的逻辑控制能力。7. 类型处理的桥梁TypeHandlerTypeHandler是Mybatis中处理Java类型与JDBC类型相互转换的桥梁。无论是参数设置ParameterHandler还是结果获取ResultSetHandler都离不开它。内置的智慧Mybatis为所有常见的Java类型String,Integer,Date等和JDBC类型都提供了默认的TypeHandler。例如IntegerTypeHandler负责java.lang.Integer与JDBC INTEGER之间的转换。这些处理器在TypeHandlerRegistry初始化时就被注册好了。自定义TypeHandler当我们需要处理特殊类型时比如将数据库中的VARCHAR存储的JSON字符串映射为Java的Map或自定义对象或者将枚举类型按自定义的code值存储就需要自定义TypeHandler。实现TypeHandlerT接口或继承BaseTypeHandlerT通常继承后者更方便我们只需要重写四个方法setNonNullParameter设置参数、getNullableResult三个重载从ResultSet、CallableStatement或列名获取结果。注册在mybatis-config.xml中通过typeHandlers标签注册或者在Mapper XML的resultMap中指定某个列的typeHandler属性。源码中的执行时刻参数设置在DefaultParameterHandler.setParameters()中它会遍历BoundSql中的ParameterMapping列表为每个映射找到对应的TypeHandler然后调用typeHandler.setParameter(ps, i, value, jdbcType)。结果获取在DefaultResultSetHandler.getPropertyMappingValue()中当根据ResultMapping获取结果值时会使用指定的或自动选择的TypeHandler调用typeHandler.getResult(rs, columnName)来获取Java对象。理解TypeHandler机制不仅能解决特殊数据类型的映射问题还能让我们更清晰地看到数据在Java世界和数据库世界之间流动的细节。例如在处理数据库datetime字段时是映射为java.util.Date还是java.time.LocalDateTime选择不同的TypeHandler会有不同的行为这需要与数据库驱动和实际业务需求相结合。