MyBatis 源码精读:初始化、执行链路、缓存与类型转换全解析 我这些年读过不少开源框架源码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各种 BuilderXMLConfigBuilder、XMLMapperBuilder 都在这sessionSqlSession、SqlSessionFactoryexecutor执行器、缓存、结果集处理的核心链路mappingMappedStatement、ResultMap、SqlSource 这些核心模型typeTypeHandler 以及各种类型处理器scripting动态 SQL 解析XMLScriptBuilder 和 SqlSource 就在这里。先花十分钟把包结构记住后面打断点跳来跳去时会很有方向感。很多初学者读源码失败不是能力问题而是上来就扎进某个类逐行读没有先建立地图。地图在手代码就是故事。1.3 我的源码阅读顺序由外到内由调用到实现具体读源码怎么切入我的建议是先跑通一个最小环境再跟着调用栈走。不要先读 Xxx.java 的源码而是准备一个 Spring Boot MyBatis 的最小项目Mapper 里写一个简单的 select 查询然后在启动和查询的关键位置打断点。我第一次读 MyBatis 源码时的路线是在 SqlSessionFactoryBuilder.build() 打第一个断点看 Configuration 是怎么从 XML 里建出来的顺着 XMLConfigBuilder.parse() 往里跳观察各个标签的解析过程真正执行 SQL 时在 SqlSession.selectList() 打断点跟 Executor 那套调用链遇到不认识的组件先看接口定义再看实现类数量挑主实现去读。这种由外到内、由调用到实现的方式能保证你读的每一行代码都是“当前场景真实需要的”。源码阅读最怕的是“想着某个类然后读着读着游到九霄云外”有了断点和调用栈约束思路很难跑偏。2. XMLConfigBuilder 工作流程配置文件是怎么“长出”Configuration 的2.1 ConfigurationMyBatis 的中央仓库要理解 XMLConfigBuilder必须先理解它想构建的目标Configuration。这是整个 MyBatis 运行时唯一的核心数据模型可以看作一个中央仓库所有配置信息都被塞进这个对象。看一眼 Configuration 的字段就能猜到它的职责mappedStatements 保存了所有 MappedStatementkey 是 namespace.idresultMaps 保存结果映射规则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 里的值应用到 ConfigurationenvironmentsElement解析数据源和事务工厂databaseIdProviderElement多数据库支持typeHandlersElement注册自定义 TypeHandlermappersElement解析所有 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 namecom.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每次执行创建新的 StatementREUSE复用同一个 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()创建 StatementHandlerStatementHandler 里再有 ParameterHandler 设置参数ParameterHandler 设置完后执行 JDBC 的 PreparedStatement.execute()。最后 ResultSetHandler 处理结果集把 ResultSet 里的行映射成 ListT 返回。我会把这套流程贴到显示器旁边因为线上排查时你只要说出“这问题发生在哪一段”基本就能定位到具体类。比如参数没生效去看 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 持有 ExecutorExecutor 内部又有 localCache这个 localCache 就是 PerpetualCache本质上是一个 HashMap。缓存的 key 是 CacheKeyvalue 是查询结果列表。它的生命周期跟 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 等。通过这些装饰器可以组合出各种缓存策略。比如配置 evictionLRU 时实际是一串 PerpetualCache → LruCache → ScheduledCache → SerializedCache → SynchronizedCache 的装饰链。这也解释了为什么二级缓存返回的对象有可能是深拷贝开了 SerializedCache修改返回对象不会影响缓存本身但如果是同一引用你改返回对象其实就等于改了缓存数据。4.3 CacheKey 到底由什么组成缓存查询时最关键的一步是生成 CacheKey。我在源码里看到CacheKey 会逐步 update 以下几个数据MappedStatement 的 id、RowBounds 的 offset 和 limit、BoundSql 的最终 SQL、以及参数对象本身。CacheKey.update() 里的算法不复杂但对命中率影响很大。它用了 multiplier37 这种常见的散列因子把每次 update 的对象信息逐步累加进 hashcode同时每个对象还要记录到 updateList 里用于 equals 比较。要注意参数是 Object 类型时用的是 hashCode 和 equals。如果你自定义的实体类没有好好实现 equals/hashCode可能出现“逻辑上一样的查询CacheKey 却不一样”的问题缓存就永远命不中。读到这里你会发现缓存“玄学命中”的本质其实就是 key 的等价判定。不管是面试还是排查遇到缓存失效先去核对 CacheKey 里的几个字段基本不会跑偏。4.4 一级缓存与二级缓存速查对比对比项一级缓存二级缓存默认状态开启关闭需cache/开启作用范围单个 SqlSession跨 SqlSession按 namespace 隔离底层结构PerpetualCacheHashMapPerpetualCache 装饰器链写入时机查询完成后立即写入事务提交时才写入真实缓存主要失效点update、commit、rollback、clearCache、传 ResultHandler对应 namespace 的缓存被更新或清空典型坑点Spring 非事务方法每次新建会话缓存形同虚设多表 join 时不同 namespace 缓存可能不一致这张表是我在项目里排查问题时常用的速查卡建议收藏。5. TypeHandler 工作流程类型转换的“双向飞地”5.1 从 Java 类型到 JDBC 类型setParameterTypeHandler 是 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一级缓存约等于没开。顺带纠正一个很容易混淆的点useCachefalse 只影响二级缓存不会绕过一级缓存。很多人把这两个开关混为一谈源码一读就清楚了。回答时如果能补上“CachingExecutor 处理 useCacheBaseExecutor 处理 flushCacheRequired”面试官立刻知道你是看过源码的。6.2 “查询条件不生效”的源码定位法“MyBatis 条件不生效”是我在社区看到的高频问题表现形式五花八门但源码定位法非常统一。第一检查if test...里的表达式和参数对象属性是否对得上。动态 SQL 是通过 OGNL 解析的如果属性拼错或参数对象没有这个字段表达式可能被判断为 false条件就不会拼进 SQL。第二检查多参数方法的 Param。如果你定义的是 selectByPage(String status, int pageSize) 这种多参数方法没有加 ParamMyBatis 会用 param1、param2 这种位置名你在 SQL 里写 #{status} 根本找不到参数条件自然不生效。加上 Param(status) 后ParamNameResolver 会把参数放到一个 Map 里key 就是 status。第三直接用日志看拼出来的 SQL。在 Spring Boot 配置文件里加一行 logging.level.org.apache.ibatisdebug执行时就能看到 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 接收 RegisterRequestService 层调用 userMapper.insert(user)。如果 Mapper 方法定义是 insert(Param(user) User user)SQL 里要用 #{user.username}如果漏掉 Param改成 insert(User user)SQL 里可以只写 #{username}但底层参数 Map 的构建逻辑不同。两种写法都能跑关键是别混。第二主键回填。数据库自增主键时在 insert 标签加 useGeneratedKeystrue keyPropertyidMyBatis 执行完 insert 后会调用 JDBC 的 getGeneratedKeys() 把主键回填到 user.id。这是 MappedStatement 的 keyGenerator 字段干的活属于源码里很值得读的一块。第三事务边界。注册过程加 Transactional才能保证一个 SqlSession 完成整个注册流程。不加事务的话如果后面还有更新逻辑可能因为多次会话而在某个环节出现“数据插了一半”的诡异问题。这三关跑通后你会发现 MyBatis 的源码不是抽象的理论而是每天都在操作的代码。6.5 源码阅读后的调试建议最后给几条我实测下来比较管用的调试建议。第一IDEA 里把 MyBatis 源码下载好遇到问题直接 CtrlAltB 跳实现不要怕打断点。MyBatis 调用链不长几分钟就能跟完一个流程。第二把 SqlSessionFactoryBuilder、XMLConfigBuilder、Executor、BaseExecutor 这几个类的断点都熟记于心。它们对应初始化、解析、执行三层任何业务问题都能映射到这个模型上。第三配置日志实现时记得 mybatis.configuration.log-impl。StdOutImpl 会把 SQL 直接打到控制台Slf4jImpl 会输出到日志文件调试效率差别很大。日志实现的加载也是通过 settings 解析完成的。第四如果想对比二级缓存效果把 cacheEnabled 开关在 true/false 之间切换在 CachingExecutor 的 query 方法加断点一眼就能看出结果是否从缓存返回。源码阅读这件事最划算的时间点就是今天。它不会让你的项目立刻多几个接口但下一次你面对“为什么查询慢了”“为什么缓存没生效”“为什么条件没拼进去”这类问题几分钟就能定位不用再到处复制粘贴日志求人帮忙。