☰
MyBatis 源码精读:初始化、执行链路、缓存与类型转换全解析
2026/10/10 22:14:46 网站建设 项目流程

我这些年读过不少开源框架源码,MyBatis 是少有的、源码读起来不吃力的一个。它不像 Spring 那样动不动就从注解扫描绕一大圈,也不像 Netty 那样在并发模型上反复横跳。MyBatis 的核心流程基本就是“配置文件 → 解析成对象 → 拿对象去执行 SQL → 处理结果集”,一条直线走完。但这条直线的每一站里都藏着不少东西:XMLConfigBuilder 怎么解析配置、一级缓存和二级缓存为什么“玄学失效”、TypeHandler 在参数和结果集转换中到底扮演什么角色、Mapper 接口为什么不需要写实现类也能调用——把这些问题在源码里过一遍,面试问 MyBatis 基本不会再有死角。

这篇内容不是教你怎么背面试题,而是基于我实际读源码的经验,把 MyBatis 初始化、执行、缓存、类型转换这四条主线的关键代码讲透,顺带解决一批高频问题。适合这几类读者:正在准备 Java 面试,想用源码细节拉开差距的;项目里遇到“缓存不生效”“条件没拼进去”“批量插入特别慢”这类诡异问题,想自己定位的;还有刚想迈入源码阅读门槛,需要一个不那么劝退的切入点的开发者。

1. 源码阅读前的三个准备:架构认知、版本选择和排查思路

1.1 先看懂 MyBatis 的分层架构:三层半模型

MyBatis 的整体架构用一个词来形容就是“管线”:从入口到数据库,数据沿着一条清晰的链路流动。我习惯把它拆成三层半。

第一层是接口层,也就是 SqlSession、SqlSessionFactory、SqlSessionFactoryBuilder 这套东西。你在业务代码里直接接触的 SqlSession,或者通过 Mapper 接口间接调用的 SqlSession,都属于这一层。第二层是核心处理层,负责真正执行 SQL,主要成员是 Executor、StatementHandler、ParameterHandler、ResultSetHandler。第三层是基础支撑层,包含 Configuration、TypeHandler、TypeAliasRegistry、缓存、数据源等,这些组件为上面两层提供通用的基础设施。剩下那半层是 XML 解析与绑定,包括 XMLConfigBuilder、XMLMapperBuilder、XMLStatementBuilder,它们负责把 XML 描述翻译成 Java 对象。

怎么理解这三层?可以把这个流程想象成一家餐厅:接口层是前台的点单入口,你只需要对服务员说要什么菜;核心处理层是后厨,切菜、配菜、炒菜都在这个流水线上完成;基础支撑层是食材仓库和调料架,各种豆子、面条、瓶瓶罐罐都是提前备好的。XML 解析这块则像菜谱的翻译员,把“宫保鸡丁”这种菜名翻译成厨师看得懂的备菜单。读源码时先问自己是站在哪一层,再决定往哪个包跳,效率会高很多。

1.2 选对源码版本:我建议 3.5.x

源码阅读第一步,先确认自己要读哪个版本。我强烈建议选择 MyBatis 3.5.x,最好是 3.5.9 以上。原因有两个:一是这个版本的代码结构已经非常稳定,网上能搜到的大量资料、调试经验都基于这一系;二是它支持现代 JDK 和主流数据库驱动,拿来直接编译跑测试用例不会遇到“配了三天环境”这种劝退问题。

从仓库把代码 clone 下来之后,你会看到 org.apache.ibatis 包下很有规律的目录,我列几个关键包:

  • builder:各种 Builder,XMLConfigBuilder、XMLMapperBuilder 都在这;
  • session:SqlSession、SqlSessionFactory;
  • executor:执行器、缓存、结果集处理的核心链路;
  • mapping:MappedStatement、ResultMap、SqlSource 这些核心模型;
  • type:TypeHandler 以及各种类型处理器;
  • scripting:动态 SQL 解析,XMLScriptBuilder 和 SqlSource 就在这里。

先花十分钟把包结构记住,后面打断点跳来跳去时会很有方向感。很多初学者读源码失败,不是能力问题,而是上来就扎进某个类逐行读,没有先建立地图。地图在手,代码就是故事。

1.3 我的源码阅读顺序:由外到内,由调用到实现

具体读源码怎么切入?我的建议是先跑通一个最小环境,再跟着调用栈走。不要先读 Xxx.java 的源码,而是准备一个 Spring Boot + MyBatis 的最小项目,Mapper 里写一个简单的 select 查询,然后在启动和查询的关键位置打断点。

我第一次读 MyBatis 源码时的路线是:

  1. 在 SqlSessionFactoryBuilder.build() 打第一个断点,看 Configuration 是怎么从 XML 里建出来的;
  2. 顺着 XMLConfigBuilder.parse() 往里跳,观察各个标签的解析过程;
  3. 真正执行 SQL 时,在 SqlSession.selectList() 打断点,跟 Executor 那套调用链;
  4. 遇到不认识的组件,先看接口定义,再看实现类数量,挑主实现去读。

这种由外到内、由调用到实现的方式,能保证你读的每一行代码都是“当前场景真实需要的”。源码阅读最怕的是“想着某个类,然后读着读着游到九霄云外”,有了断点和调用栈约束,思路很难跑偏。

2. XMLConfigBuilder 工作流程:配置文件是怎么“长出”Configuration 的

2.1 Configuration:MyBatis 的中央仓库

要理解 XMLConfigBuilder,必须先理解它想构建的目标:Configuration。这是整个 MyBatis 运行时唯一的核心数据模型,可以看作一个中央仓库,所有配置信息都被塞进这个对象。

看一眼 Configuration 的字段就能猜到它的职责:mappedStatements 保存了所有 MappedStatement,key 是 namespace.id;resultMaps 保存结果映射规则;typeAliasRegistry 管理类型别名;typeHandlerRegistry 管理类型处理器注册;environments 保存数据源和事务工厂;caches 和 cacheNames 管二级缓存。基本上,你在 XML 里写的每个标签最终都会对应到这里某个字段。

这里有一个很重要的设计思路:MyBatis 把“配置”和“执行”解耦。解析阶段把所有 XML 转成 Configuration,执行阶段则只依赖 Configuration——不再去碰 XML。以后你想在项目里做多数据源切换、动态增删 Mapper,或者单测时手动构造 Configuration,理解这个模型会很有帮助。

2.2 XMLConfigBuilder 的标签解析顺序

XMLConfigBuilder 的 parse() 方法特别短,我划一下重点。它先检查 parsed 标志位,防止一个实例被解析两次,然后调用 parseConfiguration(parser.evalNode("/configuration"))。每个 XMLConfigBuilder 实例只能用一次,这个细节在面试中偶尔会当作“MyBatis 初始化流程的特点”来问。

parseConfiguration() 内部是一串固定顺序的调用,我简写出来:

  • propertiesElement:加载 properties 文件;
  • settingsAsProperties:读取 settings 段;
  • loadCustomVfs、loadCustomLogImpl:加载 VFS 和日志实现;
  • typeAliasesElement:注册类型别名;
  • pluginElement:加载插件;
  • objectFactoryElement、objectWrapperFactoryElement、reflectorFactoryElement:注册工厂类;
  • settingsElement:把 settings 里的值应用到 Configuration;
  • environmentsElement:解析数据源和事务工厂;
  • databaseIdProviderElement:多数据库支持;
  • typeHandlersElement:注册自定义 TypeHandler;
  • mappersElement:解析所有 mapper 文件或 Mapper 接口。

有些初学者会问:“这些标签是不是随便换顺序都行?”官方 DTD 其实已经规定了顺序要求,但 XML 解析器并非总能强制校验。实践中真正容易出问题的是 typeAliases 和 settings 的先后位置,因为它们会影响后续解析时别名的解析以及一些默认值。稳妥的做法永远是照着官方顺序写,我不建议为了“看起来紧凑”而调整标签顺序。

2.3 XMLMapperBuilder 到 XMLStatementBuilder:一条 SQL 的转正之路

mappersElement 解析到具体某个 mapper XML 后,会交给 XMLMapperBuilder。这个类的 configurationElement() 方法把整个 mapper 文件拆成几个部分:resultMap、typeHandler、sql、select/insert/update/delete。其中每一条 SQL 节点又会被 XMLStatementBuilder 处理。

XMLStatementBuilder.parseStatementNode() 会读取 id、parameterType、resultType、resultMap、flushCache、useCache、fetchSize 等属性,然后把 SQL 节点中的动态标签继续往下交。处理动态 SQL 的核心类是 XMLScriptBuilder,它把 if、where、choose、foreach 这些节点翻译成一个个 SqlNode 对象,最后组合成 DynamicSqlSource 或 RawSqlSource。

最终生成的 MappedStatement 会注册到 Configuration.mappedStatements 里,key 是“namespace.id”。这也是为什么 Mapper 文件里 namespace 必须唯一,id 在 namespace 下必须唯一——因为它们直接决定了 MappedStatement 的查找 key。如果 key 冲突,初始化时就会抛出异常。

2.4 配置解析阶段最容易踩的坑

解析阶段的问题,往往让人满头雾水。我把我实际踩过的坑整理一下。

第一,typeAliases 包扫描顺序。如果你用<package name="com.demo.entity"/>扫描实体包,两个不同包下的同名类会让后扫描的覆盖先扫描的,别名全是小写类名,这会导致某些 resultMap 引用别名时解析到错误的类。第二,resultMap 里使用的别名必须在前面的 typeAliases 阶段先注册,否则会报“Could not resolve type alias”。第三是 SQL 里的 ${} 和 #{} 在解析阶段就走向不同路径,前者走文本替换,后者走参数映射。一旦出现 SQL 注入或拼错 SQL 的诡异问题,优先检查是不是把 #{} 写成了 ${}。

另外,热词里提到的“自定义 Configuration”,其实也很简单:你可以继承 Configuration 重写 newExecutor() 方法,定制执行器创建逻辑;在 Spring Boot 项目中则使用 ConfigurationCustomizer 回调来修改 Configuration。理解了 Configuration 这个中央仓库,你在各种框架整合时就能找到合适的扩展点。

3. SqlSession 创建与执行链路:一次查询从入口到结果集

3.1 SqlSessionFactoryBuilder:一切的起点

原生 MyBatis 的起点是 SqlSessionFactoryBuilder.build()。这个类内部的逻辑非常简单,创建 XMLConfigBuilder,调用 parse() 得到 Configuration,然后 new DefaultSqlSessionFactory(configuration)。build 完这个 Builder 就可以扔了,它本身不持有任何状态。

在 Spring Boot 里,MybatisAutoConfiguration 干的事情本质上是同一套:从 Spring 容器拿 DataSource,把数据库配置设置进 Configuration,然后创建 SqlSessionFactoryBean,最后生成 SqlSessionFactory。所以只要你把原生初始化流程读通,Spring Boot 的自动配置对你来说就是一层皮。

有人问:“为什么 openSession() 之前必须拿到 SqlSessionFactory?”源码里可以找到答案:SqlSessionFactory 是生产 SqlSession 的工厂,而 SqlSession 内部又要持有 Configuration 和 Executor。如果没有 Configuration,连 MappedStatement 都取不到,更谈不上执行 SQL。

3.2 SqlSession 与 Executor:会话里面包着执行器

获得 factory 之后,每次操作数据库都会走 openSession()。DefaultSqlSessionFactory.openSessionFromDataSource() 里面做了三件事:从 Configuration 拿 Environment,创建 Transaction;根据 ExecutorType 创建 Executor;最后包装成 DefaultSqlSession。

ExecutorType 就是面试里常提到的 SIMPLE、REUSE、BATCH。它们分别对应 SimpleExecutor、ReuseExecutor、BatchExecutor,使用的策略完全不同:

  • SIMPLE:每次执行创建新的 Statement;
  • REUSE:复用同一个 Statement,减少 SQL 预编译开销;
  • BATCH:批量执行 update,攒一批一起提交,适合大批量插入。

如果你在 Spring Boot 配置里见过 “executor-type” 这个配置项,就是指这一步创建哪种执行器。项目里批量插入慢的优化点,对应的就是这里。

3.3 Executor 的装饰链:CachingExecutor 怎么套娃

Configuration.newExecutor() 是理解整个执行链的关键方法。它先按 executorType 创建 SimpleExecutor、ReuseExecutor 或 BatchExecutor,然后如果 cacheEnabled 为 true,就再包一层 CachingExecutor,最后调用 interceptorChain.pluginAll(executor),把插件装饰上去。

这串装饰链非常能体现 MyBatis 的设计功力:每一层只负责自己的职责,查询请求先过 CachingExecutor 的二级缓存,再落到真正执行 SQL 的执行器。插件层通过 JDK 动态代理包裹 Executor、StatementHandler、ParameterHandler、ResultSetHandler 这些目标对象,所以插件能拦截的方法都是有限的——具体看你的 signature 配置。

有个经验:排查“插件不生效”或“拦截器顺序不对”时,不要去看业务代码,重点检查 Configuration.newExecutor() 里 pluginAll 的调用顺序,以及你的 Interceptor 在 interceptorChain 中的注册顺序。插件之间的装饰顺序会直接影响责任链结果。

3.4 一次 selectList 的完整调用链

以 sqlSession.selectList("com.demo.UserMapper.selectAll") 为例,完整的链路是:

DefaultSqlSession.selectList() → Executor.query() → CachingExecutor(二级缓存命中检查) → BaseExecutor.query()(一级缓存命中检查) → SimpleExecutor.doQuery() → StatementHandler.query() → PreparedStatement.execute() → ResultSetHandler.handleResultSets()

DefaultSqlSession.selectList() 先从 configuration.getMappedStatement() 拿到 MappedStatement;然后调用 executor.query()。如果 executor 是 CachingExecutor,它会先查二级缓存;如果没命中,会委派给底层 Executor。BaseExecutor.query() 再查一级缓存,如果也没命中,就进入 doQuery(),创建 StatementHandler,StatementHandler 里再有 ParameterHandler 设置参数,ParameterHandler 设置完后执行 JDBC 的 PreparedStatement.execute()。最后 ResultSetHandler 处理结果集,把 ResultSet 里的行映射成 List<T> 返回。

我会把这套流程贴到显示器旁边,因为线上排查时,你只要说出“这问题发生在哪一段”,基本就能定位到具体类。比如参数没生效,去看 ParameterHandler;返回数据映射错误,去看 ResultSetHandler;数据重复或者查出来是旧值,去看缓存层的 CacheKey。

3.5 为什么 Mapper 接口不需要实现类:MapperProxy 的魔法

Spring 项目里只定义接口 UserMapper,加上 @Mapper 就能注入,这背后是 binding 包里的 MapperProxyFactory 和 MapperProxy。调用 Mapper 接口的方法时,实际执行的是一个 JDK 动态代理:MapperProxy.invoke() 会把当前方法转换成 MapperMethod,再根据参数解析结果去执行对应的 SqlSession 方法。

这个机制有几个实际影响。第一,方法重载在 Mapper 接口里要非常小心,因为最终都是靠方法名找 MappedStatement;第二,多参数的方法必须用 @Param 显式命名,否则 MyBatis 会用 param1、param2 这种位置名,SQL 里的 #{xxx} 很容易取不到值;第三,SqlSession 必须是线程安全的代理实现,这个在 Spring 整合时尤其重要,所以在项目中直接用 SqlSessionTemplate 而不是裸的 SqlSession。

4. 一级缓存和二级缓存:源码视角下的缓存真相

4.1 一级缓存:PerpetualCache 和它的“短命”特性

一级缓存是 MyBatis 默认开着的,作用域是单个 SqlSession。每个 SqlSession 持有 Executor,Executor 内部又有 localCache,这个 localCache 就是 PerpetualCache,本质上是一个 HashMap。缓存的 key 是 CacheKey,value 是查询结果列表。

它的生命周期跟 SqlSession 走。同一个 SqlSession 里同一条 SQL 查两次,第二次如果命中一级缓存,就不会真查数据库。但一级缓存非常“短命”:执行 update、commit、rollback、手动 clearCache,以及查询时传入了 ResultHandler 而不走缓存,这些情况下缓存都会失效或绕过。还有一个容易被忽略的点:Spring 事务里,同一个事务方法多次查询会共享一个 SqlSession,一级缓存是生效的;不在事务里时,每次 Mapper 方法调用都是新建会话,一级缓存基本等于摆设。

把一级缓存想成“本次会话的草稿纸”:同一张纸上填写的内容可以直接复用,但只要你更新了数据、提交了事务或者是换了张纸,草稿就作废了。搞懂这个机制后,网上那些“我开了缓存怎么还是查数据库”的问题,百分之八十都能用“有没有换 SqlSession / 执行了 update 所以被清掉”解释清楚。

4.2 二级缓存:CachingExecutor 与装饰器模式

二级缓存是跨 SqlSession 的,默认关闭,需要在 mapper XML 里加<cache/>。它的源码实现很巧妙:用装饰器模式,把底层的 Executor 包进 CachingExecutor。CachingExecutor.query() 里先按 MappedStatement 拿到二级缓存,命中就直接返回结果;没命中才委托给底层执行器执行,再放入缓存。

这里有个关键点:二级缓存的写入不是查询后立刻写,而是等事务提交时才真正合并到缓存。具体实现是 TransactionalCacheManager:每次查询得到结果放进一个“待提交缓存”TransactionalCache;事务 commit 时,TransactionalCache 才把数据 flush 到真正的二级缓存;事务回滚时直接丢弃。所以如果你开着二级缓存但方法没走事务,或者事务一直没提交,缓存行为会和你预期的不太一样。

caching 包下面有一堆装饰器:LruCache、FifoCache、SoftCache、WeakCache、ScheduledCache、BlockingCache 等。通过这些装饰器可以组合出各种缓存策略。比如配置 eviction="LRU" 时,实际是一串 PerpetualCache → LruCache → ScheduledCache → SerializedCache → SynchronizedCache 的装饰链。这也解释了为什么二级缓存返回的对象有可能是深拷贝(开了 SerializedCache),修改返回对象不会影响缓存本身,但如果是同一引用,你改返回对象其实就等于改了缓存数据。

4.3 CacheKey 到底由什么组成

缓存查询时,最关键的一步是生成 CacheKey。我在源码里看到,CacheKey 会逐步 update 以下几个数据:MappedStatement 的 id、RowBounds 的 offset 和 limit、BoundSql 的最终 SQL、以及参数对象本身。

CacheKey.update() 里的算法不复杂,但对命中率影响很大。它用了 multiplier=37 这种常见的散列因子,把每次 update 的对象信息逐步累加进 hashcode,同时每个对象还要记录到 updateList 里用于 equals 比较。要注意,参数是 Object 类型时,用的是 hashCode 和 equals。如果你自定义的实体类没有好好实现 equals/hashCode,可能出现“逻辑上一样的查询,CacheKey 却不一样”的问题,缓存就永远命不中。

读到这里你会发现,缓存“玄学命中”的本质其实就是 key 的等价判定。不管是面试还是排查,遇到缓存失效先去核对 CacheKey 里的几个字段,基本不会跑偏。

4.4 一级缓存与二级缓存速查对比

对比项一级缓存二级缓存
默认状态开启关闭,需<cache/>开启
作用范围单个 SqlSession跨 SqlSession,按 namespace 隔离
底层结构PerpetualCache(HashMap)PerpetualCache + 装饰器链
写入时机查询完成后立即写入事务提交时才写入真实缓存
主要失效点update、commit、rollback、clearCache、传 ResultHandler对应 namespace 的缓存被更新或清空
典型坑点Spring 非事务方法每次新建会话,缓存形同虚设多表 join 时不同 namespace 缓存可能不一致

这张表是我在项目里排查问题时常用的速查卡,建议收藏。

5. TypeHandler 工作流程:类型转换的“双向飞地”

5.1 从 Java 类型到 JDBC 类型:setParameter

TypeHandler 是 MyBatis 里一个容易被忽略但非常关键的组件。它要处理两个方向的类型转换:执行 SQL 前,把 Java 对象参数写入 PreparedStatement;执行完后,把 ResultSet 里的 JDBC 类型转换为 Java 对象。

它的接口方法分两类:setParameter 和 getResult 系列。setParameter 在 DefaultParameterHandler.setParameters() 里被调用。当你的 Mapper 方法接收实体参数,XML 里的 #{username} 就会被解析成 ParameterMapping,每个 ParameterMapping 都会找到一个对应的 TypeHandler,然后调用 setParameter 给 PreparedStatement 传值。getResult 系列则是在 DefaultResultSetHandler 处理结果集时调用,把数据库返回的列值转成实体字段。

如果你曾经在 LocalDateTime、Date、JSON 这类类型转换上遇到过奇怪的错误,多半是这一环出了问题。最直接的办法就是定义自己的 TypeHandler,让两个方向的转换完全由你控制。

5.2 TypeHandlerRegistry 的注册查找机制

TypeHandlerRegistry 负责管理所有类型处理器。它内部有两类映射:按 JdbcType 分类的 jdbcTypeHandlerMap,以及按 Java 类型分类的 typeHandlerMap。为什么还要分 JdbcType?因为同一个 Java 类型映射到不同 JDBC 列类型时,可能需要不同的处理方式。

当你注册自定义 TypeHandler 时,可以用 @MappedTypes 和 @MappedJdbcTypes 两个注解来限定它适用的 Java 类型和 JDBC 类型。注册方式有几种:在 SqlSessionFactoryBuilder 构建之前通过 configuration.getTypeHandlerRegistry().register() 注册;在 mybatis-config.xml 里用<typeHandlers>标签或<package>扫描;还有就是在 Spring Boot 里通过 ConfigurationCustomizer 调用 register。

如果没写注解,MyBatis 会根据泛型参数推断 Java 类型,但有时推断不出来,或者推断出来的类型和你实际要处理的类型不符,就会出现“怎么调都不走我的自定义处理器”的尴尬局面。这时候就得在 resultMap 的<result>标签里显式指定 typeHandler,绕开注册查找的自动匹配。

5.3 BaseTypeHandler 的骨架逻辑

自定义 TypeHandler 通常不直接实现接口,而是继承 BaseTypeHandler。这个抽象类把 setParameter 里的空值处理已经写好了:parameter 为 null 时,根据 jdbcType 调用 ps.setNull();不为 null 时才调用你实现的 setNonNullParameter。你只需要关心非空参数的转换逻辑。

getResult 方面有两个方向的读取:按列名 getNullableResult(ResultSet, String) 和按列索引 getNullableResult(ResultSet, int),加上 CallableStatement 的重载。如果你希望查出来为 NULL 时返回 null,而不是抛 NPE,空值场景都要覆盖好。这个类看起来简单,但它是我在遇到“数据库字段值为 null,结果实体却是空对象”这种问题时的第一排查对象。

6. 面试题与实战排查:把源码用在真实项目里

6.1 源码级回答“一级缓存什么时候失效”

面试被问“一级缓存什么时候失效”,不要背网上的零散结论,直接按源码逻辑回答。一级缓存是 Executor 内部的 PerpetualCache,命中条件是 SqlSession 相同、CacheKey 相同、没有走 update、没有 commit/rollback、没有手动 clearCache。

具体来说,BaseExecutor.update() 会先 clearLocalCache();commit、rollback 也会清空;clearCache 更不用说;查询时如果 ms 的 flushCacheRequired 为 true,会在查询前清空缓存;如果传入 ResultHandler,则干脆不查一级缓存。另外,Spring 管理的 Service 方法在没开事务时,每次 Mapper 调用都是新 SqlSession,一级缓存约等于没开。

顺带纠正一个很容易混淆的点:useCache=false 只影响二级缓存,不会绕过一级缓存。很多人把这两个开关混为一谈,源码一读就清楚了。回答时如果能补上“CachingExecutor 处理 useCache,BaseExecutor 处理 flushCacheRequired”,面试官立刻知道你是看过源码的。

6.2 “查询条件不生效”的源码定位法

“MyBatis 条件不生效”是我在社区看到的高频问题,表现形式五花八门,但源码定位法非常统一。第一,检查<if test="...">里的表达式和参数对象属性是否对得上。动态 SQL 是通过 OGNL 解析的,如果属性拼错或参数对象没有这个字段,表达式可能被判断为 false,条件就不会拼进 SQL。

第二,检查多参数方法的 @Param。如果你定义的是 selectByPage(String status, int pageSize) 这种多参数方法,没有加 @Param,MyBatis 会用 param1、param2 这种位置名,你在 SQL 里写 #{status} 根本找不到参数,条件自然不生效。加上 @Param("status") 后,ParamNameResolver 会把参数放到一个 Map 里,key 就是 status。

第三,直接用日志看拼出来的 SQL。在 Spring Boot 配置文件里加一行 logging.level.org.apache.ibatis=debug,执行时就能看到 MyBatis 实际执行的 SQL 和参数绑定情况。这一步能帮你快速区分是“条件没拼进去”还是“参数没传进来”。如果还看不出来,就在 DynamicSqlSource.getBoundSql() 上打断点,直接看最终生成的 SQL 文本,这比猜快得多。

6.3 Spring Boot + MyBatis 整合中的 SqlSession 线程模型

Spring Boot 整合 MyBatis 之后,事务和 SqlSession 的关系经常让人困惑。核心是 SqlSessionTemplate:它实现了 SqlSession 接口,内部每次调用方法时,会从 TransactionSynchronizationManager 获取当前事务绑定的 SqlSession;如果没有事务,就临时创建一个,用完关闭。

所以事务方法内,多次调用 Mapper 方法用的是同一个 SqlSession,一级缓存、事务原子性都有保障;非事务方法则是“每次调用开一个会话”的模式。我见过不少同事在非事务方法里调用两次 selectByPrimaryKey,然后疑惑“为什么查了两次数据库”,其实就是因为每次都是新会话,一级缓存根本没机会生效。

批量插入慢的问题也在这块源码里有答案。如果你用默认的 SIMPLE 执行器,每调一次 mapper.insert() 就会创建、执行、关闭一个 Statement,循环一千次就是一千次往返。换用 ExecutorType.BATCH,比如手动打开一个批量 SqlSession,一批 SQL 攒着一起提交,性能差异非常明显。MyBatis-Plus 的 saveBatch 原理本质也是走这个批量执行思路,分批攒 SQL、分批 flush。

6.4 一个最简单的注册功能:从源码视角检验整合成果

热词里有两关很有意思:“项目整合 - springboot + mybatis”和“使用 springboot + mybatis 实现一个最简单的注册功能”。这两关其实是绝佳的源码练习项目,因为它完整覆盖了“配置解析、SqlSession 创建、Mapper 代理、SQL 执行、事务边界”这条主线。

实现注册功能时,我建议盯着三个源码位置。第一,参数绑定。Controller 接收 RegisterRequest,Service 层调用 userMapper.insert(user)。如果 Mapper 方法定义是 insert(@Param("user") User user),SQL 里要用 #{user.username};如果漏掉 @Param,改成 insert(User user),SQL 里可以只写 #{username},但底层参数 Map 的构建逻辑不同。两种写法都能跑,关键是别混。第二,主键回填。数据库自增主键时,在 insert 标签加 useGeneratedKeys="true" keyProperty="id",MyBatis 执行完 insert 后会调用 JDBC 的 getGeneratedKeys() 把主键回填到 user.id。这是 MappedStatement 的 keyGenerator 字段干的活,属于源码里很值得读的一块。第三,事务边界。注册过程加 @Transactional,才能保证一个 SqlSession 完成整个注册流程。不加事务的话,如果后面还有更新逻辑,可能因为多次会话而在某个环节出现“数据插了一半”的诡异问题。

这三关跑通后,你会发现 MyBatis 的源码不是抽象的理论,而是每天都在操作的代码。

6.5 源码阅读后的调试建议

最后给几条我实测下来比较管用的调试建议。第一,IDEA 里把 MyBatis 源码下载好,遇到问题直接 Ctrl+Alt+B 跳实现,不要怕打断点。MyBatis 调用链不长,几分钟就能跟完一个流程。第二,把 SqlSessionFactoryBuilder、XMLConfigBuilder、Executor、BaseExecutor 这几个类的断点都熟记于心。它们对应初始化、解析、执行三层,任何业务问题都能映射到这个模型上。第三,配置日志实现时,记得 mybatis.configuration.log-impl。StdOutImpl 会把 SQL 直接打到控制台,Slf4jImpl 会输出到日志文件,调试效率差别很大。日志实现的加载也是通过 settings 解析完成的。第四,如果想对比二级缓存效果,把 cacheEnabled 开关在 true/false 之间切换,在 CachingExecutor 的 query 方法加断点,一眼就能看出结果是否从缓存返回。

源码阅读这件事,最划算的时间点就是今天。它不会让你的项目立刻多几个接口,但下一次你面对“为什么查询慢了”“为什么缓存没生效”“为什么条件没拼进去”这类问题,几分钟就能定位,不用再到处复制粘贴日志求人帮忙。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询