先说说我自己的经历。大学刚毕业那会儿,我在一家外包公司写Java,数据库操作用的还是最原始的JDBC。每次写数据访问代码,都得自己管理Connection、PreparedStatement、ResultSet,手动处理异常、关闭资源。代码里最显眼的就是一堆try-catch-finally,真正干活的SQL反而被埋没在样板代码里。后来项目引入MyBatis,我的视角一下就变了:原来SQL可以跟Java代码分离,原来一个接口方法配上XML映射就能完成数据库操作,原来条件查询可以这么灵活。如果说JDBC是刀耕火种,那MyBatis算是把数据访问这层从“体力活”变成了“脑力活”,而今天这篇博文,就是我从零开始梳理的MyBatis入手指南,覆盖环境搭建、核心配置、增删改查的完整写法,以及我踩过的坑和总结的排查技巧。
这篇内容适合谁?刚学完Java语法和MySQL,准备进入企业开发的新手;在SSM项目里天天写Mapper但一直没搞懂底层原理的同学;以及那些面试前需要快速梳理MyBatis知识脉络的朋友。全文不推荐框架、不空谈,直接按我实际项目的经验,带你从JDBC的痛点出发,一步步走进MyBatis的世界,把增删改查这关彻底打通。
1. 为什么选择MyBatis:从JDBC的痛点说起
要真正理解MyBatis的价值,得先回头看看JDBC写起来有多痛苦。我举个例子,一个最基础的查询操作,用JDBC写需要几步:
第一步,加载驱动并获取连接。这里需要Class.forName注册驱动、DriverManager.getConnection传入URL、用户名、密码。第二步,创建Statement或者PreparedStatement,拼接SQL语句。第三步,执行查询拿到ResultSet,然后遍历结果集,手动调用rs.getString、rs.getInt等方法取字段,再一个个set到实体对象里。第四步,在finally代码块里依次关闭ResultSet、Statement、Connection。
这四个步骤看着不多,但重复性极高。每写一个方法都要来一遍,而且还有三个很麻烦的问题。
第一个问题是SQL和Java代码强耦合。SQL写成字符串埋在Java代码里,没有语法高亮、没有智能提示,写错一个拼写只能在运行时报错。更别说动态SQL了,要根据条件拼接where子句时,只能用if判断字符串拼接,一旦某个条件没拼好,多了一个and或者少了一个空格,调试起来让人头大。
第二个问题是结果集的映射工作非常机械。数据库字段叫user_name,实体类属性叫userName,每次都要手动rs.getString("user_name")再setUserName(),字段一多,三十个字段映射代码能把你写到崩溃,而且一旦数据库字段改名,所有相关的set方法调用都要跟着改。
第三个问题是连接资源的管理。很多新手初期都忽略了这个,忘了关闭连接,在高并发下很快就会把数据库连接池打满,出现连接超时。项目小的时候不太明显,一旦上线跑上几天,问题立刻暴露。
MyBatis解决的就是这三类痛点。它是一个半自动的ORM框架,半自动的意思是:SQL由开发者自己写,这一点跟Hibernate那种全自动框架不同,MyBatis不帮你自动生成SQL,而是给你一个清晰的机制来管理SQL和结果映射。你只需要定义好Mapper接口、写好自己的SQL,剩下的参数设置、结果集映射、连接管理,MyBatis帮你处理。
它的核心思想是“SQL与Java代码分离”。你可以在XML文件里写SQL,也可以使用注解写SQL,然后在Java代码里只调用接口方法,MyBatis会自动把接口方法跟SQL对应起来。SQL字符串不再散落在Java代码里,而是集中在一个映射文件里,维护起来舒服很多。
另外,MyBatis在ORM上有自己独有的灵活性。标准的ORM是表映射到对象,MyBatis则是SQL结果映射到对象,你可以让query返回一个Map,也可以让它返回一个自定义的DTO,甚至返回一个嵌套的对象列表。这种灵活性在复杂查询场景下特别好用。
我通常会把MyBatis理解成“一个帮你搭好骨架的JDBC封装”,它没有把SQL的能力阉割掉,反而保留了你对SQL的完全控制权。想写存储过程可以,想写多表联查可以,想用数据库特有的函数也可以,没必要学会HQL那套。这也是很多老项目、金融项目、传统企业项目至今仍用MyBatis的根本原因。
2. 快速搭建MyBatis环境:依赖、配置和第一个会话
很多人一开始学MyBatis,习惯直接配Spring Boot,因为Spring Boot的starter把很多事情都封装好了,但我建议第一次接触MyBatis,还是先脱离Spring框架,单独跑一遍原生的MyBatis流程。原因很简单:Spring Boot帮你自动装配了SqlSessionFactory,掩盖了MyBatis核心组件的创建过程,你根本看不到SqlSessionFactory是怎么来的、SqlSession是如何获取的,这样即使跑通了,心里也没底。等原生流程理顺了,再切到Spring Boot,你会发现所有概念都是一一对应的。
2.1 Maven依赖引入
我用Maven管理项目,先建一个普通的Java工程,然后在pom.xml里添加MyBatis依赖和MySQL驱动。MyBatis核心依赖就一个:
<dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.16</version> </dependency>MySQL驱动依赖:
<dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency>这里有一个非常重要的版本说明:MySQL 8.x以后,驱动类名变了,不再是com.mysql.jdbc.Driver,而是com.mysql.cj.jdbc.Driver。之前很多老教程还在用旧的驱动类名,如果你复制过来运行,大概率会报ClassNotFoundException。另外MySQL 8.x的驱动包也不需要手动Class.forName加载,因为JDBC 4.0之后驱动会自动注册。
2.2 核心配置文件mybatis-config.xml
MyBatis的全局配置文件负责定义运行时的行为,比如数据库连接、日志实现、别名、映射文件注册等。在resources目录下创建一个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> <settings> <setting name="logImpl" value="STDOUT_LOGGING"/> <setting name="mapUnderscoreToCamelCase" value="true"/> </settings> <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/mybatis_demo?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"/> <property name="username" value="root"/> <property name="password" value="123456"/> </dataSource> </environment> </environments> <mappers> <mapper resource="mapper/UserMapper.xml"/> </mappers> </configuration>这段配置文件有几个值得展开说明的细节。先说settings里的logImpl,设置成STDOUT_LOGGING后,MyBatis会直接把SQL日志输出到控制台,对于学习阶段来说是观察SQL执行的最简单方式。比起翻日志文件,控制台输出来得直接太多。还有一个非常关键的设置是mapUnderscoreToCamelCase,它会把数据库字段的下划线命名自动映射到Java属性的驼峰命名。也就是说myBatis数据库字段名能自动映射到myBatis属性名,这一个配置能省掉大量resultMap的编写工作。但要注意,这个配置只做自动映射,如果你的查询结果涉及嵌套对象,还是要手动写resultMap。
再来说environments配置。
transactionManager和dataSource的概念源自MyBatis的底层架构。transactionManager类型选JDBC,表示事务控制交给MyBatis管理,底层通过java.sql.Connection的commit、rollback来控制事务。dataSource选POOLED,表示使用MyBatis自带的数据库连接池,它会帮你复用连接,避免每次操作都重新创建连接。在真实项目中,这里往往会被Spring接管,配置成JNDI或者使用Druid、HikariCP连接池,但我们现在先使用内置的POOLED,跑通流程就够用了。
URL里的serverTimezone=Asia/Shanghai是为了解决MySQL 8.x连接时的时区报错。MySQL 8.x默认时区是UTC,如果本地环境是东八区但不匹配的话,会报serverTimeZone相关的异常。所以这个参数建议加上,免得浪费时间在连接阶段。
mappers标签里注册的是Mapper映射文件,读写SQL映射就放在这个文件里。这里注意resource路径要写对,是从classpath根目录开始算的,写成mapper/UserMapper.xml,那么文件实际应该放在resources/mapper/UserMapper.xml。
2.3 构建SqlSessionFactory的工具类
MyBatis操作数据库的核心链路是:SqlSessionFactory -> SqlSession -> Mapper接口。SqlSessionFactory是重量级对象,一个应用只需要创建一次,应该使用单例模式来管理。我写了一个简单的MyBatisUtil工具类:
public class MyBatisUtil { 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(); throw new ExceptionInInitializerError("MyBatis初始化失败"); } } public static SqlSession getSession() { // 默认不自动提交事务 return sqlSessionFactory.openSession(); } public static SqlSession getSession(boolean autoCommit) { // 需要手动提交时传true return sqlSessionFactory.openSession(autoCommit); } }为什么要单独写这个工具类?因为SqlSessionFactory的构建只跟配置文件读取有关,属于应用初始化阶段的动作,放在static代码块里执行一次就够了。如果每次操作数据库都重建一个SqlSessionFactory,性能会有损失,而且连接池也没法复用。而SqlSession是线程不安全的,不能设计成全局共享对象,每个线程应该用自己的SqlSession,用完就关闭。这是MyBatis使用中特别重要的一个认知。
3. 从建表到第一个查询:写一个完整的CRUD
环境搭好了,接下来就是实际的增删改查操作。我建议跟着下面的步骤手写一遍,不要复制粘贴,因为CRUD的每一步都有一些小细节,自己敲过一遍之后记忆会深刻很多。
3.1 准备数据库表和实体类
先准备一张用户表,字段尽量简单,方便聚焦在框架用法上:
CREATE DATABASE IF NOT EXISTS mybatis_demo DEFAULT CHARACTER SET utf8mb4; USE mybatis_demo; CREATE TABLE `user` ( `id` int(11) NOT NULL AUTO_INCREMENT, `user_name` varchar(50) NOT NULL, `password` varchar(100) DEFAULT NULL, `email` varchar(100) DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;然后写实体类。注意数据库字段用的是下划线user_name,实体类属性用的是驼峰userName,后面我们会验证mapUnderscoreToCamelCase是否真的生效:
public class User { private Integer id; private String userName; private String password; private String email; private Date createTime; // getter/setter 省略 // toString 省略 }细心的读者会发现一个点:字段类型Integer和int、Date和java.util.Date,MyBatis对于这些基本类型的映射有完善的转换机制,数据库int类型可以映射到Java的Integer或者int,datetime类型可以映射到Date。平时开发中如果确定字段值可能为空,建议使用包装类型,比如Integer而不是int,否则查询结果为空时,int类型会收到默认值0,容易造成数据丢失的误解。
3.2 Mapper接口与XML映射文件
MyBatis的Mapper接口和XML映射文件是通过namespace关联起来的。Mapper接口只负责声明方法签名,XML里则写着具体执行的SQL。先定义UserMapper接口:
public interface UserMapper { User selectById(Integer id); List<User> selectAll(); int insert(User user); int update(User user); int deleteById(Integer id); }再写对应的XML映射文件UserMapper.xml,放在resources/mapper目录下:
<?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.demo.mapper.UserMapper"> <select id="selectById" resultType="com.demo.entity.User"> SELECT id, user_name, password, email, create_time FROM user WHERE id = #{id} </select> <select id="selectAll" resultType="com.demo.entity.User"> SELECT id, user_name, password, email, create_time FROM user </select> <insert id="insert"> INSERT INTO user (user_name, password, email) VALUES (#{userName}, #{password}, #{email}) </insert> <update id="update"> UPDATE user SET user_name = #{userName}, password = #{password}, email = #{email} WHERE id = #{id} </update> <delete id="deleteById"> DELETE FROM user WHERE id = #{id} </delete> </mapper>这里有几个关键点。
namespace必须写接口的全限定名,MyBatis会依据这个把XML里的SQL绑定到接口方法上。select标签的id值必须跟接口方法名一致,resultType写的是实体类的全限定名。这里的resultType,是MyBatis最常用的自动映射方式,它会根据返回类型自动把查询结果列映射到对应属性。由于我们在全局配置中开启了mapUnderscoreToCamelCase,所以查询出来的user_name会直接映射到userName属性。
插入语句里的字段没有id和create_time,原因是id是自增主键,create_time有默认值CURRENT_TIMESTAMP,这两列完全可以交给数据库来处理,不需要我们在SQL里显式写入。
注意一下#{}参数占位符的写法。#{userName}会通过JDBC的PreparedStatement机制替换成?占位符,然后MyBatis会根据传入对象的属性名来取值。这是MyBatis中非常重要的知识:#{...}是预编译占位符,能防止SQL注入,而${...}是字符串拼接,会直接把值拼到SQL里,存在注入风险。对于入门阶段,牢记一个原则:默认情况下能写#{}就不要写${}。
3.3 执行CRUD操作的完整代码
现在所有组件都准备好了,我来写一个增删改查的完整测试代码。这里用一个普通的main方法演示,实际项目中不需要每次自己手动创建SqlSession,Spring会帮我们管理,但当前先这样做:
public class MyBatisCrudDemo { public static void main(String[] args) throws IOException { // 1. 获取SqlSession,默认不自动提交 SqlSession sqlSession = MyBatisUtil.getSession(); try { UserMapper mapper = sqlSession.getMapper(UserMapper.class); // 2. 查询单个用户 User user = mapper.selectById(1); System.out.println(user); // 3. 新增用户 User newUser = new User(); newUser.setUserName("张三"); newUser.setPassword("123456"); newUser.setEmail("zhangsan@example.com"); int insertResult = mapper.insert(newUser); System.out.println("影响行数: " + insertResult); // 4. 修改用户 newUser.setId(newUser.getId()); newUser.setEmail("newemail@example.com"); int updateResult = mapper.update(newUser); System.out.println("更新行数: " + updateResult); // 5. 删除用户 int deleteResult = mapper.deleteById(newUser.getId()); System.out.println("删除行数: " + deleteResult); // 6. 查询所有用户 List<User> users = mapper.selectAll(); System.out.println("当前用户数量: " + users.size()); // 7. 提交事务 sqlSession.commit(); } finally { sqlSession.close(); } } }这个代码流程值得注意的点有两个。第一,SqlSession默认是手动提交事务的模式,执行完insert、update、delete操作后,必须调用sqlSession.commit(),否则数据不会真正写入数据库。这是一个典型的新手坑,很多人写了insert之后查询,查不到数据,最后发现是没提交事务。第二,finally里关闭SqlSession,把资源释放做干净。SqlSession内部持有数据库连接,不关闭的话连接池连接会耗尽。
如果不想每个操作都手动提交,可以在获取SqlSession时传入true,开启自动提交:
SqlSession sqlSession = MyBatisUtil.getSession(true);这样每一次操作都会自动提交,但这种方式适合简单的测试,真实项目里事务往往需要跨越多个方法,手动控制更符合实际业务场景。
4. 参数传递、主键回填与SQL日志:核心机制深入
第二部分的增删改查跑通了,但还有几个高频使用的操作细节,几乎每个真实项目都会碰到。把它们理清楚,才能真正脱离“照抄教程”的阶段。
4.1 方法多参数的传递方式
在实际项目中,单参数的情况其实不多,更多时候需要多个参数过滤条件。比如我需要根据用户名和邮箱查询用户,接口方法是这样写的:
User selectByNameAndEmail(String userName, String email);那么对应的XML里,SQL怎么写?直接写#{userName}和#{email}会报错,因为MyBatis不知道这两个参数叫什么名字。MyBatis默认会把参数按照param1、param2的顺序命名,所以可以这样写:
<select id="selectByNameAndEmail" resultType="com.demo.entity.User"> SELECT id, user_name, password, email, create_time FROM user WHERE user_name = #{param1} AND email = #{param2} </select>这样能跑通,但param1、param2可读性差,方法改名后更是难以维护。更推荐的方式是使用@Param注解显式指定参数名:
User selectByNameAndEmail(@Param("userName") String userName, @Param("email") String email);XML里就可以用#{userName}、#{email},一目了然。这是MyBatis几乎每天都会用到的注解,建议从一开始就习惯它。
当参数是一个对象的时候,比如我们前面写的insert(User user),XML中不需要额外注解,直接用#{userName}、#{email}就能取到对象里的属性值,这是userName取自user.getUserName(),即JavaBean的属性访问方式。
另一个常见场景是传一个Map作为参数。Map的话,key就是参数的名称,比如:
<select id="selectByMap" resultType="com.demo.entity.User"> SELECT * FROM user WHERE user_name = #{name} AND email = #{email} </select>调用时传一个Map,包含name和email两个key即可。不过Map的可读性比实体对象差,也不容易做类型校验,所以能用实体类、@Param的地方尽量别用Map。
4.2 自增主键回填
新增用户之后,我们常常需要拿到数据库生成的自增主键,用来做后续操作,比如写入关联表、记录日志、展示给前端。JDBC里的做法是调用PreparedStatement的getGeneratedKeys(),而MyBatis在insert标签中提供了一个useGeneratedKeys属性来做这件事,非常简单。
修改一下UserMapper.xml中的insert语句:
<insert id="insert" useGeneratedKeys="true" keyProperty="id"> INSERT INTO user (user_name, password, email) VALUES (#{userName}, #{password}, #{email}) </insert>useGeneratedKeys="true"表示需要数据库返回自增主键,keyProperty="id"表示把生成的主键值回填到传入对象User的id属性里。这样执行insert后,直接调用newUser.getId()就能拿到数据库生成的id。
需要注意,MySQL的auto_increment支持这个特性,其他数据库需要看是否支持类似机制。Oracle或者PostgreSQL不一定适用这种方式,它们可能要靠序列。不同数据库之间的自增策略差异,在项目中切换数据库时要格外注意。
4.3 clearn SQL日志里看到了什么
开启了STDOUT_LOGGING后,MyBatis在控制台输出的日志很有意思,它能让你直接看到框架做了哪些事。大致是这样的:
==> Preparing: SELECT id, user_name, password, email, create_time FROM user WHERE id = ? ==> Parameters: 1(Integer) <== Columns: id, user_name, password, email, create_time <== Row: 1, zhangsan, 123456, zhangsan@example.com, 2024-01-01 12:00:00 <== Total: 1日志分三段:Preparing显示的是预编译的SQL,参数用?代替;Parameters显示实际传入的参数值;Row显示查询出来的结果行数据。这个能力在后期调试SQL时非常有价值。比如你怀疑SQL写错了、参数传错了、结果映射有问题,看这几行日志就能定位。
而在生产环境中,通常不会使用STDOUT_LOGGING,而是把MyBatis的日志接入Logback或者Log4j2,输出到独立日志文件里。配置方式也简单,只要在resources下引入对应的日志框架,并设置MyBatis的日志级别为debug即可。不过这是后话,入门阶段STDOUT_LOGGING是最快的感知途径。
5. 动态SQL:条件查询的核心技能
增删改查学会了,接着要解决一个非常实际的问题:查询条件不固定怎么办?比如后台管理页面,用户列表查询有多个筛选条件:按用户名模糊查、按邮箱模糊查、按创建时间范围查、按状态查。用户填了哪个条件就查哪个,没填的忽略。用静态SQL写不出来这种效果,这时候就要用MyBatis动态SQL了。
5.1 if和where标签实现多条件筛选
我先写一个多条件查询的示例,把if和where配合起来用,这是动态SQL里最基础、出现频率最高的组合。
接口方法:
List<User> selectByCondition(@Param("userName") String userName, @Param("email") String email, @Param("createTime") Date createTime);XML:
<select id="selectByCondition" resultType="com.demo.entity.User"> SELECT id, user_name, password, email, create_time FROM user <where> <if test="userName != null and userName != ''"> AND user_name LIKE CONCAT('%', #{userName}, '%') </if> <if test="email != null and email != ''"> AND email = #{email} </if> <if test="createTime != null"> AND create_time >= #{createTime} </if> </where> </select>先解释一下where标签。它最大的好处是,如果所有条件都不成立,SQL就变成SELECT * FROM user,不带where子句,没问题。如果条件中有一个成立,比如只有email条件成立,生成的SQL是SELECT * FROM user WHERE email = ?,where标签会自动把拼接后的第一个AND删掉。你看,没有where标签的话,如果第一个if成立,SQL就会是WHERE AND email = ?,语法直接报错。所以where标签的作用就是“去掉多余的AND/OR前缀”。
再说if标签里的test表达式。这里是OGNL表达式,MyBatis的if判断用test属性表示条件是否成立。常见写法有:
- 判断非空字符串:userName != null and userName != ''
- 判断数值:status != null
- 判断集合非空:list != null and list.size() > 0
因为我们要做的是模糊查询,所以不能用=,而是用LIKE CONCAT('%', #{userName}, '%')拼接。你也可以写成 LIKE '%${userName}%',但它存在SQL注入风险,不推荐。
注意XML里的特殊字符。>=在XML中需要转义为>=(greater than or equal),因为XML解析器会认为>没有特殊问题,但>在XML标签属性中通常没问题,而<必须转义为<。为了避免麻烦,凡是小于号、大于号相关的比较,最好都写转义形式。
5.2 choose when otherwise实现多选一逻辑
if是“每个条件独立判断”,有可能多个if同时生效。但有些场景需要“多选一”:前端传了一个type参数,值为1按用户名查,值为2按邮箱查,值为3按创建时间查,其他情况查全部。这种逻辑用if就不合适,要用choose、when、otherwise。
<select id="selectByType" resultType="com.demo.entity.User"> SELECT id, user_name, password, email, create_time FROM user <choose> <when test="type == 1"> WHERE user_name = #{value} </when> <when test="type == 2"> WHERE email = #{value} </when> <when test="type == 3"> WHERE create_time >= #{value} </when> <otherwise> WHERE status = 1 </otherwise> </choose> </select>choose相当于Java里的switch,when类似case,一旦有when满足条件,后续的when和otherwise都不会再执行。如果所有when都不满足,才会执行otherwise。
还有一个经常用到的场景是批量删除,要用foreach标签。比如删除多个用户:
int deleteBatch(@Param("ids") List<Integer> ids);<delete id="deleteBatch"> DELETE FROM user WHERE id IN <foreach collection="ids" item="id" open="(" separator="," close=")"> #{id} </foreach> </delete>foreach的collection指定集合参数名,item是循环变量名,open是前缀,separator是分隔符,close是后缀。生成的SQL形如DELETE FROM user WHERE id IN (1, 2, 3)。这个写法在批量插入、批量更新里很常用,核心就是这一套foreach属性。
set标签同样是一个高频标签,用在update语句中处理“更新部分字段”的场景。比如只修改邮箱,不修改密码:
<update id="updateSelective"> UPDATE user <set> <if test="userName != null">user_name = #{userName},</if> <if test="password != null">password = #{password},</if> <if test="email != null">email = #{email},</if> </set> WHERE id = #{id} </update>set标签会动态地在前面加上SET关键字,同时去掉多余的逗号。这是MyBatis里最常用的工具标签五件套:where、if、choose、foreach、set。把这五样掌握了,90%的动态SQL场景都能覆盖。
6. resultMap与多表查询映射
入门阶段很多人都会有一个疑惑:为什么resultType能把列映射到对象,有时候又要写resultMap?这两者到底什么区别?
简单说,resultType偏自动,它按字段名和属性名做匹配,配好了mapUnderscoreToCamelCase之后,大多数单表单结果查询都不需要额外配置。而resultMap是显式定义映射规则,用于处理字段名对不上、嵌套对象、集合这种复杂的映射场景。
举一个常见的例子。订单表和用户表关联查询,订单表里有user_id,查询结果要返回订单信息,同时把用户名称带出来。如果实体设计成OrderDTO,包含订单字段和userName字段,那resultType就够了:
<select id="selectOrderWithUser" resultType="com.demo.dto.OrderWithUserDTO"> SELECT o.id, o.order_no, o.user_id, u.user_name FROM orders o LEFT JOIN user u ON o.user_id = u.id WHERE o.id = #{id} </select>字段映射得比较多时,resultType是最简单的方案。
但如果需求是“订单对象里包含一个完整的User对象”,也就是关联查询要映射成嵌套对象,就需要resultMap:
public class Order { private Integer id; private String orderNo; private Integer userId; private User user; }XML中的resultMap写法:
<resultMap id="orderResultMap" type="com.demo.entity.Order"> <id property="id" column="id"/> <result property="orderNo" column="order_no"/> <result property="userId" column="user_id"/> <association property="user" javaType="com.demo.entity.User"> <id property="id" column="user_id"/> <result property="userName" column="user_name"/> <result property="email" column="email"/> </association> </resultMap> <select id="selectOrderDetail" resultMap="orderResultMap"> SELECT o.id, o.order_no, o.user_id, u.user_name, u.email FROM orders o LEFT JOIN user u ON o.user_id = u.id WHERE o.id = #{id} </select>association就是用来映射“一个对象”属性的,如果订单要有多个商品明细,那就是一个List ,要用collection标签处理。resultMap比较繁琐,但它是处理复杂映射的兜底方案。项目一复杂,多表联查频繁出现时,resultMap的知识就变得异常重要。
在实际开发中,我的习惯是能依靠自动映射就不手写resultMap,一旦碰到嵌套对象或者字段名特殊的情况,再显式定义resultMap。这一方面减少了XML体量,另一方面也让映射关系清晰可见,不容易出问题。
7. 常见踩坑与排查方法快查表
整理了这段时间使用MyBatis遇到的高频问题,每一个都是真实场景里踩过的,全部列出来供大家参考。
| 问题表现 | 根本原因 | 解决方案 |
|---|---|---|
| Mapper接口方法报BindingException,说Invalid bound statement | namespace或id写错,XML文件没被扫描注册 | 检查namespace是否等于接口全限定名,id是否等于方法名,确认mybatis-config.xml里是否正确注册了mapper resource |
| 查询结果全是null | mapUnderscoreToCamelCase未开启,或者字段名和属性名对不上 | 全局配置开启该参数,无法匹配的字段手动写resultMap |
| 执行insert后数据库没有数据 | 没有commit,SqlSession默认手动提交 | 手动调用sqlSession.commit(),或者openSession(true)开启自动提交 |
| SQL日志打印了,但控制台没有输出 | 没有配置logImpl,或者没有引入日志依赖 | settings标签中设置STDOUT_LOGGING,或者引入Logback依赖并设置debug级别 |
| 使用#{}多个参数时报错SQL语法错误 | 方法多个参数没有@Param注解 | 使用@Param给每个参数显式命名,或用param1、param2(不推荐) |
| 动态SQL报错,说AND多余或者WHERE异常 | where和set标签使用不当 | 使用where标签替代手动WHERE,使用set标签替代手动SET |
| 中文乱码 | jdbc url没有配置characterEncoding=utf8 | 在JDBC URL中添加characterEncoding=utf8,并确保数据库表用utf8mb4 |
| SQL中用到<号报XML解析错误 | XML特殊字符未转义 | 写<和>,或使用CDATA区域包裹SQL |
还有一个比较隐蔽的问题,就是实体类属性名和数据库字段名对不上,比如数据库叫order_no,属性叫orderNo,如果没开启自动驼峰映射,这个字段结果就是null。排查这种问题时,最快的诊断方式就是看打印出来的SQL和Row日志,如果你发现SQL查出来了字段,但对象的属性是null,优先检查自动映射开关和resultMap。
另外提醒一下,XML文件里的SQL语句结尾要不要加分号?我建议不加。MyBatis在处理SQL时会自动处理,加了分号在一些数据库驱动下可能会报错,特别是在使用动态SQL拼接时。养成不加分号的习惯,能避开隐性错误。
还有一类常见问题来自IDEA等编辑器不刷新XML文件。修改了Mapper.XML后,如果构建工具没有重新编译target目录,运行时会加载旧的XML,表现为“我改了SQL但运行没变化”。排查时先Clean再重新编译,或者确认IDEA中的build模式是自动的。
8. MyBatis学习中必须想明白的几个问题
前几节可以理解为“会用MyBatis了”,但要想在面试中或者真实项目中真正立得住,还需要把背后的几个设计原理想明白。
第一个问题:MyBatis为什么叫“半自动”ORM?全自动ORM比如Hibernate,通过实体类映射自动生成SQL,开发者不需要写SQL,但代价是SQL调优空间受限。而MyBatis把SQL的控制权完全交给开发者,同时用映射机制解决了结果集到对象的繁琐转换。所以它在复杂查询、动态SQL、数据库特性利用方面有独特优势。面试里如果问你MyBatis和Hibernate的区别,这就是核心回答框架。
第二个问题:SqlSessionFactory和SqlSession到底是什么关系?你可以把SqlSessionFactory理解成“数据库连接工厂”,它是重量级、线程安全的,全局一个就够了。SqlSession则是“一次会话”,代表与数据库的一次交互过程,它不是线程安全的,每个线程需要独立的SqlSession。在MyBatis中,一次会话通常对应一个方法调用或者一次请求处理,用完需要关闭。MyBatis与Spring集成后,MyBatis-Spring会帮我们把SqlSession销毁和线程绑定等事情做了,但底层机制还是这一套。
第三个问题:一级缓存和二级缓存是怎么回事?面试题里经常出现。一级缓存是SqlSession级别的缓存,同一个SqlSession内执行相同的查询,第二次会直接从缓存返回,而不会再次执行SQL。二级缓存是namespace级别的缓存,多个SqlSession可以共享,默认情况下二级缓存是关闭的,需要配置开启。有时候你发现改了数据库数据但查询结果没变化,很可能就是缓存没失效。入门阶段知道这个机制存在即可,但面试或者做性能优化时,这是躲不掉的知识点。
第四个问题:Mapper接口为什么不需要实现类?很多人第一次看MyBatis代码都有这个疑问。其实Mapper接口的代理对象是JDK动态代理生成的,MyBatis在启动时会扫描Mapper接口,把每个方法跟XML里的SQL绑定,然后生成一个代理对象。代理对象调用方法时,根据方法名找到SQL,执行并返回结果。所以从本质上说,Mapper接口是“声明”而非“实现”,真正的实现逻辑由MyBatis动态代理加XML映射共同完成。
这四个问题想通了,MyBatis就不只是一个“照着写就完了”的工具了,你开始具备透过现象看本质的视角。后续看MyBatis源码、学习Spring集成、处理复杂事务时,都会有厚实的基础。
另一个非常现实的问题是:学了MyBatis之后要不要马上学Spring Boot集成?我的建议是,先花一天时间把原生MyBatis跑一遍,梳理清楚各个组件的关系,然后再去看Spring Boot+MyBatis的starter依赖。你会发现Spring Boot只是把SqlSessionFactory的创建和注入这件事自动化了,核心概念一个没变。直接上Spring Boot而不了解原生流程,对应的组件知识就会比较碎片化,遇到问题定位也慢。
以我自己的经历来说,从最初看不懂XML配置,到后来能根据日志快速判断是参数问题还是映射问题,这中间靠的不是死记硬背,而是多写、多试、多做组合条件的查询。增删改查是最基础的一环,所有偏复杂的查询、嵌套映射、批量操作,都是在这个基础上叠加场景。建议你顺手把这几段示例代码都跑一遍,给User表多造几条测试数据,然后自己写一个把动态SQL和resultMap结合起来的查询,这个过程走下来,MyBatis的骨架就算真正撑起来了。