news 2026/10/10 22:27:21

MyBatis核心原理与实战:从XML解析到缓存机制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MyBatis核心原理与实战:从XML解析到缓存机制

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、别名、类型处理器、缓存、插件、环境信息等。

我拆一下它的工作流程,方便你记忆:

  1. 解析<properties>标签:加载外部属性文件,支持占位符替换。
  2. 解析<settings>标签:设置全局参数,比如是否开启驼峰映射、缓存开关、执行器类型。
  3. 解析<typeAliases>标签:注册类别名,之后在XML里写resultType="user"而不是全限定类名。
  4. 解析<typeHandlers>标签:注册自定义TypeHandler,后面专门讲。
  5. 解析<objectFactory>、<objectWrapperFactory>、<reflectorFactory>:扩展点,平时用得少,但面试会问。
  6. 解析<plugins>标签:注册拦截器,MyBatis的插件机制就在这里。
  7. 解析<environments>标签:配置数据源和事务工厂。
  8. 解析<databaseIdProvider>标签:支持多数据库适配。
  9. 解析<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; }

它的工作流程,我用四个步骤说明:

  1. 参数写入阶段:当MyBatis执行PreparedStatement的setParameter时,它会用ParameterHandler遍历SQL里的#{xxx}参数,每个参数根据Java类型匹配对应的TypeHandler,调用setParameter方法,把Java对象写入JDBC的PreparedStatement。
  2. 结果映射阶段:查询返回ResultSet后,ResultSetHandler根据MappedStatement里记录的resultMap或resultType,拿到每一列的JdbcType和JavaType,然后调用对应的TypeHandler的getResult方法,取出数据库值并转化为Java值。
  3. 类型匹配规则:MyBatis默认注册了一堆TypeHandler(BooleanTypeHandler、IntegerTypeHandler、StringTypeHandler等),它有一个TypeHandlerRegistry来维护Java类型到JdbcType的映射关系。当默认匹配不到,或你显式指定了typeHandler属性,则使用自定义的。
  4. 注册与生效:自定义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里读写混合,二级缓存命中率极低,形同虚设。

我自己做项目时,二级缓存用的非常克制。只在两类场景开:

  1. 字典表、配置表这种几乎不更新、访问量很大的数据。
  2. 单表查询且实体几乎不变的历史数据。

如果是多表关联查询的结果,千万别塞进二级缓存。原因很简单:一个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的映射)

我在实战中要求自己做到三点:

  1. Controller不写业务逻辑,只做参数转DTO。
  2. Service定义事务边界,配置@Transactional。
  3. 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>条件明明成立却没拼上SQLtest表达式写错或参数名不对通过日志打印实际SQL,逐个条件核对
开启二级缓存后报NotSerializableExceptionreadOnly=false要求实体序列化实体实现Serializable接口,或设置readOnly=true(不推荐)
批量插入报max_allowed_packet单条SQL过大降低每次批量条数(推荐500~1000),或使用ExecutorType.BATCH
一个事务里多次查询,修改同一个返回对象导致数据被污染一级缓存返回的是同一个对象引用需要修改时复制对象,或查询后clearCache
SQL打印不输出Mapper接口日志级别没配debuglogging.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的控制力用好,就是对自己代码最大的负责。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/10 22:26:53

Python + CNN 网络入侵检测实战:从预处理到模型复现避坑指南

简介&#xff1a;该项目是一套基于Python与CNN网络入侵检测算法源码&#xff0c;面向网络安全、机器学习方向的课程设计、期末大作业及毕业设计场景。项目以NSL-KDD等数据集为基础&#xff0c;涵盖数据预处理、CNN模型构建、训练、预测与评估的完整流程&#xff0c;适合具备一定…

作者头像 李华
网站建设 2026/10/10 22:19:52

从零实现全连接神经网络:Python手写代码与核心原理解读

简介&#xff1a;这份资源是一份使用 Python 与 NumPy 从零搭建全连接神经网络的入门实现&#xff0c;面向想理解深度学习底层原理的初学者与进阶开发者&#xff0c;解决“只会调用框架、不懂内部机制”的问题。资源共含 7 个 py 文件&#xff0c;压缩包仅 4KB&#xff0c;代码…

作者头像 李华
网站建设 2026/10/10 22:18:38

微电网仿真必看:三机并联风光储系统Simulink建模全流程

做微电网仿真课题的人&#xff0c;十个里有八个会卡在“风光储怎么并联”这一步。我拿到“三机并联风光混合储能并网系统”这个题目时&#xff0c;起初也以为就是把光伏、风机、储能各搭一个模型&#xff0c;往交流母线上一怼就行。真跑到波形收敛那一关才发现&#xff0c;三台…

作者头像 李华
网站建设 2026/10/10 22:18:14

AnyPS5通用化方案:手柄映射、串流优化与媒体管理实战

1. 从“AnyPS5”这个名字说起&#xff1a;它到底想解决什么问题第一次看到“AnyPS5”这个标题&#xff0c;我脑子里蹦出来的第一个念头是&#xff1a;这大概率是一个围绕 PlayStation 5 生态做“泛化接入”或“能力扩展”的项目。为什么这么说&#xff1f;因为“Any”这个前缀在…

作者头像 李华
网站建设 2026/10/10 22:16:36

Claude BugHunter 技能分析报告:把 Burp MCP 接到 TaoToken 的配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/10 22:09:37

无人机数据链加密仿真:AES与ChaCha20实时性能对比

1. 给地面控制链路加加密&#xff0c;不是"多加一个函数"的事无人机项目里&#xff0c;地面控制站与无人机之间的数据传输是飞行安全的主干道。控制指令可能是几字节的航点修正&#xff0c;数据量小但对延迟极其敏感&#xff1b;遥测状态是周期性回传的GPS坐标、姿态…

作者头像 李华