如果你正在学 JavaWeb,大概率已经绕不开 Mybatis 这个名字了。很多教程把它当作“配置完就能跑”的黑盒,但真正上手写项目时,你会发现一个残酷的事实:增删改查谁都会抄,可一旦遇到 SQL 报错、参数绑定失败、缓存数据对不上,你没搞清楚底层那点机制,就只能靠 Ctrl+C / Ctrl+V 和瞎猜来写代码。这篇东西就是把我从入门到进阶阶段踩过的坑、看过的源码、整理过的笔记浓缩成一份可以照着做的实战总结,核心讲 Mybatis 的基础操作,也会把 XML 配置初始化原理、缓存机制、动态 SQL 失效这些高频问题一并说透。内容适合两类人:刚学完 JavaWeb 基础、准备把 Mybatis 真正用进项目里的新手,以及写过一些 CRUD 但从来没认真排查过“为什么我的查询结果不对”的半进阶同学。我会尽量用大白话讲原理,再补上可以直接抄进项目的配置和代码。
我当年第一次用 Mybatis 的时候,整整卡了一个下午,原因不过是 Mapper 接口和 XML 命名空间没对齐,控制台给我甩了一句 “Invalid bound statement (not found)”。当时完全不知道这句话在说什么,后来把 XML 配置初始化流程从头捋了一遍才恍然大悟。所以这篇我决定不按教科书顺序来,而是先讲清楚 Mybatis 在项目里是怎么工作的,再带你手写整套基础操作,最后把常见问题做成速查表。看完你至少能实现一个完整的注册功能,并且能跟面试官把 Mybatis 的初始化原理、缓存机制聊得明明白白。
1. 先从项目视角理解:JavaWeb 项目的持久层到底在做什么
1.1 数据访问这件事,为什么不能让 JDBC 裸奔
JavaWeb 项目做到后期,绕不开一件事:把内存里的对象数据保存到数据库,以及把数据库的表数据读回到内存对象里。最原始的 JDBC 写法,核心代码无非就是DriverManager.getConnection()、PreparedStatement、ResultSet这三板斧,但问题在于“样板代码”太多了。
举个例子,你只是想查一个用户表里 id 为 1 的记录,JDBC 至少要写 8 到 10 行准备代码:加载驱动、拿连接、写 SQL、填参数、执行查询、遍历结果集、手动 set 到 User 对象、关闭连接。这还只是一条最简单的查询。真实项目里一张表二三十个字段,一条 insert 要写二十多个ps.setString(),体验极其痛苦,而且手写结果集映射特别容易漏字段、写错下标,编译器还不会报错,只有跑到线上数据量大的时候才在某个角落悄悄返回 null。
我把这种状态总结为“三个重复”:重复的获取连接逻辑、重复的参数装配代码、重复的结果映射代码。Mybatis 的价值就是把这三种重复全部从业务代码里剥离出去,让 SQL 本身成为项目里的一等公民。
1.2 Mybatis 解决的不是“不要写 SQL”,而是“把 SQL 管理好”
很多刚入门的同学有一个误区,觉得用了 ORM 框架就不用写 SQL 了。这其实是 JPA / Hibernate 那套的思维。Mybatis 从来不是“帮你生成 SQL”的框架,它是“帮你把 SQL 组织起来、把参数和结果映射好”的框架。
它做的事情可以拆成四块:
- 管理数据库连接的生命周期,不再让业务代码里到处
getConnection()。 - 通过 Mapper 接口与 XML / 注解绑定,把 SQL 写在一个可控、可 review 的地方。
- 参数处理上支持
#{}预编译占位符和${}字符串拼接两种模式。 - 结果映射支持自动映射和高度定制化的
resultMap,让数据库字段和 Java 属性之间的关系清清楚楚。
所以我一直建议学 JavaWeb 的同学,哪怕以后想用 JPA,也值得先把 Mybatis 学扎实。因为你会 SQL、懂映射、能调优,这些能力在任何持久层方案里都是通用的,而 Mybatis 是让你最快建立这套感觉的框架。
1.3 和 JPA、JDBC 对比之后,你就知道自己该怎么选
很多讲 Mybatis 的文章喜欢直接开喷 JPA,我觉得没必要。不同框架适合不同场景,简单用一张表说清楚更实在:
| 维度 | 原生 JDBC | Mybatis | JPA / Hibernate |
|---|---|---|---|
| SQL 控制力 | 完全控制 | 完全控制 | 框架生成,复杂 SQL 较难调优 |
| 开发效率 | 低 | 中高 | 高 |
| 上手门槛 | 低但繁琐 | 中,需懂 SQL | 中高,需理解对象关系映射 |
| 动态 SQL 支持 | 手动拼接 | 非常灵活 | 相对笨重 |
| 适合场景 | 学习原理、小工具 | 复杂业务、团队规范明确 | 标准 CRUD 为主、领域模型复杂 |
我的观点很直接:如果你做的是互联网业务系统,SQL 复杂、性能要求高,Mybatis 是稳妥选择;如果做企业级管理系统,标准 CRUD 占绝大多数,JPA 能省不少事。但不管选哪个,先把 Mybatis 搞明白,你的 SQL 功底和排错能力一定会涨一大截。
2. Mybatis 核心原理:一条 SQL 从接口到数据库的完整旅程
2.1 五个核心角色,先记住它们再学操作
我第一次看 Mybatis 的初始化流程时,头都是大的,因为名词太多。后来我用一条“点外卖”的类比把它记住了。
SqlSessionFactoryBuilder:相当于“餐馆总部的建店部门”,它拿着一份装修图纸(mybatis-config.xml),帮你把一家餐馆(SqlSessionFactory)建好。它是个一次性的工具,用完就可以扔。SqlSessionFactory:建好的餐馆本体。一家餐馆可以接待很多客人,所以它是全局单例,整个应用生命周期里只需要创建一次。SqlSession:一个客人到店后领到的“就餐位”。每个会话对应一次数据库连接的获取与释放,它不是线程安全的,所以不能共享。Executor:后厨的厨师。真正执行 SQL、处理缓存、管理事务的都是它。MappedStatement:菜单上的一道菜。它封装了一条 SQL 的完整定义:SQL 文本、参数类型、返回类型、缓存策略等。
记住这五个角色,后面看配置初始化、看源码、排查问题都会轻松很多。
2.2 XML 配置初始化工作原理:XMLConfigBuilder 到底做了什么
很多面试题会问“Mybatis 基于 XML 配置的初始化工作原理”,其实答案就是围绕XMLConfigBuilder展开的。它的工作流程非常清晰:
- 读取
mybatis-config.xml,拿到根节点<configuration>。 - 按 XML 节点的顺序依次解析:
properties、settings、typeAliases、environments、mappers等。 - 每解析一个节点,就把配置填充到
Configuration对象对应的字段里,比如environments节点会解析出数据源和事务工厂,mappers节点会加载 Mapper XML 文件,并把每一条 SQL 解析成MappedStatement。 - 全部解析完成,
Configuration对象就是一份“完整菜单”,SqlSessionFactoryBuilder拿着它构建出SqlSessionFactory。
这里有个细节值得注意:XMLConfigBuilder解析 Mapper XML 时,会用XMLMapperBuilder逐个解析<mapper>文件,校验 namespace 是否唯一,然后解析<select>、<insert>、<update>、<delete>,最终注册成MappedStatement,key 就是“namespace + id”。
这就是为什么“Invalid bound statement (not found)”那个报错会出现在方法找不到绑定上:你的 Mapper 接口方法名或 namespace 只要有一个跟 XML 里对不上,注册表里就查不到对应条目。到这一步,你再回头看那个报错,就会觉得它非常直白——不是数据库连不上,而是你的“菜单”里根本没这道菜。
2.3 手写一个最简 XML 配置初始化流程,加深理解
如果你跟我一样是“看十遍不如跑一遍”的选手,可以自己用纯 Java 写一次初始化,不需要 Spring Boot,反而能把原理看得更透。
String resource = "mybatis-config.xml"; InputStream inputStream = Resources.getResourceAsStream(resource); SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream); try (SqlSession sqlSession = sqlSessionFactory.openSession()) { UserMapper userMapper = sqlSession.getMapper(UserMapper.class); User user = userMapper.findById(1L); System.out.println(user.getUsername()); }对应的最小mybatis-config.xml:
<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/javaweb_demo"/> <property name="username" value="root"/> <property name="password" value="123456"/> </dataSource> </environment> </environments> <mappers> <mapper resource="mapper/UserMapper.xml"/> </mappers> </configuration>跑通这段代码,你就等于亲手完成了一遍XMLConfigBuilder的初始化旅程。后面接入 Spring Boot,无非是把“创建 factory”这一步交给容器管理,本质没有变化。
2.4 配置打印 SQL:调试期的救命开关
新手阶段最痛苦的事情之一,就是 Mybatis 报 SQL 语法错误,但你不知道它实际执行的是什么 SQL。网上搜“mybatis 配置打印”,答案五花八门,其实就两招,属于我实测下来最稳定的方案。
第一种方式,在application.yml里配置日志级别:
logging: level: com.example.demo.mapper: debug原则是把 Mapper 接口所在的包名配成 debug,Mybatis 就会打印这个包下所有 SQL 的预编译语句和参数。
第二种方式,在 mybatis 配置里指定 StdOutImpl:
mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这种方式会把 SQL 直接打到控制台,优点是简单粗暴,缺点是不支持按包过滤,所有 SQL 都会输出,适合本地调试,不建议带到生产。
我建议新手无论如何先把打印 SQL 打开。你只有看到了==> Preparing:和==> Parameters:这两行,才能真正理解#{}和${}的区别,也才能在动态 SQL 出问题时第一时间定位。
3. Mybatis 基础操作实战:从环境搭建到完成注册功能
3.1 搭建 Spring Boot + Mybatis 项目骨架
现在做 JavaWeb 项目,最主流的方式就是 Spring Boot 整合 Mybatis。很多老教程还在教手动建SqlSessionFactory的 Bean,其实mybatis-spring-boot-starter已经帮我们封装好了,你只需要做三件事。
第一步,引入依赖。在pom.xml中添加:
<dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.3</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>第二步,在application.yml里完成核心配置。这里给出我实际项目里的最小配置模板:
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/javaweb_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 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.StdOutImpl第三步,在主类上加上@MapperScan注解,或者在每个 Mapper 接口上加@Mapper注解。我个人的习惯是用@MapperScan,写在启动类上,这样新加 Mapper 接口不用反复打注解:
@SpringBootApplication @MapperScan("com.example.demo.mapper") public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }这里有个细节容易踩坑:mapper-locations配的是 XML 文件路径,type-aliases-package配的是实体类包名。如果 XML 里的resultType不想写全限定类名,就必须配置别名包扫描。而map-underscore-to-camel-case几乎必开,不然数据库的create_time字段映射不到 Java 的createTime属性上。
3.2 基础 CRUD 的 Mapper XML 写法,每个符号都有讲究
配置好环境之后,核心就是写 Mapper 接口和 XML。我以一张最简单的user表为例,把增删改查全部写透。
表结构:
CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL, `password` varchar(100) NOT NULL, `email` varchar(100) DEFAULT NULL, `create_time` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;对应的实体类:
public class User { private Long id; private String username; private String password; private String email; private LocalDateTime createTime; // getter / setter 省略 }Mapper 接口:
public interface UserMapper { User findById(Long id); List<User> findAll(); int insert(User user); int update(User user); int deleteById(Long id); }然后是核心的 XML。先说查询,这是最基础的:
<mapper namespace="com.example.demo.mapper.UserMapper"> <select id="findById" resultType="com.example.demo.entity.User"> SELECT id, username, password, email, create_time FROM user WHERE id = #{id} </select> <select id="findAll" resultType="com.example.demo.entity.User"> SELECT id, username, password, email, create_time FROM user </select> <insert id="insert" parameterType="com.example.demo.entity.User" useGeneratedKeys="true" keyProperty="id"> INSERT INTO user(username, password, email, create_time) VALUES(#{username}, #{password}, #{email}, #{createTime}) </insert> <update id="update" parameterType="com.example.demo.entity.User"> UPDATE user SET username = #{username}, password = #{password}, email = #{email} WHERE id = #{id} </update> <delete id="deleteById"> DELETE FROM user WHERE id = #{id} </delete> </mapper>这里有三个重点需要展开讲。
第一个重点是#{}和${}的区别。#{}在预编译阶段会被替换成?占位符,参数通过PreparedStatement安全传入,能有效防 SQL 注入。${}是字符串直接拼接,如果有用户输入参与,等于把 SQL 注入漏洞直接打开。我的铁律是:能用#{}的地方绝不用${}。唯一允许${}的场景是动态传入表名、排序字段这类无法预编译的 SQL 片段,而且必须经过严格白名单校验。
第二个重点是useGeneratedKeys和keyProperty。这两个属性是配合自增主键用的。不加这两个属性,执行完 insert 之后,传入的user.getId()仍然是 null,因为你还没查数据库。加上之后,Mybatis 会在执行完插入后把生成的主键回填到对象的id属性上,省一次查询。注意keyProperty必须写 Java 属性名id,而不是数据库列名。
第三个重点是resultType的自动映射规则。开了map-underscore-to-camel-case之后,create_time会自动映射到createTime,但千万注意username这种没有下划线的字段也有隐式约定:如果数据库列名和属性名完全一致,自动映射没问题;不一致时最好在 SQL 里用别名对齐,或者使用resultMap强制指定,后者的可维护性更高。举个例子,如果表里的列叫uname,属性叫username,自动映射就不会生效,你只能写SELECT uname AS username FROM user,或者定义resultMap。
3.3 用 Spring Boot + Mybatis 实现一个最简单的注册功能
前面说了那么多,不如落地一个完整功能。注册功能是最典型的“项目整合”场景,它能串起 Mapper、Service、Controller 三层,同时涉及 insert、唯一性校验、密码处理三个关键点。
第一步,在 Service 层处理注册逻辑。我这里把校验写在代码里,方便你看懂流程,真实项目里建议配合参数校验注解:
@Service public class UserService { @Autowired private UserMapper userMapper; public void register(String username, String password, String email) { // 1. 校验用户名是否已存在 User existUser = userMapper.findByUsername(username); if (existUser != null) { throw new RuntimeException("用户名已存在"); } // 2. 构建实体 User user = new User(); user.setUsername(username); user.setPassword(password); user.setCreateTime(LocalDateTime.now()); // 3. 插入数据库 userMapper.insert(user); System.out.println("注册成功,生成的自增ID = " + user.getId()); } }第二步,在 Mapper 中补充按用户名查询的方法。注意这里有一个新手常见问题:如果你用selectByUsername返回User,但查无此人时 Mybatis 返回的是 null 而不是空对象,所以existUser != null的判断是可靠的。另外,如果只判断用户名是否重复,建议给表的username字段加上唯一索引,双保险,避免并发场景下检查通过但插入失败。
第三步,Controller 层写入口。返回 JSON 结果的结构我习惯用一个统一响应类,但这里只演示最小实现:
@RestController @RequestMapping("/api/user") public class UserController { @Autowired private UserService userService; @PostMapping("/register") public String register(@RequestBody RegisterRequest request) { userService.register(request.getUsername(), request.getPassword(), request.getEmail()); return "注册成功"; } }跑通这个功能之后,你可以自己验证两个点。第一,控制台打印的 SQL 里Parameters:行是否包含三个参数,如果参数是 null 也会在这行显示,这能帮你排查参数没传进来的问题。第二,插入完成后user.getId()是否已经拿到自增主键,拿不到就回去检查useGeneratedKeys="true"和keyProperty="id"是否配对。
3.4 动态 SQL:if 条件不生效,多半是这几个原因
动态 SQL 是 Mybatis 最实用的能力,也是“条件不生效”问题的高发区。我先把最常用的<if>、<where>、<set>、<foreach>写一遍,再讲为什么条件会失效。
最常见的场景是多条件查询:
<select id="searchUsers" resultType="com.example.demo.entity.User"> SELECT id, username, password, email, create_time FROM user <where> <if test="username != null and username != ''"> AND username LIKE CONCAT('%', #{username}, '%') </if> <if test="email != null and email != ''"> AND email = #{email} </if> </where> </select>条件不生效的问题,我总结过四个高频原因。
第一,test表达式里忘了判断空字符串。username != null只排除了 null,如果前端传了个空字符串进来,条件就会拼成AND username LIKE '%%',查出来一堆不该出现的数据。所以字符串字段的判断必须写成!= null and != ''。
第二,<if>的位置不对。test里引用的参数名必须和 Mapper 方法参数名一致。如果你在接口里写的是User search(@Param("name") String name),那么 XML 里也必须用name,写成username就会报参数找不到。
第三,数值类型判断0的坑。如果条件参数是Integer类型,值等于 0 时,<if test="status != null and status != ''">这个表达式会直接报错或者不生效,因为''和Integer之间的比较行为不符合直觉。整数类型只要判!= null就够了,不用也不能和空字符串比较。
第四,动态 update 里要用<set>而不是手写SET。如果你这样写:
<update id="updateUser"> UPDATE user SET <if test="email != null and email != ''"> email = #{email}, </if> WHERE id = #{id} </update>当email为空时,SQL 会变成UPDATE user SET WHERE id = ?,语法直接报错。用<set>标签会自动去除多余的逗号,省心得多:
<update id="updateUser"> UPDATE user <set> <if test="email != null and email != ''"> email = #{email}, </if> </set> WHERE id = #{id} </update>另外再说一个高频场景:批量插入或批量查询用<foreach>。比如批量插入:
<insert id="batchInsert"> INSERT INTO user(username, password, email, create_time) VALUES <foreach collection="list" item="item" separator=","> (#{item.username}, #{item.password}, #{item.email}, #{item.createTime}) </foreach> </insert>collection的值有讲究:如果接口参数加了@Param("list"),这里就写list;如果不加,单参数集合默认也可以用list或collection,但为了可读性,我建议所有集合参数都显式加@Param。
4. Mybatis 缓存机制:一级缓存、二级缓存与那些让你怀疑人生的坑
4.1 一级缓存默认开启,但它的生命周期比你想的短
Mybatis 的一级缓存是SqlSession级别的,也就是说同一个 SqlSession 内执行同一条 SQL(相同语句、相同参数),第二次会直接命中缓存,不再查数据库。
但实际在 Spring 环境中,一级缓存的作用比你想象的小得多。因为 Spring 整合 Mybatis 后,每一次 Mapper 方法调用都会重新获取和关闭 SqlSession,默认情况下不同方法调用之间根本不会共享同一个 SqlSession。只有在同一个事务里,Spring 才会让多个 Mapper 调用共用一个 SqlSession。
我见过不少新手同学误以为一级缓存能帮忙减少重复查询,结果发现根本不起效,原因就在这里:一级缓存的生效范围是“同一个 SqlSession + 同一条 SQL + 相同参数”,Spring 默认的非事务调用根本不满足第一个条件。
另外,有几种情况会主动清空一级缓存。执行任何 insert、update、delete 会清空;显式调用sqlSession.clearCache()会清空;提交事务或关闭会话也会清空。这些行为不需要刻意记,只需要记住结论:一级缓存是在极短生命周期内的优化,不能依赖它解决跨会话的数据一致性问题。
4.2 二级缓存默认为关闭,打开之后还有一堆注意点
二级缓存是 Mapper 级别(namespace 级别)的缓存,生命周期跨越多个 SqlSession。它的默认状态是关闭的,要在 XML 里显式开启:
<mapper namespace="com.example.demo.mapper.UserMapper"> <cache/> </mapper>光写<cache/>还不够,被缓存的实体类必须实现Serializable接口,因为二级缓存默认的存储方式涉及序列化。这一步漏掉,运行时就会报NotSerializableException。
<cache/>标签的可配置属性也很值得了解:
| 属性 | 默认值 | 说明 |
|---|---|---|
eviction | LRU | 淘汰策略,常用 LRU(最近最少使用)和 FIFO(先进先出) |
flushInterval | 无 | 缓存刷新间隔,单位毫秒,不设置就没有定期刷新 |
readOnly | false | true 时返回缓存对象的直接引用,false 时返回序列化副本 |
size | 1024 | 缓存可以存放的对象数量 |
blocking | false | true 时使用阻塞锁,避免并发下缓存击穿 |
如果设置了flushInterval,一定要结合业务更新频率来定。我见过一个项目把二级缓存开了但没配flushInterval,结果用户改了头像,其他人看到的还是旧头像,排查了很久才发现是这个原因。
二级缓存最大的坑在于多表关联。因为二级缓存是按 namespace 隔离的,如果你在两个 mapper 里分别查了user和user_order这两张表关联的数据,而其中一个 namespace 更新了user_order表,它只会清空自己的缓存,不会去清空user那个 namespace 里的缓存。这就会导致另一个 namespace 读到过期的联表结果。对这个问题的通用解法是:多表关联查询要么别用二级缓存,要么使用CacheRef把相关的 namespace 绑定起来,让其中一个更新时同时清空另一个。
4.3 缓存相关的面试高频题,背答案不如懂原理
Mybatis 缓存这块是面试重灾区,我把自己被问到过的问题整理了一份,建议按“原理 + 场景”两条线来理解。
- 一级缓存的失效场景有哪些?核心答:SqlSession 不同、SQL 不同、参数不同、执行了增删改、手动 clearCache、事务提交或回滚后。
- 为什么 Spring 环境下一级缓存经常不生效?核心答:每次 Mapper 调用默认独立 SqlSession,只有事务内共享。
- 二级缓存为什么需要实体类实现序列化?核心答:默认存储策略
PERPETUAL会通过序列化/反序列化保存对象,readOnly=false时要返回副本,避免多个会话拿到同一个引用互相污染。 - 二级缓存数据不一致的典型场景?核心答:多表 join 查询 + 不同 namespace 更新不同表,导致某个 namespace 缓存无法感知其他表的数据变化。
我的建议是回答这些问题时,不要只背结论,要能画一条线出来:会话发起查询 -> 先查二级缓存 -> 没命中查一级缓存 -> 再没命中查数据库 -> 结果逐级回填。你把这条链路讲清楚,面试官基本就认可你是真懂。
5. 常见问题与排查技巧实录(可以当速查表用)
5.1 我实际踩过的坑,全部整理成一张排查表
这部分内容是我在练习和做小项目时真实遇到过的报错,比官方文档更有参考价值。我按报错信息排查、原因分析和解决方案列成表格:
| 现象 | 常见原因 | 解决办法 |
|---|---|---|
| Invalid bound statement (not found) | namespace、id、接口方法名不一致 | 核对 XML 的 namespace 和<select>的 id,与接口全限定名和方法名一一对应 |
| Parameter 'xxx' not found. Available parameters are [...] | 多参数方法没有加@Param | 多参数一律使用@Param("xxx")显式命名 |
| 查询结果字段全是 null | 列名和属性名对不上,驼峰映射没开 | 开启map-underscore-to-camel-case,或用resultMap/ SQL别名 |
| 中文乱码 | 数据库连接 URL 没指定编码 | URL 加useUnicode=true&characterEncoding=utf8,确认表字符集为 utf8mb4 |
| SQL 语法错误但代码看起来没问题 | 动态 SQL 生成了非法语句 | 打开 SQL 打印,看==> Preparing:实际执行的完整语句 |
| LIKE 查询查不到内容 | 用${}直接拼或没处理通配符 | 用CONCAT('%', #{keyword}, '%') |
| 实体类序列化报错 | 二级缓存开启但实体未实现 Serializable | 让实体类实现Serializable并加 serialVersionUID |
| 事务不生效 | 异常被 try-catch 吞掉,或方法被同类内部调用 | 保证异常抛出到 Spring 代理边界,不内部调用同类方法 |
5.2 两个让我印象深刻的排错过程,值得完整复盘
第一个是“条件不生效”的完整排查。当时我做一个搜索接口,前端传status=0,结果查出来的是全部数据。我第一反应是打开 SQL 打印,发现实际执行的 SQL 里WHERE后面根本没有 status 条件。再回头看<if test="status != null and status != ''">,明白了:status是Integer,等于 0 时status != ''的判断在类型转换上出了问题,条件判为 false。改成test="status != null"之后立即恢复正常。这个坑很典型,我印象特别深,因为当时网上搜到的答案大多是针对字符串类型的判断,很少有人提整数判断空字符串的陷阱。
第二个是二级缓存导致的数据“幽灵”。项目里有一个配置表,后台改完配置,前台怎么刷新都是旧值。当时第一反应是浏览器缓存,清缓存没用;查了接口返回,发现数据确实是旧的;最后看到 Mapper XML 里有<cache/>,而配置表的更新操作在另一个 Mapper 里,因为 namespace 不同,更新操作根本清不到配置查询的那份缓存。从那以后我的规则就变成了:凡是没有把握保证一致性的查询,一律不用二级缓存。启动简单,数据对不上才是噩梦。
5.3 给新手的三个调试技巧,能省你大量时间
第一个技巧,一定要学会“看 Preparing 不看报错”。Mybatis 的报错信息很多时候只告诉你“哪一行出错了”,但不告诉你“实际执行的 SQL 长什么样”。排查 SQL 问题的第一件事永远是打开 SQL 打印,确认实际执行语句、参数类型和参数值。
第二个技巧,单元测试里跑 Mapper 比启动整个 Web 应用快得多。Spring Boot 项目里写一个@SpringBootTest的测试类,直接调 Mapper 方法,能省掉每次 Ctrl+C 重启 Tomcat 的时间。我练习时基本上就是写完 XML 立刻跑一个测试方法验证。
第三个技巧,善用@Param比其他任何传参方式都稳。不管是单参数还是多参数,统一加@Param标注,能让 XML 里的参数引用始终可控,还能规避“Available parameters are”这类报错。代价只是多写几个字,收益是排错时间大幅下降。
6. Mybatis 基础操作之后,下一步该往哪里走
如果你把上面的内容都消化了,并且亲手跑通了注册功能、做了一遍动态 SQL 查询,那 Mybatis 的基础操作这一关就算真正过了。接下来我的建议是按这个顺序往上走。
先把resultMap的关联映射学扎实。一对多、多对一在真实项目里躲不开,<association>和<collection>怎么用、什么时候用嵌套查询什么时候用嵌套结果,这些搞明白之后,你写联表查询会顺手很多。
再去看 Mybatis 与 Spring 事务的配合。到这一步你要理解的是:为什么加了@Transactional之后多个 Mapper 调用会共享一个 SqlSession,以及事务回滚和缓存清空之间的关系。这个知识点绕不开,因为几乎所有写操作接口都会涉及。
最后才是 Mybatis 的插件机制和拦截器。分页插件 PageHelper、慢 SQL 拦截、数据权限过滤,这些都是基于拦截器做的。学会写一个简单的拦截器,你才算真正摸到了 Mybatis 的可扩展边界。
我个人在实际操作中的体会是:Mybatis 这东西,网上教程看一百遍不如自己把一个注册功能从零跑通一遍。你只要亲手踩过“Invalid bound statement”“条件不生效”“缓存数据不新鲜”这三个坑,对框架的理解就会上一个台阶。这也是为什么我在这篇文章里写了大量报错场景和排查过程——因为这些都是我在学习阶段真正卡住过的地方。
最后再分享一个小技巧:把你自己常踩的坑整理成一个notes.md文件,放在项目根目录,按“现象 -> 原因 -> 解决”三列记录。这个习惯我从学 Mybatis 一直保持到现在,排查问题的速度比绝大多数同事都快。技术文档会过期,但你自己攒下来的避坑笔记,越用越值钱。