MyBatis核心原理与实战:从初始化到缓存、TypeHandler与动态SQL 1. 项目概述MyBatis到底是个什么东西先说结论MyBatis是一个半自动的ORM框架它的核心思路是把SQL语句和Java对象映射分开管理让开发者自己写SQL而不是由框架帮你自动生成SQL。这一点和Hibernate那种全自动方案有本质区别。国内Java后端面试里MyBatis几乎是被问得最密集的框架之一。热搜词里出现的mybatis面试题mybatis源码mybatis缓存xmlconfigbuilser工作流程这些关键词说明大家已经不满足于会用而是想搞清楚它在底层到底干了什么。这篇文章我会从配置初始化、SQL执行、缓存机制、类型处理、动态SQL这几个维度拆开讲最后再带一个SpringBoot整合MyBatis实现注册功能的完整案例尽量做到既适合面试复习也适合实际开发排坑。MyBatis这个名字早期写作iBATIS后来从Google Code迁到Github时改名。它解决的痛点很直接JDBC原生代码太啰嗦手动处理结果集太痛苦但同时又不想失去SQL的灵活性和可控性。所以它采取了一个折中方案——SQL你写映射我帮你做。2. 初始化工作原理XMLConfigBuilder到底干了什么2.1 从配置文件到Configuration对象很多人在项目中用MyBatis都是直接用SpringBoot的starter配置都放到application.yml里久而久之对初始化这件事就没概念了。但面试题里反复出现xmlconfigbuilser工作流程图mybatis中初始化工作流程说明这确实是考察底层理解的经典切入点。MyBatis的初始化本质上就是把配置变成可执行的结构。如果走XML配置方式入口是SqlSessionFactoryBuilder.build(InputStream)它会创建一个XMLConfigBuilder然后调用parse()方法得到Configuration对象。你可以在源码里看到XMLConfigBuilder.parse()返回值是new Configuration()但这个Configuration并不是空的——它会先通过parser.evalNode(/configuration)拿到根节点然后逐个解析properties、settings、typeAliases、typeHandlers、objectFactory、objectWrapperFactory、plugins、environments、databaseIdProvider、mappers这些子节点。这里有一个容易被忽略的细节XMLConfigBuilder在创建Configuration之前会先调用XMLMapperEntityResolver来验证XML格式。也就是说mybatis-config.xml的DTD校验、属性替换properties标签里定义的变量都是在parse阶段完成的。如果你在配置里用了${driver}这种占位符它在这里就会被替换成实际值。配置初始化完成之后build()方法会调用new DefaultSqlSessionFactory(configuration)工厂对象就这样产生了。要注意的是这个工厂在整个应用中应该是单例的而SqlSession是每个请求或事务一个。2.2 Configuration对象里存了什么关键结构Configuration是MyBatis整个框架的心脏它几乎保存了运行期需要的全部元数据。我挑几个最重要的说mappedStatements一个Mapkey是Mapper接口全限定名.方法名value是MappedStatement对象。每个MappedStatement代表一条SQL命令包含sqlSource、statementType、resultMaps、parameterMap、keyGenerator、缓存配置等。resultMaps存的是ResultMap负责数据库列名到Java属性的映射规则。typeHandlerRegistry类型处理器的注册表保存Java类型、JDBC类型和TypeHandler的对应关系。cacheRegistrycaches二级缓存的注册中心。parameterMappings参数映射关系由ParameterMapping组成描述SQL中每个占位符的参数行为。你可以理解成Configuration是编译后的产物它把xml和注解里分散的配置信息浓缩成一套可被Executor直接调用的数据结构。这也是为什么MyBatis启动慢一丢丢但一旦启动完成运行期执行效率是有保障的——因为解析工作已经全部做完了。2.3 自定义Configuration的入口热搜词里还有一个mybatis中自定义configuration这个在面试里属于加分项。其实有两种自定义方式一种是在mybatis-config.xml里配置objectFactory、objectWrapperFactory等扩展点这种方式不需要继承Configuration只是替换内部组件。另一种是直接继承Configuration类重写newExecutor()、newStatementHandler()之类的方法。比如你可以继承Configuration把默认的Executor换成自研的实现。然后在SqlSessionFactoryBuilder.build()之前手动new一个自定义Configuration再传给build方法MyConfiguration config new MyConfiguration(); config.setEnvironment(env); SqlSessionFactory factory new SqlSessionFactoryBuilder().build(config);不过说实话生产环境里自定义Configuration的场景非常少我见过的更多是自定义Interceptor插件来拦截Executor、StatementHandler比如做分页插件、慢SQL统计插件。这种方式比继承Configuration更安全因为它不破坏原有初始化逻辑只是在执行链上插入一道处理。3. SqlSession生命周期与SQL执行链路3.1 Mapper接口是如何被动态代理的MyBatis允许你只写一个接口不写实现类就能执行SQL这靠的是MapperProxyFactory动态代理。当MyBatis初始化时会把configuration里注册的mapper接口逐个扫描为每个接口创建MapperProxyFactory。当你调用sqlSession.getMapper(UserMapper.class)时返回的是MapperProxy的代理对象。关键点在于MapperProxy并不是在代理里直接执行SQL而是通过MapperMethod来包装。一个MapperMethod内部持有SqlCommandSQL命令类型INSERT/UPDATE/DELETE/SELECT和MethodSignature方法签名参数名、返回类型、是否有Param注解等。每次调用方法时MapperMethod.execute()会被执行它会根据SQL命令类型决定调用sqlSession.insert/update/delete/selectOne/selectList并且负责把方法参数转成SQL参数、把查询结果转成方法返回类型。我讲一个面试中常问的点为什么Mapper接口方法不能重载原因也很简单——MyBatis的MappedStatement的key是接口名.方法名不支持同名方法区分参数。如果你强行写两个同名方法后面解析的会把前面的覆盖掉运行起来就乱了。3.2 Executor真正干活的组件SqlSession本身不是直接执行JDBC操作的它把请求转交给Executor。Executor是MyBatis的执行器接口负责调度缓存、事务、SQL执行。MyBatis提供了三种内置ExecutorSimpleExecutor每执行一次SQL就创建一个新的Statement对象用完即关。默认就是它简单直接。ReuseExecutor内部维护一个Map把SQL字符串作为key对应的Statement对象缓存起来重复执行时直接复用。BatchExecutor专门用于批量操作它会把多条更新语句累积到Batch对象里直到提交事务或主动flush才一次性发送给数据库。如果你在SpringBoot里用ExecutorType.BATCH开启批量模式意味着Spring管理的SqlSession会用SqlSessionTemplate的batch模式。但注意一点Batch模式下SQL不会立刻发送到数据库只有调用sqlSession.flushStatements()或者事务提交时才会真正执行。所以如果你在批量插入后立刻去查询可能查不到刚插入的数据这是新手容易踩的坑。3.3 批量插入怎么优化热搜词里有mubatis mybatis-plus 批量这里统一说一下。用MyBatis做批量插入最常见的两种方式一种是循环调用单条insert另一种是用foreach拼接一条多VALUES语句。先说结论大批量数据比如上万条用foreach一次插入更高效但SQL字符串会很长用BatchExecutor则能在内存占用和SQL长度之间取得平衡。我实际测试过插入10000条数据用循环单条插入大约需要8秒左右用foreach一次插入大约1秒多用BatchExecutor大约2-3秒。但如果数据量到10万条foreach拼接出来的SQL会非常长MySQL的max_allowed_packet可能撑不住这时候反而要分片处理比如每500条一组。另外你使用JDBC连接MySQL时如果想要批量插入真正快起来一定要在连接URL上加上rewriteBatchedStatementstrue。否则MySQL驱动并不会把多条insert合并成一条多值insertBatchExecutor的性能优势就发挥不出来。这个参数很多人不知道属于批量优化里的关键点。4. 缓存机制一级缓存和二级缓存实现原理4.1 一级缓存SqlSession级别的本地缓存MyBatis一级缓存默认开启范围是SqlSession。意思是在同一会话中如果执行了同一条SQL参数一致、语句一致第二次不会真的去查数据库而是直接返回缓存中的结果。一级缓存的实现类是PerpetualCache本质上就是一个HashMapkey是CacheKeyvalue是查询结果对象。CacheKey由MappedStatement的id、SQL语句、参数值、分页参数、环境信息等组合生成。所以只要SQL字符串或参数有任何细微差异缓存key就不同不会造成误命中。一级缓存有个很容易踩坑的地方如果你在一个SqlSession里先查了某个用户然后手动去数据库里把这条数据改了再在这个SqlSession里查同一个用户你会发现查到的还是旧数据。因为一级缓存没有主动失效机制只能靠更新操作或者sqlSession.clearCache()来清理。所以生产环境里长生命周期SqlSession是一个非常危险的东西一定要保证SqlSession短命并及时关闭。4.2 二级缓存namespace级别的跨会话缓存二级缓存默认是关闭的你需要手动开启。开启方式是在Mapper XML中添加cache/标签或者在Mapper接口上加CacheNamespace注解。二级缓存的作用域是namespace也就是同一个Mapper下的所有SqlSession可以共享缓存。配置了cache/之后MyBatis会为这个Mapper创建一个缓存实例默认实现还是PerpetualCache但外面会套上多个装饰器比如LruCacheLRU淘汰、SerializedCache序列化、ScheduledCache定时刷新。所以默认情况下缓存到二级缓存的对象必须实现Serializable接口否则会序列化失败。二级缓存的实际管理不是直接Put到缓存里的而是通过TransactionalCacheManager。在执行查询时如果命中二级缓存直接返回结果如果没有命中查询一次数据库然后把结果存到TransactionalCache中。TransactionalCache不会立刻写入真正的二级缓存而是等事务提交时才真正提交缓存。事务回滚时这个临时缓存会被清空。这个设计是为了避免脏缓存——比如一个事务还没结束时查询的数据被其他会话读到了结果这个事务回滚了数据就变成了脏数据。4.3 缓存引发的典型脏读问题面试里有一道经典题MyBatis二级缓存为什么容易产生脏数据我来拆一下。二级缓存的粒度是namespace但复杂的业务查询经常跨表join。比如OrderMapper里有一条join了User表的查询SQL这条SQL会把用户信息也查出来放到Order的namespace缓存里。此时如果修改了UserMapper的操作它会更新自己的namespace缓存但不会让OrderMapper的缓存失效。于是再次查询OrderMapper那条join SQL时缓存返回的可能是旧用户数据。所以我的建议是注意涉及多表关联查询、频繁更新的业务慎用二级缓存。MyBatis官方文档也提到像cache-ref这种跨namespace引用一定要能确保自己清楚缓存一致性机制否则别乱用。这也是为什么很多团队选择默认关掉二级缓存只在读多写少、几乎不涉及Join的简单场景打开。5. TypeHandler类型转换的幕后推手5.1 TypeHandler在什么时候介入写MyBatis的时候你会在resultMap里写jdbcTypeVARCHAR或者在参数上用Param指定类型。但Java的String、Integer、LocalDateTime这些类型和数据库的VARCHAR、INT、TIMESTAMP并不是天然对应的中间那层转换靠的就是TypeHandler。TypeHandler有两条工作路径参数设置阶段在执行PreparedStatement时MyBatis调用handler.setParameter(ps, i, parameter, jdbcType)把Java参数写入SQL占位符。结果读取阶段在执行ResultSet查询时MyBatis调用handler.getResult(rs, columnName)把数据库列值转换为Java对象。TypeHandlerRegistry有一个默认的注册表覆盖了基本类型、String、日期类型、BigDecimal、字节数组等常见类型。如果你不主动配置MyBatis会根据参数的Java类型自动找到对应的TypeHandler。这个自动查找过程发生在MappedStatement的参数映射和结果映射的解析阶段。5.2 自定义TypeHandler实战遇到枚举类型、JSON字符串这类特殊对象默认TypeHandler就无能为力了。我举个实际例子有一个订单状态字段数据库存INT0、1、2Java里用枚举类型OrderStatus表示。自定义TypeHandler需要继承BaseTypeHandlerT实现四个抽象方法MappedTypes(OrderStatus.class) MappedJdbcTypes(JdbcType.INTEGER) public class OrderStatusTypeHandler extends BaseTypeHandlerOrderStatus { Override public void setNonNullParameter(PreparedStatement ps, int i, OrderStatus parameter, JdbcType jdbcType) throws SQLException { ps.setInt(i, parameter.getCode()); } Override public OrderStatus getNullableResult(ResultSet rs, String columnName) throws SQLException { return OrderStatus.fromCode(rs.getInt(columnName)); } Override public OrderStatus getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return OrderStatus.fromCode(rs.getInt(columnIndex)); } Override public OrderStatus getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return OrderStatus.fromCode(cs.getInt(columnIndex)); } }注册方式有三种在mybatis-config.xml里用typeHandlers注册在SpringBoot里用Configuration注册或者在定义类上直接加MappedTypes注解然后放到扫描路径里。我踩过的一个坑是自定义TypeHandler注册了但没生效。原因通常是XML中的resultMap里没有显式指定typeHandler属性MyBatis在结果映射时只根据Java类型去注册表找如果多个TypeHandler对应了同一个Java类型就会模糊。解决方案是显式在resultMap的column映射中写typeHandlercom.xxx.OrderStatusTypeHandler这样优先级最高避免歧义。6. 动态SQL条件不生效的排查经验6.1 #{}与${}的区别不只是魔数注入很多初级开发知道${}有SQL注入风险但不知道它俩在底层实现上的本质差别。#{}在预编译阶段会变成?占位符参数通过PreparedStatement传入。${}则是在SQL文本组装阶段直接拼接字符串不管你是传字符串还是数字都会被拼到SQL里再执行。所以${}被注入攻击的可能性极高。但在某些场景比如动态传表名、排序字段ORDER BY columnName你没法用#{}只能用${}。这时候我的建议是一定要做白名单校验。比如表名、排序字段用枚举或固定的List去校验不在白名单里就直接拒绝。6.2 if test条件不生效的几个经典场景mybatis条件不生效这个热搜词我很确定说的就是动态SQL里if test失效。总结下来最常见的三个原因场景一test里的字符串比较写错了。如果你要判断字符串是否等于某个值写成teststatus 1在某些情况下会报错或出错。稳妥写法是teststatus 1内单外双。如果status是String类型更推荐用test1.equals(status)避免空指针和类型转换问题。场景二参数是包装类型判断null和空字符串的先后顺序不合理。比如if testuserName ! null and userName ! AND user_name #{userName} /if看起来没问题但如果userName是Integer类型这里userName ! 其实是不合法的MyBatis在解析OGNL表达式时会因为类型不匹配直接抛异常或者静默失败。写动态条件前先想清楚参数的真实类型。场景三多个SQL条件共用某个参数但方法签名没有加Param。比如Mapper方法传两个参数直接写insertSelective(User user, Integer status)你在XML里用#{user.name}和#{status}没问题但如果你在if teststatus ! null里用statusMyBatis是找不到的因为多参数时必须用Param(status)给参数命名否则XML里只能用param1、param2这种位置参数名签名里没有status这个名字。这种情况查日志通常会看到Parameter status not found。排查动态SQL问题我有一个习惯先开启日志打印看生成的SQL长什么样。SQL本身就能告诉你if条件到底有没有匹配上。如果if里没拼接上的条件生成的SQL里自然没有对应片段。7. SpringBoot整合MyBatis从零实现注册功能7.1 项目整合思路与基础配置SpringBoot整合MyBatis其实不需要写mybatis-config.xml因为SpringBoot推荐全注解或纯application配置。起步依赖用mybatis-spring-boot-starter版本和SpringBoot版本匹配即可。在你的application.yml里需要配置数据源和MyBatis相关属性spring: datasource: url: jdbc:mysql://localhost:3306/test_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.demo.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case这个配置非常实用它让数据库列名user_name自动映射为Java属性userName省去了写resultMap的工作量。log-impl设置为StdOutImpl就可以在控制台直接看到MyBatis生成的SQL和参数排查问题效率翻倍。启动类上别忘了加MapperScan(com.example.demo.mapper)把Mapper接口扫描进Spring容器。这一步相当于替代了原来XML里的mappers配置。7.2 注册功能实现Mapper接口和XML配合假设我们做一个最简单的注册功能用户填用户名和密码往user表插一条记录。实体类Userpublic class User { private Integer id; private String username; private String password; private LocalDateTime createTime; // 省略getter/setter }Mapper接口public interface UserMapper { int insertUser(User user); User findByUsername(Param(username) String username); int countByUsername(Param(username) String username); }Mapper XML?xml version1.0 encodingUTF-8 ? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.demo.mapper.UserMapper insert idinsertUser parameterTypecom.example.demo.entity.User useGeneratedKeystrue keyPropertyid INSERT INTO user (username, password, create_time) VALUES (#{username}, #{password}, #{createTime}) /insert select idfindByUsername resultTypecom.example.demo.entity.User SELECT id, username, password, create_time FROM user WHERE username #{username} /select select idcountByUsername resultTypeint SELECT COUNT(*) FROM user WHERE username #{username} /select /mapper需要解释一下useGeneratedKeystrue和keyPropertyid这表示插入成功后数据库自增主键会自动回填到User对象的id字段。如果没有这两行你插入完想拿主键得再查一次或用selectKey标签特别麻烦。Service层实现注册逻辑时要做两件事先countByUsername检查用户名是否已被占用如果没占用再执行insertUser。实际项目里建议给username加唯一索引用数据库约束兜底因为并发情况下仅靠先查再插仍然可能出现重复。Controller层就比较简单了接收前端提交的RegisterRequest封装成User对象调Service层方法返回成功或失败的结果。基于SpringBoot加MyBatis的实现这个注册功能已经可以直接跑通。如果你用的是MyBatis-Plus那insertUser和findByUsername这些简单SQL甚至不用写XML直接用BaseMapper提供的方法就行。7.3 SQL日志打印配置与调试心得热搜词里有mybatis配置打印这里必须细说。MyBatis打印SQL有两个层面。一个是我上面提到的log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这是直接把SQL打到控制台简单粗暴适合开发环境。但到了测试或生产环境用stdout会夹杂大量日志不便于归档和查询。更规范的做法是利用日志系统的level控制logging: level: com.example.demo.mapper: debug把Mapper所在的包日志级别设为debug配合log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl就可以把SQL日志输出到你的logback/log4j2系统里统一格式、统一管理。这样既能保留SQL日志又不会污染控制台。我自己调试一个诡异Bug的时候通常不看业务日志直接看SQL日志。对比我的XML条件是什么和实际生成的SQL是什么就能定位99%的动态SQL问题。剩下1%再看Preparing和Parameters两行日志里绑定的参数值基本也能定位。8. 常见问题速查与个人经验总结平时维护老项目或者带新人排查MyBatis问题我总结了一张问题速查表结合热搜词里的高频关键词整理如下现象大概率原因解决方向Mapper方法找不到SQLnamespace写错或Mapper XML没扫描到检查MapperScan扫描路径和XML的mapper-locations动态SQL条件没拼接test写错、参数名缺失、OGNL表达式类型不匹配打印SQL对比给多参数加Param批量插入很慢没开启rewriteBatchedStatementstrue或循环单插开启批量重写分片foreach或BatchExecutor查询出旧数据一级缓存命中或二级缓存脏数据确保SqlSession短命复杂查询慎用二级缓存枚举、JSON字段存取异常缺少匹配的TypeHandler自定义TypeHandler并显式注册自增主键拿不到insert没配useGeneratedKeys配置keyProperty或selectKeySQL日志打印不出来log-impl配置缺失或包级别没设debug配置log-impl并调整日志级别最后分享一个我个人的排错习惯凡是MyBatis报错第一件事永远是开SQL日志第二件事是打印出Configuration里对应那条MappedStatement的SQL。很多时候你觉得自己写的是A但MyBatis解析出来的是B不用争吵看日志就行。还有一点如果你在同一个事务里先做批量插入再查询遇到查不到数据别慌先确认是不是BatchExecutor没有flush。这个坑我帮别人排查过不止一次最终都是因为ExecutorType.BATCH模式下SQL还攒在Statement里没有真正发给数据库。手动调一次sqlSession.flushStatements()或者调整事务边界问题就消失了。MyBatis这个框架入门确实容易但真正用好的边界条件非常多。它给了你SQL的灵活性代价就是所有映射细节都要你自己承担。能把它初始化流程、缓存、TypeHandler、动态SQL这些核心问题搞透无论是开发效率还是面试表现都会明显不一样。