1. 从零开始:为什么Mybatis依然是Java开发者的必修课?
如果你刚接触Java后端开发,或者正准备从JDBC、JDBCTemplate这类“原始”的数据访问方式升级,那么Mybatis绝对是你绕不开的一关。尽管现在Spring Data JPA和Mybatis-Plus等框架热度很高,但Mybatis本身作为一款半自动化的ORM框架,其核心价值在于它精准地平衡了灵活性与开发效率。它不像JPA那样试图用一套标准“封装”所有数据库操作,也不像纯手写SQL那样繁琐。Mybatis让你能完全掌控SQL,同时又帮你处理了结果集映射、参数绑定这些重复劳动。很多面试官喜欢问Mybatis的原理,不是因为它过时,恰恰是因为它的设计思想——通过接口与XML/注解的映射,将Java方法与SQL语句优雅地绑定——是理解很多现代框架底层逻辑的绝佳入口。
最近在社区里,关于Mybatis的讨论依然火热,从“mybatis配置打印”调试SQL,到“mybatis缓存”机制优化,再到“mybatis批量更新batch”处理性能问题,以及经典的“mybatis updatebyid更新值为null不更新问题”,每一个都是实战中高频出现的痛点。理解Mybatis的CRUD(增删改查),不仅仅是学会几个标签,更是掌握一套与数据库交互的“方言”和最佳实践。今天,我就以一个完整的实例为线索,带你从环境搭建、配置解读,到每一个CRUD操作的细节实现与避坑,彻底吃透Mybatis的基础核心。
2. 项目初始化与环境搭建:不止于Spring Boot Starter
很多人一上来就用spring-boot-starter-mybatis,这没问题,但如果你不清楚它背后帮你做了什么,一旦出问题就会很懵。我们从头捋一遍。
2.1 依赖引入:看清每一个Jar包的作用
创建一个标准的Maven项目,核心依赖不仅仅是Mybatis和数据库驱动。
<dependencies> <!-- Spring Boot Web Starter (非必须,但常见于Web项目) --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Mybatis Spring Boot Starter:核心集成依赖 --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.3</version> <!-- 请使用最新稳定版 --> </dependency> <!-- 数据库驱动,以MySQL 8为例 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- Lombok:简化实体类代码(可选但推荐) --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <!-- 单元测试 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>注意:
mybatis-spring-boot-starter这个包至关重要,它自动配置了SqlSessionFactory和SqlSessionTemplate,并开启了Mybatis的Mapper接口扫描。如果你用的是非Spring Boot项目,则需要手动配置这些Bean,过程要复杂得多。
2.2 核心配置解析:application.yml里的门道
在src/main/resources/application.yml中,基础的配置如下:
spring: datasource: url: jdbc:mysql://localhost:3306/mybatis_demo?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver mybatis: # 1. 指定Mapper XML文件的位置。这是最关键的配置之一。 mapper-locations: classpath:mapper/*.xml # 2. 指定实体类的包路径,用于简化XML中的resultType书写。 type-aliases-package: com.example.demo.entity # 3. 开启下划线转驼峰命名映射。数据库字段user_name会自动映射到实体属性userName。 configuration: map-underscore-to-camel-case: true # 4. 打印SQL语句到控制台,调试神器。对应热搜“mybatis配置打印” log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这里有几个实战经验:
mapper-locations:通常我们会把XML文件放在resources/mapper/目录下,保持和Java接口的包结构一致是良好习惯。type-aliases-package:配置后,在XML里写resultType="User"即可,不用写全类名com.example.demo.entity.User。log-impl:开发阶段强烈建议开启。除了StdOutImpl,还可以集成SLF4J(org.apache.ibatis.logging.slf4j.Slf4jImpl),让SQL日志融入项目的统一日志系统。看到热搜里很多人搜这个,就是因为这是排查SQL问题的第一把钥匙。
2.3 实体与表结构设计:定义清晰的领域模型
我们以一个简单的User用户表为例。先看数据库表结构:
CREATE TABLE `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', `name` varchar(100) NOT NULL COMMENT '姓名', `age` int(11) DEFAULT NULL COMMENT '年龄', `email` varchar(100) DEFAULT NULL COMMENT '邮箱', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';对应的Java实体类User.java:
package com.example.demo.entity; import lombok.Data; import java.time.LocalDateTime; @Data public class User { private Long id; private String name; private Integer age; private String email; private LocalDateTime createTime; // 注意:这里驼峰命名,对应表字段create_time }使用了Lombok的@Data注解,自动生成了getter、setter、toString等方法。这里的关键点是createTime属性,由于我们在配置中开启了map-underscore-to-camel-case,Mybatis会自动将数据库的create_time映射到createTime,无需额外注解。
3. Mapper层剖析:接口与XML的共生关系
Mybatis的核心映射就在这一层。它由两部分组成:一个Java接口(Mapper Interface)和一个XML映射文件(Mapper XML)。两者通过命名空间(namespace)绑定。
3.1 创建Mapper接口:定义数据契约
首先创建接口UserMapper.java:
package com.example.demo.mapper; import com.example.demo.entity.User; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import java.util.List; @Mapper // Spring Boot中,此注解标记该接口为Mybatis的Mapper,会被自动扫描并注入 public interface UserMapper { // 1. 插入用户,并返回自增主键 int insertUser(User user); // 2. 根据ID更新用户 int updateUserById(User user); // 3. 根据ID删除用户 int deleteUserById(@Param("id") Long id); // 4. 根据ID查询单个用户 User selectUserById(@Param("id") Long id); // 5. 查询所有用户 List<User> selectAllUsers(); // 6. 根据姓名模糊查询(演示动态SQL和参数传递) List<User> selectUsersByName(@Param("name") String name); }接口中的每一个方法,都对应着后续XML文件里的一个SQL语句标签。@Param注解用于给参数命名,在XML中可以通过#{name}来引用,当方法有多个参数时,这个注解是必须的。
3.2 编写Mapper XML:SQL的舞台
在resources/mapper/目录下创建UserMapper.xml。这是重头戏,我们一步步来。
<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <!-- namespace必须对应Mapper接口的全限定名 --> <mapper namespace="com.example.demo.mapper.UserMapper"> <!-- 定义可重用的SQL片段 --> <sql id="Base_Column_List"> id, name, age, email, create_time </sql> <!-- 1. 插入 (Create) --> <insert id="insertUser" parameterType="User" useGeneratedKeys="true" keyProperty="id"> INSERT INTO user (name, age, email) VALUES (#{name}, #{age}, #{email}) </insert> <!-- 2. 根据ID更新 (Update) --> <update id="updateUserById" parameterType="User"> UPDATE user <set> <if test="name != null and name != ''"> name = #{name}, </if> <if test="age != null"> age = #{age}, </if> <if test="email != null"> email = #{email}, </if> </set> WHERE id = #{id} </update> <!-- 3. 根据ID删除 (Delete) --> <delete id="deleteUserById"> DELETE FROM user WHERE id = #{id} </delete> <!-- 4. 根据ID查询 (Retrieve - Single) --> <select id="selectUserById" resultType="User"> SELECT <include refid="Base_Column_List"/> FROM user WHERE id = #{id} </select> <!-- 5. 查询所有 (Retrieve - List) --> <select id="selectAllUsers" resultType="User"> SELECT <include refid="Base_Column_List"/> FROM user </select> <!-- 6. 根据姓名模糊查询 (动态SQL示例) --> <select id="selectUsersByName" resultType="User"> SELECT <include refid="Base_Column_List"/> FROM user WHERE 1=1 <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> ORDER BY id DESC </select> </mapper>现在,我们来拆解这里面每一个关键点:
<insert>标签详解:
id="insertUser":必须与接口中的方法名insertUser完全一致。parameterType="User":传入参数的类型。因为我们配置了type-aliases-package,所以可以直接写User。useGeneratedKeys="true" keyProperty="id":这是处理数据库自增主键回填的关键配置。执行插入后,数据库生成的主键值会自动设置到传入的User对象的id属性中。这样,方法执行完,user.getId()就能拿到ID了。
<update>标签与动态SQL:这里使用了<set>和<if>标签实现动态更新,这直接关联到热搜问题“mybatis updatebyid更新值为null不更新问题”。
- 问题场景:如果你直接写
UPDATE user SET name=#{name}, age=#{age}, email=#{email} WHERE id=#{id},当age为null时,SQL会变成SET name=?, age=null, ...,这会将数据库中的age字段更新为NULL,这可能不是你想要的行为(你或许只想更新非空的字段)。 - 解决方案:使用
<set>+<if>标签。<set>标签会智能地处理SET子句末尾的逗号。<if>标签判断属性是否不为空,只有不为空的字段才会被加入到SET子句中。这样就实现了“选择性更新”,避免用null覆盖已有值。
<select>标签与结果映射:
resultType="User":告诉Mybatis将查询结果集的每一行数据,自动映射成一个User对象。这依赖于字段名(或通过map-underscore-to-camel-case转换后的属性名)与对象属性名的一致。<include refid="Base_Column_List"/>:引用上面定义的SQL片段,避免重复书写字段列表,提高可维护性。- 在
selectUsersByName中,使用了WHERE 1=1这个小技巧,是为了方便后面拼接<if>条件而不用处理AND开头的问题。当然,Mybatis也提供了<where>标签来更优雅地处理这个问题。
4. Service层与单元测试:让CRUD跑起来
光有Mapper还不够,我们需要在业务层调用它,并通过测试验证。
4.1 编写Service层
创建一个UserService接口及其实现:
package com.example.demo.service; import com.example.demo.entity.User; import java.util.List; public interface UserService { Long addUser(User user); boolean updateUser(User user); boolean deleteUser(Long id); User getUserById(Long id); List<User> getAllUsers(); List<User> getUsersByName(String name); }package com.example.demo.service.impl; import com.example.demo.entity.User; import com.example.demo.mapper.UserMapper; import com.example.demo.service.UserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; @Service public class UserServiceImpl implements UserService { @Autowired private UserMapper userMapper; @Override @Transactional // 声明事务 public Long addUser(User user) { int rows = userMapper.insertUser(user); if (rows > 0) { // 插入成功后,user对象的id已被回填 return user.getId(); } return null; } @Override @Transactional public boolean updateUser(User user) { if (user.getId() == null) { throw new IllegalArgumentException("用户ID不能为空"); } return userMapper.updateUserById(user) > 0; } @Override @Transactional public boolean deleteUser(Long id) { return userMapper.deleteUserById(id) > 0; } @Override public User getUserById(Long id) { return userMapper.selectUserById(id); } @Override public List<User> getAllUsers() { return userMapper.selectAllUsers(); } @Override public List<User> getUsersByName(String name) { return userMapper.selectUsersByName(name); } }Service层的作用是封装业务逻辑,并管理事务(通过@Transactional注解)。注意在addUser方法中,我们直接返回了user.getId(),这得益于插入配置中的useGeneratedKeys。
4.2 编写集成测试
在src/test/java下创建测试类UserMapperTest.java,使用Spring Boot的测试框架。
package com.example.demo; import com.example.demo.entity.User; import com.example.demo.mapper.UserMapper; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.annotation.Rollback; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.List; import static org.junit.jupiter.api.Assertions.*; @SpringBootTest // 启动完整的Spring应用上下文进行测试 @Transactional // 每个测试方法执行后回滚事务,不污染数据库 @Rollback(true) // 确保回滚 class UserMapperTest { @Autowired private UserMapper userMapper; @Test void testInsertAndSelect() { User user = new User(); user.setName("测试用户"); user.setAge(25); user.setEmail("test@example.com"); // createTime 由数据库自动生成,无需设置 // 测试插入 int insertRows = userMapper.insertUser(user); assertEquals(1, insertRows); assertNotNull(user.getId()); // 验证主键回填 // 测试根据ID查询 User fetchedUser = userMapper.selectUserById(user.getId()); assertNotNull(fetchedUser); assertEquals("测试用户", fetchedUser.getName()); assertEquals(25, fetchedUser.getAge()); // 数据库生成的createTime应该不为null assertNotNull(fetchedUser.getCreateTime()); } @Test void testUpdateUserById() { // 先插入一条数据 User user = new User(); user.setName("老名字"); user.setAge(30); userMapper.insertUser(user); Long userId = user.getId(); // 准备更新数据,只更新name,age设为null User updateUser = new User(); updateUser.setId(userId); updateUser.setName("新名字"); updateUser.setAge(null); // 故意设置为null int updateRows = userMapper.updateUserById(updateUser); assertEquals(1, updateRows); // 重新查询,验证更新结果 User updatedUser = userMapper.selectUserById(userId); assertEquals("新名字", updatedUser.getName()); // 关键验证:age字段应该保持原来的30,而不是被更新为null assertEquals(30, updatedUser.getAge()); // 这里会成功,因为我们的XML用了动态SQL } @Test void testDeleteUserById() { User user = new User(); user.setName("待删除用户"); userMapper.insertUser(user); int deleteRows = userMapper.deleteUserById(user.getId()); assertEquals(1, deleteRows); User deletedUser = userMapper.selectUserById(user.getId()); assertNull(deletedUser); } @Test void testSelectAllUsers() { // 可以先清空或确保环境,这里简单测试 List<User> users = userMapper.selectAllUsers(); // 至少不应该报错 assertNotNull(users); } @Test void testSelectUsersByName() { // 插入一些测试数据 User user1 = new User(); user1.setName("张三"); userMapper.insertUser(user1); User user2 = new User(); user2.setName("张四"); userMapper.insertUser(user2); User user3 = new User(); user3.setName("李五"); userMapper.insertUser(user3); List<User> zhangUsers = userMapper.selectUsersByName("张"); assertEquals(2, zhangUsers.size()); // 应该找到张三和张四 assertTrue(zhangUsers.stream().allMatch(u -> u.getName().contains("张"))); } }运行这些测试,如果全部通过,恭喜你,一个完整的Mybatis CRUD流程已经跑通了!控制台应该会打印出Mybatis执行的每一条SQL语句(因为我们配置了log-impl),这对于调试和理解执行过程非常有帮助。
5. 进阶话题与高频问题排查
掌握了基础CRUD后,我们来看看那些热搜词背后代表的高频问题和进阶用法。
5.1 分页查询的实现与误区
热搜里有“mybatis 分页 怎么设置能全查出来,是page设置成-1吗”和“mybatis 分页查询”。Mybatis本身不提供物理分页功能,它需要配合第三方分页插件(如PageHelper)或数据库方言(如MySQL的LIMIT)来实现。
不推荐的做法:有些人在使用某些插件时,发现设置pageNum=-1或pageSize=0可能会绕过插件拦截,导致查询全部数据。这是一种对插件行为的 hack,强烈不推荐,因为行为不确定,且破坏了分页的语义。
推荐的做法:
使用PageHelper(国内最流行):
<!-- 添加依赖 --> <dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>最新版本</version> </dependency>在Service或Mapper方法调用前,使用一行代码即可:
// pageNum: 页码,pageSize: 每页条数 PageHelper.startPage(pageNum, pageSize); List<User> userList = userMapper.selectAllUsers(); // 这个查询会被自动改写,加上LIMIT // 用PageInfo包装结果,获取分页信息 PageInfo<User> pageInfo = new PageInfo<>(userList);PageHelper会在执行SQL前,通过拦截器动态拼接
LIMIT语句。手写LIMIT(简单直接): 在Mapper接口和XML中手动处理,适合简单场景。
// Mapper接口 List<User> selectUsersByPage(@Param("offset") int offset, @Param("limit") int limit);<!-- Mapper XML --> <select id="selectUsersByPage" resultType="User"> SELECT <include refid="Base_Column_List"/> FROM user ORDER BY id DESC LIMIT #{offset}, #{limit} </select>计算
offset = (pageNum - 1) * pageSize。这种方式最透明,但需要自己计算总数和页码。
5.2 批量操作(Batch)性能优化
“mybatis批量更新batch”是性能优化常见问题。逐条执行UPDATE或INSERT效率极低。Mybatis提供了foreach标签来执行批量操作。
批量插入示例:
<insert id="batchInsertUsers" parameterType="java.util.List"> INSERT INTO user (name, age, email) VALUES <foreach collection="list" item="user" separator=","> (#{user.name}, #{user.age}, #{user.email}) </foreach> </insert>接口方法:int batchInsertUsers(@Param("list") List<User> userList);
批量更新示例(根据ID更新不同值): 这种情况较复杂,MySQL不支持UPDATE ... SET (name,age) VALUES (case1), (case2)...的标准语法。通常有两种方式:
- 在Java中循环,每条执行一次更新(性能差)。
- 使用
<foreach>拼接成多条SQL语句,通过;分隔,并需要允许JDBC执行多条语句(allowMultiQueries=true在连接字符串中),但有SQL注入风险,需谨慎。 - 使用
CASE WHEN语句(推荐):
这种方法能通过一条SQL更新多条记录,性能最好,但SQL语句会随着数据量增大而变长,需注意数据库对SQL长度的限制。<update id="batchUpdateUsers"> UPDATE user SET name = CASE id <foreach collection="list" item="user"> WHEN #{user.id} THEN #{user.name} </foreach> END, age = CASE id <foreach collection="list" item="user"> WHEN #{user.id} THEN #{user.age} </foreach> END WHERE id IN <foreach collection="list" item="user" open="(" separator="," close=")"> #{user.id} </foreach> </update>
5.3 缓存机制浅析
“mybatis缓存”是一个重要话题。Mybatis提供了一级缓存和二级缓存。
- 一级缓存(本地缓存/SqlSession级别):默认开启。在同一个
SqlSession(通常对应一次数据库会话/事务)中,执行相同的查询,第二次会直接从缓存返回结果,不会再次访问数据库。注意:一旦执行了增删改操作,或者调用了sqlSession.clearCache(),或者关闭了SqlSession,缓存就会清空。在Spring集成中,由于通常将SqlSession生命周期与事务绑定,一级缓存的作用范围可能比你想象的小。 - 二级缓存(全局缓存/namespace级别):需要手动在XML中配置
<cache/>标签开启。多个SqlSession可以共享二级缓存。使用需谨慎:因为数据可能被其他会话修改,导致缓存数据脏读。通常用于读远多于写、且对实时性要求不高的场景。开启后,实体类需要实现Serializable接口。
5.4 常见问题排查清单
结合热搜词,这里整理一个快速排查指南:
SQL语句不执行或报错:
- 检查
mapper-locations配置路径是否正确,XML文件是否被正确编译到target/classes下。 - 检查接口上的
@Mapper注解或启动类上的@MapperScan。 - 开启
log-impl查看控制台,确认SQL是否被打印,语法是否正确。
- 检查
查询结果映射失败(属性为null):
- 检查数据库字段名与实体属性名是否匹配,确认
map-underscore-to-camel-case是否开启。 - 检查XML中
resultType或resultMap是否正确。 - 使用
SELECT *临时测试,看是否是字段列表不匹配。
- 检查数据库字段名与实体属性名是否匹配,确认
参数绑定失败:
- 检查接口方法参数是否使用了
@Param注解(多参数时必须)。 - 检查XML中
#{paramName}里的paramName是否与@Param值或实体属性名一致。
- 检查接口方法参数是否使用了
更新时字段被意外设置为null:
- 回顾我们上面详细讲的动态SQL
<set>+<if>用法,确保你的更新语句使用了动态判断。
- 回顾我们上面详细讲的动态SQL
分页查询返回所有数据:
- 如果使用PageHelper,检查
PageHelper.startPage()是否在查询方法之前调用,且中间不能有别的查询语句。 - 确认分页插件版本与Mybatis、Spring Boot版本兼容。
- 如果使用PageHelper,检查
6. 从Mybatis到Mybatis-Plus:平滑演进的选择
热搜里“mybatis plus”和“mybatis plus basemapper.update”的出现频率很高,这反映了开发者对更高开发效率的追求。Mybatis-Plus(简称MP)是在Mybatis基础上只做增强不做改变的框架,它提供了强大的CRUD封装、条件构造器、代码生成器等。
如果你觉得手写每个Mapper XML还是有些繁琐,特别是对于单表的简单CRUD,那么MP的BaseMapper会让你眼前一亮。它内置了几乎所有单表方法:
// 继承BaseMapper后,自动拥有这些方法,无需写XML public interface UserMapper extends BaseMapper<User> { // 你可以继续在这里定义复杂的、需要自定义SQL的方法 } // Service层调用示例 userMapper.insert(user); // 插入 userMapper.updateById(user); // 根据ID更新(注意:默认是全字段更新,需配合@TableField策略控制null值) userMapper.deleteById(1L); // 根据ID删除 userMapper.selectById(1L); // 根据ID查询 userMapper.selectList(new QueryWrapper<User>().like("name", "张")); // 条件查询对于“mybatis plus basemapper.update”提到的更新问题,MP的updateById默认也是全字段更新。MP提供了@TableField(updateStrategy = FieldStrategy.IGNORED)等策略来控制字段的更新行为,例如忽略null值,这比在XML中写动态SQL更声明式。
如何选择?
- 坚持使用原生Mybatis:项目SQL极度复杂、高度定制化,或团队对SQL掌控要求极高,不希望有任何“魔法”。
- 引入Mybatis-Plus:项目以单表操作为主,追求快速开发,希望减少重复的CRUD代码。MP的条件构造器也能很好地处理复杂查询,同时保留了原生Mybatis的所有能力(你仍然可以写XML处理超级复杂的SQL)。
无论选择哪个,深入理解本章所讲的原生Mybatis核心机制,都是你用好MP或在复杂场景下游刃有余的基石。因为MP的很多便捷功能,其底层最终还是转换成了Mybatis的SqlSession操作和XML/注解映射。理解了根本,才能更好地驾驭工具。