1. 项目整合与整体设计思路
1.1 为什么MyBatis至今仍是国内Java项目的标配
做Java后端开发的朋友,肯定绕不开ORM框架这个话题。这些年JPA、Hibernate、MyBatis-Plus轮番登场,但说实话,国内大多数互联网公司的核心交易系统里,跑在最前面的还是MyBatis。原因很简单:它把SQL的控制权完全交还给开发者,你写什么SQL,它就执行什么SQL,没有魔法,没有意外。对于复杂查询、多表关联、动态条件拼装这些场景,MyBatis的灵活度是JPA那套实体映射思维给不了的。
我经常跟刚入行的同事打比方:MyBatis像手动挡的车,JPA像自动挡。手动挡累,但你能精确控制发动机转速;自动挡省心,但偶尔会替你“自作主张”。做金融、电商、订单这类对SQL执行路径有严格要求的系统,手动挡才让人放心。
这个教程面向的读者,是那些已经能写Java、知道JDBC基本操作,但对MyBatis只停留在“会用@Select注解”阶段的同学。学完之后,你应该能搞懂MyBatis的初始化原理、SqlSession的来龙去脉、一级二级缓存的工作机制、TypeHandler的扩展方式,以及日常开发里最常见的批量操作和条件排查。说白了,就是让你从“会用”进化到“用得明白”。
1.2 整体架构:从SqlSession到Mapper的调用链路
先看一张极简的调用链路图(手画,脑补):
Java代码 -> SqlSession -> Executor -> StatementHandler -> ParameterHandler -> JDBC -> ResultSetHandler <- TypeHandler <- JDBC ResultSet我第一次看这个链路的时候,最大的困惑是:我写的Mapper接口里明明没有实现类,为什么Spring注入进去就能直接调用?后来才明白,MyBatis用了JDK动态代理,给每个Mapper接口生成了一个代理对象,调用任何接口方法时,代理逻辑会把它翻译成一条SQL执行请求。
这里有个点我想强调:很多人把MyBatis理解成“一个SQL映射工具”,这没错,但不够准确。MyBatis的底层核心实际是 Executor。Executor负责调度缓存、事务、SQL执行,StatementHandler负责和JDBC打交道,ParameterHandler负责把Java参数转成JDBC类型,ResultSetHandler负责把查询结果转成Java对象。这四个组件就是MyBatis的四梁八柱,后面聊源码和面试题,基本都围绕它们展开。
我自己在项目里踩过一次大坑:以前为了图省事,把SQL写在注解里,结果后来需求变更,要动态拼接一个非常复杂的where条件。注解里写XML标签特别别扭,可读性极差,最后被迫改回XML文件。从那之后,凡是复杂SQL我一律走XML,注解只留给那些简单的增删改查。
2. XML配置初始化工作原理
2.1 XMLConfigBuilder到底在做什么
MyBatis启动的时候,第一步一定是解析全局配置文件。如果你用的是XML配置方式,入口就是XMLConfigBuilder这个类。很多人在热词里搜“mybatis中xmlconfigbuilser的工作流程图”,说明大家对这个类的执行顺序普遍感兴趣。
先看一个最基础的mybatis-config.xml长什么样:
<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "https://mybatis.org/dtd/mybatis-3-config.dtd"> <configuration> <environments default="development"> <environment id="development"> <transactionManager type="JDBC"/> <dataSource type="POOLED"> <property name="driver" value="com.mysql.cj.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/test"/> <property name="username" value="root"/> <property name="password" value="123456"/> </dataSource> </environment> </environments> <mappers> <mapper resource="mapper/UserMapper.xml"/> </mappers> </configuration>XMLConfigBuilder做的事情,其实就是把上面这个XML文件逐标签解析,然后填充到一个Configuration对象里。Configuration是MyBatis的全局数据中心,它保存了所有注册的Mapper、别名、类型处理器、缓存、插件、环境信息等。
我拆一下它的工作流程,方便你记忆:
- 解析
<properties>标签:加载外部属性文件,支持占位符替换。 - 解析
<settings>标签:设置全局参数,比如是否开启驼峰映射、缓存开关、执行器类型。 - 解析
<typeAliases>标签:注册类别名,之后在XML里写resultType="user"而不是全限定类名。 - 解析
<typeHandlers>标签:注册自定义TypeHandler,后面专门讲。 - 解析
<objectFactory>、<objectWrapperFactory>、<reflectorFactory>:扩展点,平时用得少,但面试会问。 - 解析
<plugins>标签:注册拦截器,MyBatis的插件机制就在这里。 - 解析
<environments>标签:配置数据源和事务工厂。 - 解析
<databaseIdProvider>标签:支持多数据库适配。 - 解析
<mappers>标签:逐个加载Mapper映射文件。
有一个细节我提醒一下:<settings>和<properties>的位置是有讲究的。DOCTYPE里面定义了标签的顺序约束,你把<settings>写到<typeAliases>后面,解析直接报错。这个错很经典,新手一碰一个准。
2.2 基于XML配置的初始化流程拆解
前面说的是XMLConfigBuilder内部的职责,现在把整个初始化流程串起来。以原生MyBatis(不经过Spring)为例,代码入口是这样的:
String resource = "mybatis-config.xml"; InputStream inputStream = Resources.getResourceAsStream(resource); SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);这段代码背后发生了四件事,每一件都值得掰开揉碎。
第一,SqlSessionFactoryBuilder收到InputStream后,会创建XMLConfigBuilder,并调用parse()方法。parse()的核心逻辑是逐项解析XML并赋值给Configuration,最后返回Configuration对象。
第二,拿到Configuration后,SqlSessionFactoryBuilder会调用new DefaultSqlSessionFactory(configuration),创建一个SqlSessionFactory。这里注意:SqlSessionFactory里保存的是不可变的Configuration引用,后续通过它获取的SqlSession都共享这套配置。
第三,通过sqlSessionFactory.openSession()创建SqlSession。这一步MyBatis会操作Executor。默认情况下,Executor类型是SimpleExecutor,但如果二级缓存开启,这里会套一层CachingExecutor,也就是装饰器模式。
第四,调用Mapper接口方法时,SqlSession从Configuration中拿出MappedStatement(一个方法对应一条SQL的封装对象),然后交给Executor执行。
整个流程用文字写出来大概是这样:
读取XML配置文件 -> XMLConfigBuilder.parse() -> 构建Configuration对象 -> 解析Mapper映射文件 -> 生成MappedStatement集合 -> 构建DefaultSqlSessionFactory -> 创建SqlSession -> 获取Mapper代理 -> 执行SQL热词里还有“mybatis中初始化工作流程”,我猜问的是Spring Boot自动装配之后的流程。Spring Boot下多了一个MybatisAutoConfiguration,它会在Spring容器里自动注册SqlSessionFactory和SqlSessionTemplate,原理上仍然是基于XMLConfigBuilder或者Configuration构建器,只是入口从手动build()变成了框架回调。
2.3 自定义Configuration:当你需要干预MyBatis内部行为时
热词里有人搜“mybatis中自定义configuration”,大概率是想在启动时给MyBatis加自己的逻辑。最常见的做法是继承org.apache.ibatis.session.Configuration,覆盖某些方法,然后传给SqlSessionFactoryBuilder。
举个例子:我想让MyBatis在解析每个Mapper接口时,自动给某个特定注解的方法做额外校验,可以这样做:
public class CustomConfiguration extends Configuration { @Override public void addMappedStatement(MappedStatement ms) { // 自定义校验逻辑 if (ms.getSqlCommandType() == SqlCommandType.SELECT) { // 比如要求所有SELECT必须设置resultMap(只是举例,别当真) } super.addMappedStatement(ms); } }然后:
SqlSessionFactory factory = new SqlSessionFactoryBuilder() .build(inputStream, null, new CustomConfiguration());说句实在话,我在项目里用到自定义Configuration的次数屈指可数。大部分需求通过Interceptor(插件)就能解决,比如MyBatis-Plus的分页插件本质就是一个Interceptor,它没有改Configuration的类结构。自定义Configuration更适合做全局基建,比如统一给所有SQL加一个租户过滤条件、强制enable某些性能监控指标,这种横切逻辑才值得在Configuration层面做。
还有一种玩法,是通过Configuration对象实例化时往里手动注册TypeHandler、Interceptor,而不写XML。Spring Boot的ConfigurationCustomizer就是干这个的:
@Bean ConfigurationCustomizer mybatisConfigurationCustomizer() { return configuration -> { configuration.setMapUnderscoreToCamelCase(true); configuration.addInterceptor(new MySqlPaginationInterceptor()); }; }这种方法比继承Configuration更轻量,我推荐优先用这个。继承类的方式容易在MyBatis升级时踩到兼容问题。
3. Spring Boot整合MyBatis与SQL打印
3.1 最小可运行整合步骤
热词里有“项目整合 - springboot + mybatis”和“使用springboot + mybatis实现一个最简单的注册功能”,我把这两件事合并到一起讲。
Spring Boot整合MyBatis已经非常成熟了,核心就三步。
第一步,引入依赖。用mybatis-spring-boot-starter,版本要和Spring Boot版本匹配。以Spring Boot 2.7.x为例,引入:
<dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency>第二步,配置application.yml:
spring: datasource: url: jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=utf8&useSSL=false 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这里有个细节:mybatis.configuration.map-underscore-to-camel-case这个设置极其重要。数据库字段名通常是user_name,Java属性是userName,不开驼峰映射,查出来全是null,而且不报错。这是新手第二大坑(第一大坑是忘了加@MapperScan或者@Mapper注解)。
第三步,写代码。给启动类加@MapperScan:
@SpringBootApplication @MapperScan("com.example.demo.mapper") public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }然后写实体类、Mapper接口、XML文件。拿“注册功能”举例:
public class User { private Long id; private String username; private String password; private String email; // getter/setter 省略 }public interface UserMapper { int insert(User user); }<insert id="insert" parameterType="user" useGeneratedKeys="true" keyProperty="id"> INSERT INTO user (username, password, email) VALUES (#{username}, #{password}, #{email}) </insert>这里解释一下useGeneratedKeys="true" keyProperty="id"这两行的意义:它告诉MyBatis,插入之后把数据库自增主键的值回填到传入的User对象的id属性上。不加这两行,你插入完想要用户ID,就得再查一次,很蠢。
3.2 配置打印SQL的三种方式
工作中排查问题时,看不到SQL简直寸步难行。热词“mybatis配置打印”搜的人很多。打印SQL其实有三种姿势,我按推荐程度排个序。
第一种,控制台直接打印日志。在application.yml里加:
logging: level: com.example.demo.mapper: debug这种方式的原理是:MyBatis的Mapper接口对应的日志级别设置为debug后,StatementHandler内部会通过日志框架输出SQL、参数、执行结果。输出效果类似:
==> Preparing: SELECT * FROM user WHERE id = ? ==> Parameters: 1(Integer) <== Total: 1这种是我日常工作用得最多的,因为日志分类清晰,按包控制,不会刷屏。
第二种,使用mybatis-plus(什么?你还在用原生MyBatis?那也可以)的控制台SQL日志插件。对于大量SQL,它会把完整的预编译SQL和参数拼好打印出来,方便直接粘到Navicat里执行。这适合确认SQL语法正确性。
第三种,配置MyBatis的statementHandler拦截器,自己打印全量SQL。这种方式最灵活,但需要写插件,适合有定制企业规范的情况。说个注意事项:千万别在日志里打印带完整敏感字段值的SQL,线上环境密码字段会直接被刷出来。所以我在生产环境一般只打印SQL模板和参数类型,不打印参数值。
另外,网上有人推荐在jdbc url上加?loggerSlf4jImpl=...打印SQL,这其实是另一个思路:使用slf4j的JDBC代理驱动。效果和第一种类似,但它打印的是JDBC层面的执行,能看到真实发送给数据库的语句。我建议初级开发者优先用第一种,简单直接无侵入,后期需要深入分析时再上JDBC代理。
3.3 Mapper接口与XML绑定的常见坑
用Spring Boot整合时,Mapper接口和XML绑定有几个经典问题,我几乎每周都能在技术群里看到有人问。
坑一:mapper-locations路径配置后XML没加载。解决办法是检查target/classes目录下有没有对应的XML文件。如果你把XML放在src/main/java目录下,Maven默认不会把它复制到输出目录。要么把XML放在src/main/resources/mapper/下,要么在pom.xml中配置resources参数,允许java目录下的xml被打包。
坑二:Mapper接口方法名与XML中的id不一致。MyBatis限定了两者必须严格对应,比如接口里有User findById(Long id),XML里的select标签id就必须是findById,namespace必须是对应接口的全限定名。我见过有人namespace写错,结果Spring启动时才报Invalid bound statement (not found),排查半天。
坑三:Mapper接口里重载方法。MyBatis不支持同接口内同名方法的映射,一旦有两个方法都叫findById,必挂。很多人从Spring data JPA转过来,习惯写重载,这是原生MyBatis最容易踩的雷。
这里我分享一个个人习惯:用IDE一键生成Mapper XML骨架(IDEA有个插件叫MyBatisX),能自动跳转,自动生成基础语句。此外,服务启动时配置mybatis.configuration.map-underscore-to-camel-case和mapper-locations这两个参数,直接决定了你后面写代码的顺畅程度。一行配置,值回票价。
4. TypeHandler与参数/结果集映射
4.1 TypeHandler的工作流程
热词里有一条“mybatis中typehandler的工作流程图”。TypeHandler在MyBatis里是个不上台面但不可或缺的角色,它负责Java类型和JDBC类型之间的双向转换。
我先抛出一个经典例子:数据库里存的sex字段是0/1,Java实体里想用Boolean的false/true来接收。如果你不处理,查询结果会一直是0或1,而不是Boolean。处理方式就是自定义TypeHandler。
TypeHandler接口定义非常简单:
public interface TypeHandler<T> { void setParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException; T getResult(ResultSet rs, String columnName) throws SQLException; T getResult(ResultSet rs, int columnIndex) throws SQLException; }它的工作流程,我用四个步骤说明:
- 参数写入阶段:当MyBatis执行PreparedStatement的
setParameter时,它会用ParameterHandler遍历SQL里的#{xxx}参数,每个参数根据Java类型匹配对应的TypeHandler,调用setParameter方法,把Java对象写入JDBC的PreparedStatement。 - 结果映射阶段:查询返回ResultSet后,ResultSetHandler根据MappedStatement里记录的resultMap或resultType,拿到每一列的JdbcType和JavaType,然后调用对应的TypeHandler的
getResult方法,取出数据库值并转化为Java值。 - 类型匹配规则:MyBatis默认注册了一堆TypeHandler(BooleanTypeHandler、IntegerTypeHandler、StringTypeHandler等),它有一个TypeHandlerRegistry来维护Java类型到JdbcType的映射关系。当默认匹配不到,或你显式指定了typeHandler属性,则使用自定义的。
- 注册与生效:自定义TypeHandler要在mybatis-config.xml里注册,或者在Mapper XML的
<resultMap>的<result>标签中通过typeHandler属性指定。
画成流程就是:
PreparedStatement.setObject() <- ParameterHandler <- TypeHandler(Java -> JDBC) ResultSet.getObject() -> ResultSetHandler -> TypeHandler(JDBC -> Java)这句总结请大家记住:TypeHandler连接的是两个世界,一个是Java类型系统,一个是JDBC类型系统。
4.2 自定义TypeHandler实战
现在拿布尔逻辑那个例子,写一个把数据库0/1转成Boolean的TypeHandler。
@MappedTypes(Boolean.class) @MappedJdbcTypes(JdbcType.TINYINT) public class BooleanTypeHandler extends BaseTypeHandler<Boolean> { @Override public void setNonNullParameter(PreparedStatement ps, int i, Boolean parameter, JdbcType jdbcType) throws SQLException { ps.setInt(i, parameter ? 1 : 0); } @Override public Boolean getNullableResult(ResultSet rs, String columnName) throws SQLException { int value = rs.getInt(columnName); return value == 1; } @Override public Boolean getNullableResult(ResultSet rs, int columnIndex) throws SQLException { int value = rs.getInt(columnIndex); return value == 1; } @Override public Boolean getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { int value = cs.getInt(columnIndex); return value == 1; } }注册方式有两种。
XML方式:
<typeHandlers> <typeHandler handler="com.example.demo.handler.BooleanTypeHandler"/> </typeHandlers>注解方式(Mapper XML里用):
<resultMap id="userResultMap" type="user"> <id property="id" column="id"/> <result property="sex" column="sex" typeHandler="com.example.demo.handler.BooleanTypeHandler"/> </resultMap>实战心得:自定义TypeHandler的核心场景有这些——JSON字段与Java对象的互转(现在都用JacksonTypeHandler了)、日期类型的特殊格式(比如数据库存BIGINT毫秒值)、枚举的存储(数字或字符串)、加密字段的透明加解密。特别是加密字段,用TypeHandler做透明解密真的舒服,代码里无感。
注意一个坑:如果你的实体字段名在resultMap里没显式配置typeHandler,那么即使你在全局xml里注册了自定义TypeHandler,也会因为默认类型匹配而覆盖。TypeHandler查找顺序是先看resultMap/参数上的显式声明,再看@MappedTypes注解和注册表。所以,无法覆盖的情况,干脆在resultMap里写死,最稳妥。
5. 缓存机制剖析
5.1 一级缓存:容易被忽略的SqlSession级缓存
MyBatis缓存是面试必问题,也是很多人在热词里搜索“mybatis缓存”的原因。我先说一级缓存:它默认开启,作用范围是SqlSession。
一级缓存机制其实很朴素:同一个SqlSession里执行同一条SQL(相同statementId和相同参数),第二次执行直接命中缓存,不会再查数据库。内部实现是Executor里的localCache属性,数据类型是PerpetualCache,本质是一个HashMap。
为什么叫“朴素”?因为它的生命周期太短了,一旦SqlSession关闭,缓存立即清空。而在Spring整合环境下,每个Mapper方法执行时都会从SqlSessionTemplate里动态获取SqlSession——默认每次执行完毕就关闭,所以一级缓存带来的性能提升几乎感觉不到。它的作用主要体现在事务场景里(同一个事务共用一个SqlSession),比如你一个事务里先select后update再select同一行数据,第二次select能直接读到当前事务内的最新值。
一级缓存有哪些失效场景?我罗列一下:
- 两次查询之间有insert、update、delete操作(MyBatis会清空缓存);
- 查询条件不同(缓存key包含所有参数);
- 使用了
SqlSession.clearCache(); - 查询不同statement,哪怕SQL相同;
- 手动开启
localCacheScope="STATEMENT"(设置后缓存仅对当前一条语句生效)。
说实话,生产环境一级缓存能带来的优化有限,但它有一个副作用值得警惕:如果你在同一个SqlSession里反复查询同一条SQL,第二次拿到的是同一个对象的引用(一级缓存直接缓存对象)。你如果修改了这个对象的某个属性,后续任何地方使用它都会受影响。这也是MyBatis早期“缓存脏读”的一个吐槽点。
5.2 二级缓存:跨SqlSession的共享缓存与实现
二级缓存是面试重点。它的作用域是namespace,也就是一个Mapper接口(或XML)。开启方式分两步:
第一步,在mybatis-config.xml里开启全局缓存开关:
<settings> <setting name="cacheEnabled" value="true"/> </settings>注意:cacheEnabled默认值就是true,如果你没显式配置,默认是开启的。真正决定二级缓存是否生效的是第二步。
第二步,在具体的Mapper XML里配置<cache/>:
<mapper namespace="com.example.demo.mapper.UserMapper"> <cache eviction="LRU" flushInterval="60000" size="512" readOnly="false"/> ... </mapper><cache/>标签的几个参数分别代表:eviction是回收策略,默认LRU(最久未使用);flushInterval是刷新间隔,单位毫秒;size是缓存对象数量上限;readOnly为true时返回相同对象(不安全但快),false时返回序列化副本(安全但慢)。readOnly="false"要求实体实现Serializable接口,很多人漏掉这一步,一开二级缓存就报NotSerializableException。
二级缓存的工作机制我要特别说明:查询操作先按MappedStatement + 参数 + RowBounds生成缓存key,从CachingExecutor缓存中查找,命中则直接返回,未命中则委托给底层Executor执行并把结果放入缓存。但是,任何一次insert/update/delete操作都会清空该namespace下的二级缓存——所以你会看到一种现象:一个Mapper里读写混合,二级缓存命中率极低,形同虚设。
我自己做项目时,二级缓存用的非常克制。只在两类场景开:
- 字典表、配置表这种几乎不更新、访问量很大的数据。
- 单表查询且实体几乎不变的历史数据。
如果是多表关联查询的结果,千万别塞进二级缓存。原因很简单:一个select关联了user和order两个表,但你只能把它缓存在某一个Mapper的namespace里。此时order表更新了,二级缓存不会自动失效,你读到的就是脏数据。这是MyBatis缓存设计的一个经典缺陷。
再说一句源码层面的事情:二级缓存在Executor这一层是通过装饰器实现的。Configuration.newExecutor()里有个判断:
if (cacheEnabled) { executor = new CachingExecutor(executor); }CachingExecutor包在SimpleExecutor或ReuseExecutor外面,执行查询前先查缓存,执行更新后清缓存。有兴趣深挖的话,从CachingExecutor.query()方法往下断点跟一遍,比我写一万字都直观。
6. 常用操作与进阶:批量处理、条件查询与MyBatis-Plus
6.1 批量插入的正确姿势
热词里有“java mybatis mybatis-plus 批量”,这几乎是每个项目都躲不掉的需求。批量插入的实现方式有三种,我按效率从低到高排。
第一种,循环单条insert。代码长这样:
for (User user : userList) { userMapper.insert(user); }这种写法发起的SQL请求数是N次,性能最差。如果N很大,数据库连接池会被拖垮。不要笑,真有人这么干,还让DBA背锅。
第二种,使用<foreach>标签拼一条SQL:
<insert id="batchInsert"> INSERT INTO user (username, password, email) VALUES <foreach collection="list" item="item" separator=","> (#{item.username}, #{item.password}, #{item.email}) </foreach> </insert>这种方式只发一次SQL,性能尚可。但要注意MySQL对单条SQL的max_allowed_packet限制,超过限制会报错。所以每次批量条数不要贪多,我一般控制在500到1000条。如果是PostgreSQL,这种语法同样适用。SQL Server和Oracle不兼容这种写法,它们有自己的语法,做多数据库兼容时要注意。
第三种,使用ExecutorType.BATCH,手动控制:
SqlSession sqlSession = sqlSessionTemplate.getSqlSessionFactory() .openSession(ExecutorType.BATCH); try { UserMapper mapper = sqlSession.getMapper(UserMapper.class); for (User user : userList) { mapper.insert(user); } sqlSession.commit(); } finally { sqlSession.close(); }这种方式是JDBC层面的批量提交,每次addBatch不会立即执行,直到commit时才真正发送。它的优势是SQL执行次数少,但缺点是无法拿到自增主键回填(有些驱动可以,看兼容性),且一次缓存的数据量过大容易OOM。
那么,为什么大家都推荐MyBatis-Plus的saveBatch?因为它内部封装了ExecutorType.BATCH的逻辑,自动循环分批提交,每批1000条,既避免了单条SQL过大,又减少了网络往返。在当前Spring Boot项目里,我建议直接用MyBatis-Plus的IService.saveBatch,省心靠谱。但要知道它的底层原理,面试才不会被问倒。
6.2 MyBatis条件不生效的排查清单
热词“mybatis条件不生效”是我见过的最高频排查问题之一。我整理一个排查清单,直接照着检查。
第一项:看SQL到底是不是你以为的那条。打开SQL打印日志(见3.2节),确认<if>标签拼出来的where条件里有没有包含该条件。不是看代码,是看实际执行的SQL。我遇到过一次,代码里明明写了status = 1,但日志里压根没有这段,后来发现是<if>标签的test表达式写错了:test="status != null and status != ''",status是Integer类型,和空字符串比较永远为true,所以SQL没带上条件。这里有个经典经验:字符串和数字类型的空判断逻辑完全不同,数字类型不要判!= ''。
第二项:检查<if>标签的test表达式语法。test里面用的是OGNL表达式,不是Java。比如要判断Boolean字段为true,应该写test="flag == true",但很多人写的test="flag",在OGNL里这也能跑,但有些版本下坑很多。另外一个比较隐蔽的错误是,用test="name != null and name != ''"去过滤一个为null的字段,结果只想让其为null时带上条件,却写反了逻辑。
第三项:确认是否配置了<where>自动去除多余的AND。如果你手写WHERE和AND,一旦第一个条件不成立,SQL会变成WHERE AND status=1,直接语法错误或条件失效。正确做法是:
<select id="search" resultType="user"> SELECT * FROM user <where> <if test="username != null and username != ''"> AND username = #{username} </if> <if test="status != null"> AND status = #{status} </if> </where> </select><where>标签的职责是在有条件时自动拼WHERE关键字,并且去掉第一个多余的AND。这是MyBatis最实用的动态SQL标签之一,没它你后面全是血泪。
第四项:检查参数传给Mapper时,实体属性名是否和<if>里写的属性名一致。Mapper方法接收的是一个User对象,但<if>里写的是userName,而实体属性是name,永远匹配不上。
第五项:确认resultMap的映射无误。条件查询没生效,有时候不是SQL的问题,而是映射字段错了导致结果集看起来“像没过滤”。比如数据库字段是status,你的resultMap里却映射成了orderStatus,那查出来的数据当然看起来不对。
这套清单我建议直接截图保存,遇到条件不生效就按顺序排查,95%能在两分钟内定位。
6.3 Spring MVC框架与MyBatis的分层协作
热词里有一条“springboot + mybatis 结合 mvc框架设计”,这其实讲的是Controller-Service-Mapper三层结构。很多人项目里分层不清楚,Controller里直接注入Mapper,短期没问题,但项目一大就会乱。
合理的结构是:
Controller层(接收请求参数,做参数校验) -> Service层(业务逻辑、事务边界) -> Mapper层(数据访问,SQL和POJO的映射)我在实战中要求自己做到三点:
- Controller不写业务逻辑,只做参数转DTO。
- Service定义事务边界,配置
@Transactional。 - Mapper只做单表或明确的跨表查询,不做多表复杂join之外还夹杂业务判断。
为什么强烈建议Controller不直接调Mapper?第一,你无法在Service里统一加全局缓存或事务控制。第二,Controller的入参通常是DTO,而Mapper直接返回Entity或PO,直接在Controller里暴露entity,后续字段变更会波及前端接口。这是我做过几个项目后最痛的经验。
第三层(Mapper层)里,XML写SQL时尽量用parameterType指定的实体或DTO。大量公司内部还会强制要求:update语句必须带<set>标签动态更新非null字段,避免误更新null覆盖数据库已有值。比如:
<update id="updateUser" parameterType="user"> UPDATE user <set> <if test="username != null">username = #{username},</if> <if test="email != null">email = #{email},</if> </set> WHERE id = #{id} </update>7. 高频面试题与源码阅读建议
7.1 面试题背后的原理题
热词里有大量“mybatis面试题”,最常见的几个,我直接以问答形式展开。
问:MyBatis的DAO接口没有实现类,它是怎么被执行的呢?
答:MyBatis会为每个Mapper接口创建JDK动态代理对象。MapperProxy实现了InvocationHandler,当调用接口方法时,MapperProxy.invoke()会判断是不是Object方法(比如toString),否则根据方法名从Configuration里取出MappedStatement,再交给SqlSession执行。这个代理对象在MapperRegistry.getMapper()时生成,Spring注入时用的就是这个代理。
问:MyBatis的一级缓存和二级缓存有什么区别?
答:一级缓存是SqlSession级别,默认开启,生命周期随SqlSession;二级缓存是namespace级别,需要手动开启,所有SqlSession共享。二级缓存失效条件是同一namespace下有写操作(insert/update/delete),多表查询时脏读风险高。
问:如果让你优化一个慢SQL,你会从什么角度考虑?
答:这个问题我面试时遇到过。除了常规的加索引、EXPLAIN分析执行计划、改写SQL之外,基于MyBatis的角度我会说:检查是否有N+1查询(循环查库)、是否有不合理的<foreach>超大批量、是否有多余字段查询、是否有未关闭的SqlSession占用了连接、是否可以加二级缓存或Redis缓存。大多数“MyBatis慢”,慢在SQL本身,和框架关系不大。
问:$和#的区别是什么?
答:#{}是预编译参数,MyBatis用它生成?占位符,通过PreparedStatement设置参数,安全性高,推荐使用。${}是字符串替换,直接把值拼进SQL,有SQL注入风险,但某些场景必须用,比如动态表名、动态排序字段。这里提醒一句,即使必须用${},也要做白名单校验。
问:什么时候会用到<resultMap>?
答:当数据库字段名和Java属性名不一致、需要嵌套结果映射(一对一、一对多)、需要自定义TypeHandler、要处理id与实体的映射关系时,都用resultMap。开驼峰映射能解决大部分“不一致”,但复杂对象还是要写resultMap。
其实面试问的往往不是概念本身,而是你踩坑的深度。比如上面提到“二级缓存多表脏读”,如果你能举一个曾经踩过的项目案例,面试官会眼前一亮。
7.2 源码阅读路线
从我个人的源码阅读经验来说,不建议拿着源码从头逐行啃。我的路线是“问题导向”,带着问题去查。
第一条线:启动流程。从SqlSessionFactoryBuilder.build()进,到Configuration构造完成,去理解XMLConfigBuilder每个解析方法的调用顺序。这条线解决“MyBatis是怎么知道所有SQL的”问题。
第二条线:一次查询的完整旅程。从SqlSession.selectList()开始,跟到Executor.query(),看它怎么处理一级缓存、二级缓存(CachingExecutor装饰)、怎么路由到StatementHandler,再到PreparedStatementHandler.instantiateStatement()创建JDBC Statement。
第三条线:动态代理。从MapperProxyFactory开始,跟到MapperProxy.invoke(),看一个接口方法如何转成MappedStatement查询。这条线很短,但能解决“为什么Mapper接口没有实现类”的疑惑。
第四条线:插件机制。从Interceptor入手,看Plugin.wrap()怎么用JDK动态代理包装目标对象,以及@Signature注解怎么描述拦截点。理解了这条线,MyBatis-Plus的分页插件、乐观锁插件对你来说就不再是黑盒。
这四条线跟完,你已经超过了大多数“MyBatis使用者”。我在带新人时,就让他们用断点调试的方式跑一遍查询链路,比任何源码解析文章都管用。有一点要提醒,MyBatis版本升级后内部实现会有一些调整,但核心四件套(Executor、StatementHandler、ParameterHandler、ResultSetHandler)的职责分工没变过,放心跟。
常见问题速查表
我把前面提到的坑汇总成一张表,方便你对照排查。实际工作中遇到问题,可以先来查表,再决定要不要深入源码。
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
启动报Invalid bound statement (not found) | Mapper接口和XML的namespace或id不匹配 | 检查namespace是否为接口全限定名,XML是否在mapper-locations路径下 |
| 查询返回全是null | 数据库字段下划线和Java驼峰未映射 | 设置map-underscore-to-camel-case: true或写resultMap |
| 插入后拿不到自增主键 | 没有用useGeneratedKeys | 在insert标签上加useGeneratedKeys="true" keyProperty="实体id属性" |
条件查询动态SQL多了AND导致报错 | 手写WHERE + AND | 使用<where>标签包裹动态条件 |
<if>条件明明成立却没拼上SQL | test表达式写错或参数名不对 | 通过日志打印实际SQL,逐个条件核对 |
| 开启二级缓存后报NotSerializableException | readOnly=false要求实体序列化 | 实体实现Serializable接口,或设置readOnly=true(不推荐) |
| 批量插入报max_allowed_packet | 单条SQL过大 | 降低每次批量条数(推荐500~1000),或使用ExecutorType.BATCH |
| 一个事务里多次查询,修改同一个返回对象导致数据被污染 | 一级缓存返回的是同一个对象引用 | 需要修改时复制对象,或查询后clearCache |
| SQL打印不输出 | Mapper接口日志级别没配debug | logging.level.<mapper包名>: debug |
| 自定义TypeHandler没生效 | 未注册或resultMap中未指定 | 全局注册typeHandler或resultMap中显式指定typeHandler |
排查的时候记得一个原则:先看实际执行的SQL,再看参数,最后才看映射配置。这个顺序能帮你砍掉一大半无效排查。
我个人在项目初期踩的坑比这张表列的还要多。特别是有一次线上问题了半小时,最后发现只是某个<if>标签里多了一个空格,Long类型比较被转成了字符串比较,条件永远为false。这种问题不打印SQL根本发现不了。从那以后,我在任何环境都开着SQL日志,只是环境不同,日志级别不同。测试环境打debug看参数值,生产环境打info只看到花费时间和影响行数。
还有一个小技巧:IDEA里装MyBatisX插件,按住Ctrl点击Mapper接口方法可以直接跳到XML对应语句,非常实用。排查问题时的跳转效率能提升一个量级。
MyBatis这套东西,说穿了就是“配置文件+映射文件+JDBC封装”。但要把它的每个细节吃透,不是一天两天的事。尤其在生产环境压力下,你才会真正理解为什么我们要动态SQL、为什么二级缓存要慎用、为什么TypeHandler能做到透明加解密。引用一句我师父常说的话:“框架是别人的,但SQL永远是你自己的。”把MyBatis的控制力用好,就是对自己代码最大的负责。