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:一个Map,key是"Mapper接口全限定名.方法名",value是MappedStatement对象。每个MappedStatement代表一条SQL命令,包含sqlSource、statementType、resultMaps、parameterMap、keyGenerator、缓存配置等。resultMaps:存的是ResultMap,负责数据库列名到Java属性的映射规则。typeHandlerRegistry:类型处理器的注册表,保存Java类型、JDBC类型和TypeHandler的对应关系。cacheRegistry/caches:二级缓存的注册中心。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内部持有SqlCommand(SQL命令类型,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提供了三种内置Executor:
SimpleExecutor:每执行一次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上加上rewriteBatchedStatements=true。否则MySQL驱动并不会把多条insert合并成一条多值insert,BatchExecutor的性能优势就发挥不出来。这个参数很多人不知道,属于批量优化里的关键点。
4. 缓存机制:一级缓存和二级缓存实现原理
4.1 一级缓存:SqlSession级别的本地缓存
MyBatis一级缓存默认开启,范围是SqlSession。意思是在同一会话中,如果执行了同一条SQL(参数一致、语句一致),第二次不会真的去查数据库,而是直接返回缓存中的结果。
一级缓存的实现类是PerpetualCache,本质上就是一个HashMap,key是CacheKey,value是查询结果对象。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,但外面会套上多个装饰器,比如LruCache(LRU淘汰)、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里写jdbcType=VARCHAR,或者在参数上用@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就无能为力了。我举个实际例子:有一个订单状态字段,数据库存INT(0、1、2),Java里用枚举类型OrderStatus表示。
自定义TypeHandler需要继承BaseTypeHandler<T>,实现四个抽象方法:
@MappedTypes(OrderStatus.class) @MappedJdbcTypes(JdbcType.INTEGER) public class OrderStatusTypeHandler extends BaseTypeHandler<OrderStatus> { @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映射中写typeHandler="com.xxx.OrderStatusTypeHandler",这样优先级最高,避免歧义。
6. 动态SQL:条件不生效的排查经验
6.1 #{}与${}的区别,不只是魔数注入
很多初级开发知道"${}有SQL注入风险",但不知道它俩在底层实现上的本质差别。
#{}在预编译阶段会变成?占位符,参数通过PreparedStatement传入。${}则是在SQL文本组装阶段直接拼接字符串,不管你是传字符串还是数字,都会被拼到SQL里再执行。所以${}被注入攻击的可能性极高。
但在某些场景,比如动态传表名、排序字段(ORDER BY columnName),你没法用#{},只能用${}。这时候我的建议是:一定要做白名单校验。比如表名、排序字段用枚举或固定的List去校验,不在白名单里就直接拒绝。
6.2 if test条件不生效的几个经典场景
"mybatis条件不生效"这个热搜词,我很确定说的就是动态SQL里<if test="">失效。总结下来最常见的三个原因:
场景一:test里的字符串比较写错了。如果你要判断字符串是否等于某个值,写成test="status == '1'",在某些情况下会报错或出错。稳妥写法是test='status == "1"',内单外双。如果status是String类型,更推荐用test="'1'.equals(status)",避免空指针和类型转换问题。
场景二:参数是包装类型,判断null和空字符串的先后顺序不合理。比如:
<if test="userName != 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 test="status != null">里用status,MyBatis是找不到的,因为多参数时必须用@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?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/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表插一条记录。
实体类User:
public 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 version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.demo.mapper.UserMapper"> <insert id="insertUser" parameterType="com.example.demo.entity.User" useGeneratedKeys="true" keyProperty="id"> INSERT INTO user (username, password, create_time) VALUES (#{username}, #{password}, #{createTime}) </insert> <select id="findByUsername" resultType="com.example.demo.entity.User"> SELECT id, username, password, create_time FROM user WHERE username = #{username} </select> <select id="countByUsername" resultType="int"> SELECT COUNT(*) FROM user WHERE username = #{username} </select> </mapper>需要解释一下useGeneratedKeys="true"和keyProperty="id":这表示插入成功后,数据库自增主键会自动回填到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方法找不到SQL | namespace写错或Mapper XML没扫描到 | 检查@MapperScan扫描路径和XML的mapper-locations |
| 动态SQL条件没拼接 | test写错、参数名缺失、OGNL表达式类型不匹配 | 打印SQL对比,给多参数加@Param |
| 批量插入很慢 | 没开启rewriteBatchedStatements=true,或循环单插 | 开启批量重写,分片foreach或BatchExecutor |
| 查询出旧数据 | 一级缓存命中,或二级缓存脏数据 | 确保SqlSession短命,复杂查询慎用二级缓存 |
| 枚举、JSON字段存取异常 | 缺少匹配的TypeHandler | 自定义TypeHandler并显式注册 |
| 自增主键拿不到 | insert没配useGeneratedKeys | 配置keyProperty或selectKey |
| SQL日志打印不出来 | 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这些核心问题搞透,无论是开发效率还是面试表现,都会明显不一样。