1. 项目概述:从零上手MyBatis CRUD
如果你刚开始接触Java后端开发,尤其是在Spring Boot项目中操作数据库,那么MyBatis几乎是一个绕不开的名字。它不像JPA那样“全自动”,需要你手写SQL,但正是这份“手动挡”的掌控感,让很多开发者觉得心里踏实。我刚开始用的时候也觉得有点麻烦,配置文件一堆,XML里写SQL还得对字段名,但用熟了之后,你会发现它既灵活又强大,尤其是在处理复杂查询、动态SQL或者需要精细优化性能的场景下。
简单来说,MyBatis是一个优秀的持久层框架,它帮你把Java对象和数据库记录之间的映射关系管理起来。你只需要关注如何写好SQL,剩下的诸如参数设置、结果集封装这些繁琐的活,MyBatis都替你干了。今天我们就从一个最核心、最基础的场景切入——CRUD(增删改查)。别看这四个操作基础,里面涉及的配置、映射、参数传递和动态SQL,恰恰是理解MyBatis工作原理的钥匙。通过一个完整的实例,我会带你走一遍从环境搭建、配置编写到每个操作实现的完整流程,过程中穿插那些官方文档里不会写的“踩坑”经验和调试技巧。
2. 环境准备与项目初始化
2.1 技术选型与依赖引入
我们以一个典型的Spring Boot项目为例。现在初始化项目,无论是用IDEA的Spring Initializr还是官方的start.spring.io,在选择依赖时,核心就是两个:Spring Web(用于构建Web应用)和MyBatis Framework。这里有个细节,如果你用的是Spring Boot 3.x,要注意MyBatis Starter的版本兼容性。我习惯在pom.xml里显式指定一下版本,避免后续一些意想不到的问题。
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.3</version> <!-- 请根据你的Spring Boot版本选择 --> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>为什么是MySQL?因为它最常见,原理是相通的,换成PostgreSQL或者Oracle,只是驱动和连接参数不同。Lombok是为了简化实体类的Getter/Setter代码,让代码更清晰。
2.2 数据库与实体类设计
假设我们要操作一个用户表t_user。建表语句可以这样设计:
CREATE TABLE `t_user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', `username` varchar(64) NOT NULL COMMENT '用户名', `email` varchar(128) DEFAULT NULL COMMENT '邮箱', `age` int(11) DEFAULT NULL COMMENT '年龄', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';对应的Java实体类User.java:
import lombok.Data; import java.time.LocalDateTime; @Data public class User { private Long id; private String username; private String email; private Integer age; private LocalDateTime createTime; }这里有一个非常容易出错的点:数据库字段名和Java属性名的映射。默认情况下,MyBatis的自动映射是开启的,它要求数据库字段名(下划线风格,如create_time)和Java属性名(驼峰风格,如createTime)能够正确转换。如果你的数据库设计就是驼峰字段,或者你不想开启自动转换,就需要在配置或映射文件中明确指定。
2.3 核心配置文件解析
Spring Boot简化了配置,大部分设置可以在application.yml或application.properties中完成。我更喜欢用YAML,结构更清晰。
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: configuration: map-underscore-to-camel-case: true # 关键配置:开启自动驼峰命名转换 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 打印SQL日志,调试神器 mapper-locations: classpath:mapper/*.xml # 告诉MyBatis你的SQL映射文件在哪重点说下map-underscore-to-camel-case: true这个配置。它意味着MyBatis会自动将下划线命名的数据库字段(create_time)映射到驼峰命名的Java属性(createTime)上。如果不开启,查询结果中createTime属性就会是null,这是新手最常遇到的“坑”之一。另一个配置log-impl是调试必备,它会把MyBatis执行的SQL语句、传递的参数都打印到控制台,对于排查SQL错误、理解MyBatis行为有巨大帮助。
3. 核心映射器与接口定义
3.1 Mapper接口的设计哲学
MyBatis的核心思想之一,就是将数据访问接口与SQL实现解耦。我们首先定义一个UserMapper.java接口。这个接口并不包含任何实现代码,它只是声明了我们要对User实体进行哪些操作。
import org.apache.ibatis.annotations.Mapper; import java.util.List; @Mapper // 关键注解:Spring Boot需要这个来扫描并注册这个接口为Bean public interface UserMapper { // 插入用户,并返回自增主键 int insertUser(User user); // 根据ID更新用户信息 int updateUserById(User user); // 根据ID删除用户 int deleteUserById(Long id); // 根据ID查询单个用户 User selectUserById(Long id); // 查询所有用户 List<User> selectAllUsers(); // 根据用户名模糊查询 List<User> selectUsersByUsername(String username); }注意@Mapper注解,这是Spring Boot集成MyBatis后,让容器能识别并代理这个接口的关键。你可能会问,接口里的方法明明没有实现,怎么就能调用呢?这就是MyBatis的魔法——它会在运行时,根据方法名和对应的XML映射文件,动态生成接口的实现类。
3.2 XML映射文件的绑定规则
接口定义好了,SQL写在哪?答案是在同名的XML文件里。通常,我们在resources/mapper/目录下创建UserMapper.xml。接口的全限定名(包名+类名)必须与XML文件中namespace属性的值完全一致,这是它们能够绑定的唯一凭证。
<?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"> <mapper namespace="com.example.demo.mapper.UserMapper"> <!-- SQL定义在这里 --> </mapper>namespace就像是这个XML文件的“身份证”,MyBatis通过它找到对应的Java接口。接下来,XML文件内的每一个SQL片段(<insert>,<update>,<delete>,<select>),其id属性必须严格对应接口中的方法名。这种约定大于配置的方式,既清晰又减少了出错的可能。
4. CRUD操作实现详解
4.1 插入(Create)操作与主键回填
插入操作看似简单,但涉及一个非常实用的需求:获取数据库自动生成的主键(比如自增ID)。在UserMapper.xml中:
<insert id="insertUser" parameterType="User" useGeneratedKeys="true" keyProperty="id"> INSERT INTO t_user (username, email, age) VALUES (#{username}, #{email}, #{age}) </insert>这里有几个关键点:
parameterType="User":指明传入的参数类型是我们的实体类。在MyBatis中,parameterType有时可以省略,因为MyBatis可以通过反射推断出来,但显式声明更清晰。useGeneratedKeys="true":这是告诉MyBatis,数据库支持主键自动生成(如MySQL的AUTO_INCREMENT)。keyProperty="id":这是最关键的一步。它指定了将生成的主键值设置到参数对象(User user)的哪个属性上。执行完这个插入方法后,传入的user对象的id属性就会被自动赋值为数据库中新记录的主键值。
实操心得:
keyProperty的值一定要和实体类中的属性名一致,这里是id,而不是数据库字段名id。我曾经因为写成了keyProperty="userId"而调试了半天,发现主键始终没有回填。
4.2 查询(Read)操作与结果映射
查询是CRUD中最灵活的部分。我们先看根据ID查询单个用户:
<select id="selectUserById" parameterType="Long" resultType="User"> SELECT id, username, email, age, create_time FROM t_user WHERE id = #{id} </select>parameterType="Long":传入参数是基本包装类型Long。resultType="User":指定返回值类型。由于我们在全局配置中开启了驼峰转换,create_time字段会自动映射到createTime属性。如果没有开启,这里就需要使用更复杂的resultMap来手动定义映射关系。#{id}:这是MyBatis的参数占位符。它会将传入的Long类型参数安全地设置到SQL中,防止SQL注入。它和JDBC中的?占位符是类似的,但更强大。
查询所有用户和模糊查询:
<select id="selectAllUsers" resultType="User"> SELECT id, username, email, age, create_time FROM t_user </select> <select id="selectUsersByUsername" parameterType="String" resultType="User"> SELECT id, username, email, age, create_time FROM t_user WHERE username LIKE CONCAT('%', #{username}, '%') </select>在模糊查询中,我使用了CONCAT('%', #{username}, '%')来拼接%通配符。为什么不直接在Java代码里拼接好%张%再传进来?因为使用#{}占位符,MyBatis会对参数进行预编译处理,安全性更高。如果写成WHERE username LIKE '%${username}%',虽然也能运行,但${}是字符串替换,有SQL注入风险,强烈不推荐。
4.3 更新(Update)操作与动态SQL初探
更新操作通常需要根据实际情况更新部分字段,而不是全部。比如我们只想更新用户的邮箱和年龄,如果传过来的email为null就不更新这个字段。这就需要用到MyBatis强大的动态SQL功能。我们先看一个全量更新的简单版本:
<update id="updateUserById" parameterType="User"> UPDATE t_user SET username = #{username}, email = #{email}, age = #{age} WHERE id = #{id} </update>这个SQL会把所有字段都更新一遍,即使你只想改年龄,也必须提供完整的username和email,否则它们会被更新为null,这显然不符合需求。为了解决这个问题,我们需要引入<set>和<if>标签。
4.4 删除(Delete)操作
删除操作相对直接:
<delete id="deleteUserById" parameterType="Long"> DELETE FROM t_user WHERE id = #{id} </delete>这里需要注意的是,在实际业务中,删除操作往往不是物理删除(真从数据库抹掉),而是逻辑删除(用一个字段如is_deleted来标记)。逻辑删除的好处是数据可追溯、可恢复。如果要做逻辑删除,这里的SQL就变成了UPDATE t_user SET is_deleted = 1 WHERE id = #{id},对应的接口方法名和参数类型可能也需要调整。
5. 动态SQL实战:实现智能更新
5.1<if> 与 <set> 标签的配合使用
回到更新操作的问题。我们希望实现:只更新传入对象中不为null的字段。这就要用到动态SQL。改造后的updateUserById语句如下:
<update id="updateUserById" parameterType="User"> UPDATE t_user <set> <if test="username != null and username != ''"> username = #{username}, </if> <if test="email != null"> email = #{email}, </if> <if test="age != null"> age = #{age}, </if> </set> WHERE id = #{id} </update>我们来拆解一下:
<set>标签:它会智能地处理SQL语句中的SET部分。它会自动去掉最后一个多余的逗号。比如,如果只有age不为null,生成的SQL会是UPDATE t_user SET age = ? WHERE id = ?,而不会出现SET age = ?,这样的语法错误。<if>标签:test属性里是一个OGNL表达式,用于判断条件是否成立。test="username != null and username != ''"表示只有当username不为null且不是空字符串时,才包含username = #{username}这部分SQL。- 参数判断:对于字符串,我加上了
!= ''的判断,这是业务层面的考虑,你可能不希望用空字符串去覆盖数据库里原有的用户名。对于数值类型age,通常只判断!= null即可。
5.2 动态SQL的更多可能性
<if>和<set>只是动态SQL的冰山一角。MyBatis还提供了:
<where>:智能处理WHERE子句,自动去除开头的AND或OR,避免WHERE AND username = ?这样的错误。<choose>/<when>/<otherwise>:类似于Java中的switch-case,实现多条件选择。<foreach>:用于遍历集合,常用于IN查询或批量操作。 例如,一个多条件的用户搜索可能长这样:
<select id="selectUsersByCondition" parameterType="map" resultType="User"> SELECT * FROM t_user <where> <if test="username != null"> AND username LIKE CONCAT('%', #{username}, '%') </if> <if test="minAge != null"> AND age >= #{minAge} </if> <if test="maxAge != null"> AND age <= #{maxAge} </if> </where> </select>使用<where>标签后,即使第一个<if>条件不成立,生成的SQL也不会出现WHERE AND ...的错误,<where>标签会妥善处理。
6. Service层与Controller层调用
6.1 编写Service层业务逻辑
持久层(Mapper)只负责数据访问,业务逻辑应该放在Service层。创建一个UserService:
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.util.List; @Service public class UserService { @Resource // 或者使用@Autowired private UserMapper userMapper; public int addUser(User user) { // 这里可以加入业务校验,比如用户名是否已存在 return userMapper.insertUser(user); } public User getUserById(Long id) { return userMapper.selectUserById(id); } public List<User> getAllUsers() { return userMapper.selectAllUsers(); } public int updateUser(User user) { // 更新前可以先查询一下用户是否存在 if (userMapper.selectUserById(user.getId()) == null) { throw new RuntimeException("用户不存在"); } return userMapper.updateUserById(user); } public int deleteUser(Long id) { return userMapper.deleteUserById(id); } }Service层的作用是对多个Mapper操作进行组装,并添加业务规则。例如,在addUser方法中,理论上应该先调用Mapper检查用户名是否重复。在updateUser中,我们先做了一次查询,确保要更新的用户是存在的,这是一个简单的业务保障。
6.2 通过Controller暴露API
最后,我们通过一个RESTful风格的Controller将功能暴露给前端或其他服务。
import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/users") public class UserController { @Autowired private UserService userService; @PostMapping public String createUser(@RequestBody User user) { int result = userService.addUser(user); // 插入后,user对象的id已被回填 return result > 0 ? "创建成功,用户ID: " + user.getId() : "创建失败"; } @GetMapping("/{id}") public User getUser(@PathVariable Long id) { return userService.getUserById(id); } @GetMapping public List<User> getAllUsers() { return userService.getAllUsers(); } @PutMapping("/{id}") public String updateUser(@PathVariable Long id, @RequestBody User user) { user.setId(id); // 确保路径中的ID设置到对象中 int result = userService.updateUser(user); return result > 0 ? "更新成功" : "更新失败"; } @DeleteMapping("/{id}") public String deleteUser(@PathVariable Long id) { int result = userService.deleteUser(id); return result > 0 ? "删除成功" : "删除失败"; } }这样,一个完整的、基于MyBatis的CRUD后端服务就搭建起来了。你可以使用Postman或浏览器来测试这些API端点。
7. 高级特性与性能考量
7.1 结果映射(ResultMap)的进阶使用
当查询结果无法通过简单的resultType自动映射时,或者关联查询需要映射复杂对象时,就需要使用<resultMap>。例如,我们有一个更复杂的查询,需要将用户及其拥有的多个角色映射出来(假设存在一对多关系)。
<resultMap id="UserWithRolesResultMap" type="User"> <id property="id" column="id"/> <result property="username" column="username"/> <result property="email" column="email"/> <!-- 集合映射,一对多 --> <collection property="roleList" ofType="Role"> <id property="roleId" column="role_id"/> <result property="roleName" column="role_name"/> </collection> </resultMap> <select id="selectUserWithRolesById" resultMap="UserWithRolesResultMap"> SELECT u.*, r.role_id, r.role_name FROM t_user u LEFT JOIN user_role ur ON u.id = ur.user_id LEFT JOIN t_role r ON ur.role_id = r.role_id WHERE u.id = #{id} </select><resultMap>提供了最精细的映射控制。<id>标签指定主键,有助于提高性能(如缓存)。<collection>用于映射集合属性。这是处理复杂对象关系查询的标准做法。
7.2 分页查询的实现思路
分页是高频需求。在纯MyBatis环境(不借助MyBatis-Plus等插件)中,常见的分页方式有两种:
使用数据库方言的LIMIT语句(适用于MySQL、PostgreSQL等):
<select id="selectUsersByPage" resultType="User"> SELECT * FROM t_user ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} </select>接口方法定义为:
List<User> selectUsersByPage(@Param("offset") int offset, @Param("pageSize") int pageSize);。你需要手动计算offset = (pageNum - 1) * pageSize。使用RowBounds(逻辑分页): MyBatis提供了一个
RowBounds参数,它会在查询出所有结果后,在内存中进行分页。这种方式在数据量大时性能极差,因为它先查询所有数据到内存。不推荐在生产环境使用。
性能提示:对于大数据量的分页,
LIMIT offset, pageSize在offset非常大时(如翻到第10000页)也会很慢。业界常见的优化方案有:使用游标分页(记录上一页最后一条记录的ID)、或者使用覆盖索引优化。
7.3 缓存机制浅析
MyBatis内置了一级缓存和二级缓存。
- 一级缓存(SqlSession级别):默认开启。在同一个SqlSession中,执行两次相同的查询,第二次会直接从缓存中取数据,不会发SQL。但一旦执行了增删改操作,或者调用了
sqlSession.clearCache(),缓存就会清空。这个缓存生命周期很短,作用域小。 - 二级缓存(Mapper级别):需要手动在XML映射文件中通过
<cache/>标签开启。它的作用域是一个Mapper namespace,可以被多个SqlSession共享。这意味着在一个SqlSession中查询的数据,可以被另一个SqlSession读取到。二级缓存的使用需要非常谨慎,因为数据可能被多个会话修改,容易产生脏读。在分布式环境下,直接使用MyBatis的二级缓存会遇到数据一致性问题,通常我们会用Redis等集中式缓存来替代。
8. 常见问题排查与调试技巧
8.1 SQL日志打印与参数查看
这是调试MyBatis最有效的手段。我们在配置文件中已经设置了log-impl: org.apache.ibatis.logging.stdout.StdOutImpl。当执行一个查询时,控制台会打印出类似下面的信息:
==> Preparing: SELECT * FROM t_user WHERE id = ? ==> Parameters: 1(Long) <== Columns: id, username, email, age, create_time <== Row: 1, testUser, test@example.com, 25, 2023-10-27 10:00:00 <== Total: 1Preparing:显示MyBatis准备执行的SQL,?是占位符。Parameters:显示传入的参数值及其类型。这里能清晰看到id的值是1,类型是Long。Columns和Row:显示查询结果的列信息和具体数据行。 如果SQL执行报错,错误信息也会在这里清晰地打印出来,极大地方便了定位问题。
8.2 参数绑定失败与空值问题
问题描述:执行更新或插入时,明明对象属性有值,但SQL日志显示参数是null。排查思路:
- 检查XML中
#{}里的属性名是否与Java实体类的属性名完全一致,大小写敏感。 - 检查传入的参数对象本身是否为
null。 - 检查Service层调用Mapper时,参数是否正确传递。
- 对于可能为
null的基本类型(如int),在接口方法中应使用其包装类(如Integer),因为int无法为null,这可能导致动态SQL中的test="age != null"判断永远为真或为假,产生非预期行为。
8.3 映射失败:查出的字段值为null
问题描述:查询执行成功,但返回的对象中某些字段(特别是带下划线的字段)为null。解决方案:
- 确认驼峰命名转换已开启:检查
application.yml中mybatis.configuration.map-underscore-to-camel-case是否为true。 - 检查SQL语句的列名:确保SELECT语句中的列名与数据库中的列名一致。有时我们写了别名,但别名与属性名对不上。
- 使用ResultMap:如果自动映射解决不了,或者字段名和属性名差异巨大,就老老实实使用
<resultMap>进行显式、精确的映射。这是最根本的解决方案。
8.4 关于“MyBatis-Plus”的思考
在搜索热词里看到了mybatis plus,这里简单提一下。MyBatis-Plus(简称MP)是一个MyBatis的增强工具,它在不改变MyBatis核心的基础上,提供了大量便捷功能,比如:
- 强大的CRUD接口:通过继承
BaseMapper,无需编写XML,即可获得大部分单表CRUD方法。 - 条件构造器:通过
QueryWrapper、UpdateWrapper等,用Java代码流畅地构造复杂查询条件,避免了在XML中拼接动态SQL的繁琐。 - 分页插件:配置简单,支持多种数据库,分页逻辑统一。 对于新项目,尤其是业务以单表操作为主时,我非常推荐使用MyBatis-Plus,它能极大提升开发效率。但理解原生MyBatis的工作原理,是你能用好MP乃至排查MP复杂问题的基础。