1. 我的第一个 MyBatis 实验:从 JDBC 泥潭里爬出来
如果你正在学 Java 后端,大概率已经受够了传统 JDBC 的折磨:手动注册驱动、获取连接、拼 SQL、预编译、一条条从 ResultSet 里取值、关资源……一个最简单的查用户列表功能,写出来的代码一半以上和业务无关。我第一次带学生做这个实验时就说,MyBatis 的出现,本质上就是把“和数据库打交道”这摊子脏活累活从你手里接过去,让你只关心两件事:SQL 怎么写,结果怎么映射。
那这个“实验一 MyBatis 入门操作”到底是做什么的?一句话概括:用 MyBatis 完成一张表的增删改查,理解它“半自动 ORM”的核心思路——SQL 你自己写,但连接管理、参数注入、结果集封装这些机械操作全部交给框架。它适合刚学完 JDBC、准备进入框架阶段的初学者,也适合那些想搞清楚 MyBatis 到底和 Hibernate、Spring Data JPA 有什么不同的同学。做完这个实验,你会明白 MyBatis 为什么能在国内企业里占有率这么高,也会为后面整合 Spring Boot、理解 MyBatis 源码打下底子。
这篇文章我不打算照着实验指导书念一遍,而是把“实验一”背后真正值得搞懂的东西拆开揉碎:为什么用 XML 不用注解、核心配置文件里每个节点是干什么的、占位符和拼接符的区别、主键回填怎么玩,以及我这些年见过的初学者最容易踩的坑。你照着做一遍,不只是“跑通”,而是彻底搞明白每一步在干什么。
1.1 为什么学习 MyBatis 会让写数据访问代码的体验彻底改变
先回一下 JDBC 时代。你要查一个用户列表,代码大概是这样的:Class.forName 加载驱动,DriverManager.getConnection 拿连接,然后 Statement 拼 SQL,万一里面有单引号你还得转义,接着 ResultSet 循环取值,每取一个字段调一次 getString,最后还要在 finally 里把 connection、statement、resultSet 一个个关掉。这还只是查询,如果是增删改,你还得关心事务提交。写多了你会发现,真正和业务相关的就那一行 SQL,剩下的全是模板代码,复制粘贴还容易出错。
MyBatis 的做法是:你写一个接口方法,再写一个 XML 文件(或者注解)把 SQL 绑上去,MyBatis 在运行时动态生成实现类,自动完成参数绑定和结果集映射。这就是我常给学生打的比方:JDBC 是你在厨房里从洗菜切菜到炒菜全包,MyBatis 是请了个帮厨,你只管说“今天做番茄炒蛋,番茄切块、蛋打散”,剩下的备菜他全干了,但掌勺的还是你——因为 SQL 是你自己写的,所以叫“半自动”ORM。
也正是因为“SQL 自己写”这个特性,MyBatis 没像 Hibernate 那样试图帮你生成所有 SQL,而是在你明确告诉它“我要查什么”的基础上,把 Java 对象和数据库字段之间的翻译工作自动化。对于复杂查询、多表联查、SQL 调优这些场景,MyBatis 的优势非常明显——你手里握的是完整的 SQL 控制权。
1.2 实验一到底解决什么问题
很多初学者做这个实验时容易陷入“照着敲一遍、跑通了就完事”的状态。实际上,它要解决的核心问题有三个:第一,让你理解 MyBatis 的分层设计——SqlSessionFactoryBuilder、SqlSessionFactory、SqlSession 这三者的关系,这在面试里是高频考点;第二,让你掌握 XML 映射文件里常用标签的写法,从简单的 select 到复杂的动态 SQL,这是以后开发中每天都要用的技能;第三,让你明白参数传递和结果映射的底层机制,搞清楚为什么有时候接口方法一个参数不用加 @Param,两个以上就必须加。
另外,这个实验还会接触到一个很重要的概念:MyBatis 的一级缓存。虽然入门阶段不会刻意去讲,但你在同一个 SqlSession 里执行两次相同的查询,第二次其实不会打到数据库。理解这个行为,对排查以后“数据明明改了,为什么查出来还是旧的”这种诡异问题很有帮助。后面我会单独写一节讲。
2. 环境搭建和工程初始化:Maven 工程 + MyBatis 3.5.x
做实验第一步是搭环境。我建议直接用 IntelliJ IDEA 建一个普通的 Maven 工程,不要一开始就上 Spring Boot。原因很简单:Spring Boot 的 starter 帮你把很多东西自动配置好了,你反而看不到 MyBatis 原生运行的脉络。入门阶段,把每一步都摊开在眼前,理解成本是最低的。
2.1 创建 Maven 工程并引入依赖
我用的是 MyBatis 3.5.15 版本,JDK 8 或 11 都行。Maven 依赖就三个:mybatis、mysql-connector-java、junit。如果你用其他数据库,比如 PostgreSQL,就把驱动换成对应的即可。pom.xml 关键内容如下:
<dependencies> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.15</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>4.13.2</version> <scope>test</scope> </dependency> </dependencies>注意:如果你用的 MySQL 是 8.0 以上版本,驱动类名是 com.mysql.cj.jdbc.Driver,URL 需要带 serverTimezone 参数,比如 jdbc:mysql://localhost:3306/test?serverTimezone=Asia/Shanghai。这是很多新手第一个报错来源。
我建议把依赖版本集中放在 properties 里管理,虽然这个实验只有三个依赖,但是养成良好的习惯,后面依赖多了你就知道好处了。
2.2 准备数据库表和实体类
本实验我用最经典的 student 表。建表语句:
CREATE TABLE `student` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(32) DEFAULT NULL, `age` int(11) DEFAULT NULL, `email` varchar(64) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;实体类 Student,属性名与表字段一一对应。这里有一个关键点:MyBatis 的默认映射规则是“驼峰转下划线”,也就是说 Java 里的 userName 能自动映射到数据库的 user_name,前提是你开启了 mapUnderscoreToCamelCase 配置。本实验字段都是单单词,不存在这个问题,但你要有这个意识。实体类我习惯写成这样:
public class Student { private Integer id; private String name; private Integer age; private String email; public Student() { } public Student(String name, Integer age, String email) { this.name = name; this.age = age; this.email = email; } // getter/setter 方法,这里省略 }注意一定要保留无参构造方法。MyBatis 底层是通过反射调用无参构造创建对象的,如果你重载了有参构造却忘了写无参构造,运行时会直接报 NoSuchMethodException,这个错误很隐蔽。
3. 核心配置文件 mybatis-config.xml:每个节点都是干什么的
MyBatis 的全局配置文件是整个框架的“总开关”。实验一里最重要的几个配置项是:环境配置(数据源和事务)、mapper 映射文件注册、以及可选的类型别名和驼峰映射。配置文件默认放在 resources 目录下,确保编译后能在 classpath 里找到。
3.1 从 environment 到 dataSource 的配置逻辑
看一段最标准的配置,然后逐个解释:
<?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> <!-- 引入外部 properties 文件,如数据库账号密码 --> <properties resource="db.properties"/> <!-- 开启驼峰命名自动映射 --> <settings> <setting name="mapUnderscoreToCamelCase" value="true"/> </settings> <!-- 类型别名:让 Mapper XML 里可以写 student 代替全限定类名 --> <typeAliases> <package name="com.example.entity"/> </typeAliases> <environments default="development"> <environment id="development"> <transactionManager type="JDBC"/> <dataSource type="POOLED"> <property name="driver" value="${driver}"/> <property name="url" value="${url}"/> <property name="username" value="${username}"/> <property name="password" value="${password}"/> </dataSource> </environment> </environments> <!-- 注册 Mapper 映射文件 --> <mappers> <package name="com.example.mapper"/> </mappers> </configuration>environment 节点定义了一个“运行环境”。default 属性指定默认使用哪个环境,如果你想整多套环境(开发、测试、生产),就在 environments 下写多个 environment,然后切换 default 即可。transactionManager 的 type 如果是 JDBC,表示事务由 MyBatis 自行管理,提交和回滚都是手动调用 SqlSession 的 commit/rollback;如果是 MANAGED,则表示把事务交给外部容器管理,比如 Spring,这个到整合阶段再接触,本实验用 JDBC 就对了。
dataSource 的 type 有三种可选值:UNPOOLED、POOLED、JNDI。POOLED 是带连接池的,MyBatis 内置了一个简单的连接池实现,实验里选它没问题,目的就是让连接复用,性能更好。生产环境中这个位置通常由 Druid 或 HikariCP 替代,但 MyBatis 的 POOLED 连接池机制是相通的,你先学会这个,后面换连接池就是改个类的事。
3.2 mapper 注册的两种方式
配置文件里 mappers 节点用来告诉 MyBatis“你的 SQL 映射文件在哪里”。有两种常见方式:一是用 mapper resource 直接指定 XML 路径,比如:
<mappers> <mapper resource="com/example/mapper/StudentMapper.xml"/> </mappers>二是用 package name 指定包名,MyBatis 会自动扫描包下所有接口,并要求接口和 XML 文件同名且同目录。第一种适合实验一这种 XML 和接口分开的场景,清晰直观;第二种适合后面项目化开发。我建议实验阶段先用 resource 方式,因为你不容易搞错路径。如果你用了 package 扫描,一定要保证 XML 文件的 resources 目录结构和接口的 package 结构保持一致,否则启动时会报 Invalid bound statement(not found)。
这个报错我见得太多了,几乎每个班级都会有人遇到。它出现的原因99%是 mapper 接口和 XML 文件没有正确关联:要么 XML 文件的 namespace 写错了,要么 mapper 接口的方法名和 XML 里语句的 id 对不上,要么 XML 文件压根没被 MyBatis 扫描到。排查顺序就是:一看 namespace,二看 id,三看 mappers 配置,基本百发百中。
4. Mapper 接口与 XML 映射文件:从 namespace 到 resultMap
MyBatis 从 3.0 开始支持 Mapper 接口编程,也就是你定义一个接口,MyBatis 动态生成实现类。这个机制让调用方只需要面向接口编程,不需要关心实现细节。实验一里,我们要建一个 StudentMapper 接口和对应的 StudentMapper.xml。
4.1 为什么 XML 里要有 namespace
先看接口:
public interface StudentMapper { Student selectById(Integer id); List<Student> selectAll(); int insert(Student student); int update(Student student); int deleteById(Integer id); }对应的 XML:
<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "https://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.mapper.StudentMapper"> <select id="selectById" resultType="student"> select id, name, age, email from student where id = #{id} </select> <select id="selectAll" resultType="student"> select id, name, age, email from student </select> <insert id="insert" parameterType="student" useGeneratedKeys="true" keyProperty="id"> insert into student(name, age, email) values(#{name}, #{age}, #{email}) </insert> <update id="update" parameterType="student"> update student set name = #{name}, age = #{age}, email = #{email} where id = #{id} </update> <delete id="deleteById" parameterType="int"> delete from student where id = #{id} </delete> </mapper>namespace 必须写接口的全限定名,这是 MyBatis 把接口和 XML 绑定的凭据。MyBatis 在启动时会做两件事:一是注册这个 namespace 下的所有 SQL 语句,二是把 namespace 和对应的接口 class 建立映射。运行时,当你调用 mapper 接口的方法,MyBatis 会根据“接口全限定名 + 方法名”找到唯一一条 SQL 语句。所以 namespace 一旦写错,哪怕只差一个字母,运行时就报 binding exception。
4.2 resultType 和 resultMap 的选择
对于这张 Student 表,字段名和属性名完全一致,直接用 resultType 指定别名(student 是我们在 mybatis-config.xml 里配的 typeAliases),MyBatis 会自动把查询结果的列名映射到 Student 的属性上。但如果你的结果集列名和 Java 属性名对不上,比如数据库里是 user_name,Java 里是 userName,有两种解决方案:第一是开启驼峰映射 mapUnderscoreToCamelCase,前提是列名符合下划线风格;第二是写 resultMap 显式映射。实验一我用 resultType 就够了,但我会给学生演示一下 resultMap,因为它是理解 MyBatis 结果集映射机制的核心:
<resultMap id="studentMap" type="com.example.entity.Student"> <id property="id" column="id"/> <result property="name" column="name"/> <result property="age" column="age"/> <result property="email" column="email"/> </resultMap>resultMap 里的 id 标签是主键列,result 标签是普通列。这个映射机制在后面做多表联查、一对多嵌套结果集时是你的救命稻草。入门阶段先认识它,知道它是“列名和属性名的翻译官”就够了。
4.3 #{} 和 ${} 的区别:初学者必须分清的坑
这是 MyBatis 面试题里的第一题,也是实际操作中最容易出事的地方。#{} 是预编译占位符,MyBatis 会把它解析成 JDBC 的 ?,由 PreparedStatement 来设置参数,天然防止 SQL 注入;${} 是字符串拼接,MyBatis 直接把它替换成参数的字符串值,不经过预编译。例如:
<select id="selectByName" resultType="student"> select * from student where name = #{name} </select>执行时 MyBatis 会生成 select * from student where name = ? 这样的 SQL,然后 setString(1, name)。换成 ${} 则是 select * from student where name = '张三',如果 name 里带着单引号,SQL 就会被注入。所以结论很明确:能用 #{} 的地方绝对不用 ${}。${} 的唯一合理应用场景是动态表名、排序列名这种无法用占位符的地方,而你必须在代码层做白名单校验,不能直接把用户输入拼进去。
5. 核心操作实操:从 getAll 到主键回填,五种常用 SQL 一次说清
实验一的重点体现其实就是写一个工具类、写一个测试类,把 6 种常见数据库操作跑通。下面我把每一步的细节都给你捋一遍,包括踩过的坑。
5.1 工具类 SqlSessionFactoryUtil:为什么要用单例
MyBatis 的入口是 SqlSessionFactoryBuilder 读入配置,构建出 SqlSessionFactory,再由 SqlSessionFactory 开启 SqlSession。一个常见的误区是每次都 new 一个 SqlSessionFactoryBuilder 重新读配置——这没必要,因为 SqlSessionFactory 是线程安全的,一个应用只需要创建一次。我习惯写一个工具类:
public class SqlSessionFactoryUtil { private static SqlSessionFactory sqlSessionFactory; static { try { String resource = "mybatis-config.xml"; InputStream inputStream = Resources.getResourceAsStream(resource); sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream); } catch (IOException e) { e.printStackTrace(); } } public static SqlSession getSqlSession() { return sqlSessionFactory.openSession(); } }Resources.getResourceAsStream 是 MyBatis 提供的工具方法,会从 classpath 根路径加载资源,等价于类加载器的 getResourceAsStream。openSession() 默认是不自动提交的,也就是说你要对数据库做写操作时,必须手动 commit,这个下面具体说。
SqlSession 不是线程安全的,每个线程必须使用自己的 SqlSession 实例。绝不能在多个线程间共享同一个 SqlSession。即使你用 SqlSession 直接操作也别这样做。这是 MyBatis 源码里明写的,很多坑都源于此。
5.2 测试类的完整演示:查询、插入、更新、删除
JUnit 测试类如下:
public class StudentMapperTest { @Test public void testSelectById() { SqlSession sqlSession = SqlSessionFactoryUtil.getSqlSession(); try { StudentMapper mapper = sqlSession.getMapper(StudentMapper.class); Student student = mapper.selectById(1); System.out.println(student.getName()); } finally { sqlSession.close(); } } @Test public void testInsert() { SqlSession sqlSession = SqlSessionFactoryUtil.getSqlSession(); try { StudentMapper mapper = sqlSession.getMapper(StudentMapper.class); Student student = new Student("小明", 18, "xiaoming@example.com"); int rows = mapper.insert(student); sqlSession.commit(); System.out.println("影响行数:" + rows); System.out.println("自增主键:" + student.getId()); } finally { sqlSession.close(); } } @Test public void testUpdate() { SqlSession sqlSession = SqlSessionFactoryUtil.getSqlSession(); try { StudentMapper mapper = sqlSession.getMapper(StudentMapper.class); Student student = mapper.selectById(1); student.setAge(20); mapper.update(student); sqlSession.commit(); } finally { sqlSession.close(); } } @Test public void testDelete() { SqlSession sqlSession = SqlSessionFactoryUtil.getSqlSession(); try { StudentMapper mapper = sqlSession.getMapper(StudentMapper.class); mapper.deleteById(2); sqlSession.commit(); } finally { sqlSession.close(); } } }注意几个细节:第一,查询操作不 commit 没关系,但写操作(insert/update/delete)一定要 sqlSession.commit(),否则数据不会真正写入数据库,而且连接不释放的话,第二次操作还会拿到一把“脏连接”。第二,用 try-finally 保证 SqlSession 一定关闭。忘记关闭会导致连接池连接数慢慢耗尽,服务器最终“卡死”。第三,插入后从 student.getId() 就能拿自增主键,这依赖 XML 里的 useGeneratedKeys 和 keyProperty 配置。
5.3 主键回填:useGeneratedKeys 背后的机制
在 insert 语句里我写了 useGeneratedKeys="true" keyProperty="id"。这两个配置的意思是:执行完 INSERT 后,把数据库生成的自增主键值回填到传入对象的 id 属性上。它的底层实现是 JDBC 的 getGeneratedKeys 方法,MySQL 驱动支持这种操作。
为什么不回填的话你就拿不到 id?因为自增主键是数据库生成的,你的 Java 对象在 insert 之前那个 id 是 null。如果你需要在插入后立刻拿到主键去做一些关联操作(比如插入订单后马上插入订单明细,明细要带订单id),这一步非常关键。如果你用 MySQL,也可以不用这个功能而是重新查询一次,但那样多一次数据库交互,性能就差一些了。
5.4 参数传递的两个细节:@Param 和 parameterType
看上面的 mapper 接口:selectById(Integer id) 只有一个参数,XML 里写 #{id} 没问题。但如果你的方法有两个以上参数,比如 selectByNameAndAge(String name, Integer age),MyBatis 就不知道 #{name} 具体是哪个参数了。这时你必须用 @Param 注解:
Student selectByNameAndAge(@Param("name") String name, @Param("age") Integer age);XML 里写 #{name}、#{age}。如果你不写 @Param,MyBatis 会以 arg0、arg1 或 param1、param2 作为参数名,代码可读性会大打折扣,而且在某些版本下行为可能不一样。入门阶段我给的建议是:凡是一个方法参数是两个及以上的,一律加 @Param,养成习惯,后面写复杂 SQL 时省很多事。
另外,XML 里 select 标签的 parameterType 属性可以省略,MyBatis 会自动推断参数类型。但入门阶段建议写上,一是可读性,二是某些老版本 MyBatis 会在某些边界场景下推断出错。insert、update 标签同理。
6. 进阶知识点串联:动态 SQL、缓存、逻辑删除,面试前必须搞懂的硬货
实验一跑通之后,很多人会问:这也就够做个单表 CRUD,MyBatis 凭什么是国内 Java 开发的主流?答案在于它的动态 SQL 能力——在 XML 里写逻辑分支,极大简化了多条件查询。这个部分我单独展开讲,也顺带把热搜里的几个高频问题串进来。
6.1 动态 SQL 的核心标签和“if test indexof”的奇技淫巧
比如根据姓名和年龄做条件查询,学生可能只传姓名、只传年龄、两个都传、都不传。用 JDBC 你得拼 SQL 加判断,用 MyBatis 可以这样:
<select id="selectByCondition" resultType="student"> select id, name, age, email from student <where> <if test="name != null and name != ''"> and name = #{name} </if> <if test="age != null"> and age = #{age} </if> </where> </select>where 标签会自动处理掉第一个 and 前缀,这个很省心。如果你不用 where 标签,而是自己写 where 1=1,也能跑,但 where 标签更优雅,而且语义清晰。
热搜词里有个“mybatis if test indexof”,这其实是在问 if 标签里怎么写字符串包含判断。比如查询名字里带“明”字的学生,你可以这样:
<if test="name.indexOf('明') != -1"> and name like concat('%', #{name}, '%') </if>但这里有个大坑:OGNL 表达式里写单引号字符串时,外部要用双引号,否则会解析异常。正确写法是:
<if test='name.indexOf("明") != -1'>这种冷门细节在面试里倒是常被拿来考,实际开发中更常见的做法是把这类条件直接放在 Java 代码层处理,而不是全堆在 XML 里。
动态 SQL 还有两个高频标签:foreach 用于批量操作,choose 相当于 Java 的 switch。实验一没用到,但建议自己试着写一个批量插入:
<insert id="batchInsert"> insert into student(name, age, email) values <foreach collection="list" item="stu" separator=","> (#{stu.name}, #{stu.age}, #{stu.email}) </foreach> </insert>这背后其实是把多个 values 段拼接成一条 SQL,减少了数据库交互次数。但要注意:MySQL 默认单条 SQL 大小有限制(max_allowed_packet),一次性插入几千条也可能触发报错,所以批量插入最好分批,一般每批 500 条左右比较稳妥。
6.2 MyBatis 缓存机制:一级缓存、二级缓存、以及逻辑删除
MyBatis 缓存在入门实验里不一定会演示,但它是面试题重灾区。一级缓存是 SqlSession 级别的,默认开启,同一个 SqlSession 里执行两次相同的查询,第二次直接从缓存返回,不查数据库。前提是查询中间没有执行 insert/update/delete(这些操作会清空缓存)。二级缓存是 namespace 级别的,需要手动开启,多个 SqlSession 共享,但要注意脏数据问题——如果两个表关联查询,缓存数据就可能因为另一张表的更新而过期。
再说说“mybatis plus 查询 禁用逻辑删除”。逻辑删除是指数据不真正删除,而是通过一个 deleted 字段标记为已删除。MyBatis-Plus 默认会在查询时自动拼接 deleted = 0 条件。如果你想在一次查询里把已删除的数据也查出来,就得在查询条件里设置 @TableLogic 相关的处理,或者在 mapper 接口方法上把逻辑删除的自动填充关掉。这个属于 MyBatis-Plus 的范畴,但它背后反映的是 MyBatis 拦截器原理——MyBatis 在执行 SQL 前可以通过插件机制动态改写 SQL。
6.3 MyBatis 工作原理和源码从哪入手
面试官问“MyBatis 工作原理”时,其实是想听一条完整链路:解析配置文件生成 Configuration 对象、通过 SqlSessionFactoryBuilder 构建 SqlSessionFactory、SqlSessionFactory 打开 SqlSession、SqlSession 根据 Mapper 接口动态代理生成实现类、执行 SQL 时通过 Executor 调用 StatementHandler、经过 ParameterHandler 设置参数、再经 ResultSetHandler 处理结果集。这条链路里最重要的几个类:Configuration、MappedStatement、Executor 和四个核心组件。
源码阅读的话,我建议从 SqlSessionFactoryBuilder.build() 入手,跟着它一路看到 XMLConfigBuilder 解析配置文件的过程,然后再看 mapper 的 bindMapperForNamespace。第二步去看 SqlSessionManager 的动态代理是怎么把接口方法映射到 MappedStatement 上的。面试官很少要求你背代码,但你说得出“Configuration 是根对象、MappedStatement 封装 SQL 语句信息、Executor 负责读写数据库”这套,就能过关。
6.4 打印 SQL 和可执行 SQL 插件,开发排查的必备工具
在 mybatis-config.xml 里配置日志,开发阶段强烈建议把 SQL 打出来:
<settings> <setting name="logImpl" value="STDOUT_LOGGING"/> </settings>这样 MyBatis 会把执行的 SQL、参数、返回行数打印到控制台。缺点是打印的是预编译 SQL,参数是问号,你想复制出来直接执行还得自己替换。所以社区里有了“打印可执行 SQL 插件”(比如 p6spy、MyBatis SQL 日志插件),它们通过拦截 PreparedStatement 参数,把 SQL 和参数拼成可直接执行的完整语句。如果你用 Idea,也可以装 MyBatis Log Plugin 插件。这类插件本质上是基于 MyBatis 的拦截器机制(Interceptor)在 Executor 或 StatementHandler 层面拦截,在参数设置完成后把 SQL 拼接成一个字符串再打日志。学了 MyBatis 插件机制后,你自己也可以写一个,原理不复杂。
6.5 查询行号、只读模式报错等边界问题速查
热搜里还有几个有意思的关键词。“mybatis查询增加行号”指的是在 SQL 里用 ROW_NUMBER() OVER (ORDER BY xxx) 生成序号列,这在分页列表展示时经常用到。MyBatis 里直接写在 SQL 里就行,比如:
<select id="selectWithRowNum" resultType="map"> select ROW_NUMBER() OVER (ORDER BY id) as rownum, name, age from student </select>如果你用的 MySQL 5.x 不支持窗口函数,可以改用 @rownum := @rownum + 1 这种写法。
“mybatis报错write operations are not allowed in read-only mode (flushmode.man)” 这个报错翻译过来是“不允许在只读模式下执行写操作”。它出现的场景往往是:你的数据源(比如某些云数据库、只读从库)设置了 read-only=true,或者 MyBatis 环境里的拦截器/事务管理器把连接标记为只读。解决办法是检查数据源配置,确保写操作走的是可写连接。如果用的是 Spring 事务,还要检查 @Transactional(readOnly = true) 是不是误加在了写方法上。
7. 常见报错与排查经验:五个坑,每一个都有同学踩过
这个章节我以自己的教学和项目经历整理一个速查表,遇到问题先对着查,大概率能省半小时。
| 异常现象 | 根本原因 | 解决方案 |
|---|---|---|
| java.io.IOException: Could not find resource mybatis-config.xml | 配置文件不在 classpath 下,或名字拼错 | 确认 resources 目录下存在该文件,使用 target/classes 检查编译输出 |
| org.apache.ibatis.binding.BindingException: Invalid bound statement (not found) | Mapper 接口和 XML 未正确绑定 | 检查 namespace 是否等于接口全限定名、方法 id 是否存在、mapper 是否已注册 |
| Cause: com.mysql.cj.exceptions.InvalidConnectionAttributeException | MySQL 8 连接 URL 缺少时区参数 | 在 URL 添加 serverTimezone=Asia/Shanghai |
| Exception in thread "main" org.apache.ibatis.exceptions.PersistenceException | SQL 语法错误或参数类型不匹配 | 打开 SQL 日志,把 SQL 拿出来在数据库客户端执行验证 |
| 插入数据但数据库无变化 | 未提交事务 | 写操作后调用 sqlSession.commit() |
| 数据库中自增 id 值跳动或变慢 | 自增步长设置,不是 MyBatis 的问题 | 检查 MySQL 的 auto_increment 配置和 InnoDB 锁策略 |
还有一些初学者容易忽视的细节:XML 文件放在 resources 目录但命名和接口不同,或者漏掉了 mapper 映射的 package 声明,这些都可能导致前面说的 Invalid bound statement。我的排查顺序永远是:先看控制台日志和 SQL 输出,再定位配置,最后才怀疑代码逻辑本身。多数情况下,问题就出在“配置里少写了一行”或者“包路径写错了”。
手动测试时,如果 MySQL 8 驱动版本和 MySQL 服务端版本不匹配,可能会报 Connection refused 之类的问题。检查驱动版本和 server 端的兼容性,8.0.33 驱动连接 MySQL 5.7 服务端一般是没问题的,但反过来连接 8.0 可能会因认证插件报错。这个属于环境层面问题,发生后先到 MySQL 的日志文件里找线索。
8. 从入门到深入:实验一之后还可以怎么扩展
做完 CRUD 实验,如果你还有余力,我强烈建议做几个小扩展,它们能帮你把 MyBatis 的掌握程度从“会用”拉到“用得明白”,而且这些扩展不会花太多时间。
第一,加一个分页查询功能。先用最朴素的方式 limit #{offset}, #{pageSize} 做,手动算 offset。然后你再去了解 RowBounds 的原理,以及 PageHelper 这个分页插件是怎么通过拦截器来自动拼出 count 查询和 limit 的。把这个搞明白,MyBatis 插件机制你就入门了。
第二,写一个多表联查。比如加一张 course 表和 score 表,学生选课,用 resultMap 实现一对多映射。这个练习能帮你彻底搞懂 resultMap 里的 collection 和 association 标签,这是 MyBatis 映射能力最强大的地方,也是面试重点。
第三,把整个流程从 MyBatis 原生编程迁移到 Spring Boot 整合版本。你对比一下整合前后的配置差异,就能理解 Spring Boot 的 AutoConfiguration 帮我们做了什么。学到这里,你已经能应对实际工作中的大多数场景了。
第四,如果想研究打印可执行 SQL 插件,可以自己动手基于 Interceptor 写一个简单的打印拦截器。插件的注解 Intercepts、Signature 怎么用,源码里 Executor 和 StatementHandler 的 execute 方法在什么时机调用,动手写一下比看十篇文章都有效。
9. 我个人在带实验过程中的几点体会
最后说几句凭经验得出的建议。做这个实验的时候,我见过好几种学习姿态:一种是把代码敲一遍跑通就算完,一种是试着把每个配置删掉再看有什么影响,还有一种是改各种参数看运行结果。多年下来我的体会是,最能拉开差距的恰恰是后两种。实验一的内容本身不难,真正的分水岭在于你有没有借此建立起“配置驱动”的思维方式——这个框架里几乎每个行为都是配置决定的,你改配置文件,框架行为就变。
比如你把 transactionManager 从 JDBC 换成 MANAGED,不 commit 也能写数据了吗?你把 dataSource 从 POOLED 换成 UNPOOLED,跑大量查询时会变慢多少?你把 logImpl 换成 LOG4J2,日志输出有什么不同?这些问题亲手试一遍,比背一百个面试题都管用。
还有一个小技巧:运行实验时强制自己开两个 SqlSession 做对比实验,比如同一个查询分别在两个 SqlSession 里执行,观察 SQL 日志打印了几次。你会发现第二次查询没走数据库,这就是一级缓存的直观体现。然后再在同一 SqlSession 里先查后改,再查,看看日志有没有多打一次。这种“实验式学习”的方式,能让你对 MyBatis 的理解快速超过那些只停留在“会用”层面的人。