
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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version3.0.3/version !-- 请根据你的Spring Boot版本选择 -- /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;对应的Java实体类User.javaimport 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?useUnicodetruecharacterEncodingutf-8useSSLfalseserverTimezoneAsia/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); // 查询所有用户 ListUser selectAllUsers(); // 根据用户名模糊查询 ListUser selectUsersByUsername(String username); }注意Mapper注解这是Spring Boot集成MyBatis后让容器能识别并代理这个接口的关键。你可能会问接口里的方法明明没有实现怎么就能调用呢这就是MyBatis的魔法——它会在运行时根据方法名和对应的XML映射文件动态生成接口的实现类。3.2 XML映射文件的绑定规则接口定义好了SQL写在哪答案是在同名的XML文件里。通常我们在resources/mapper/目录下创建UserMapper.xml。接口的全限定名包名类名必须与XML文件中namespace属性的值完全一致这是它们能够绑定的唯一凭证。?xml version1.0 encodingUTF-8? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.demo.mapper.UserMapper !-- SQL定义在这里 -- /mappernamespace就像是这个XML文件的“身份证”MyBatis通过它找到对应的Java接口。接下来XML文件内的每一个SQL片段insert,update,delete,select其id属性必须严格对应接口中的方法名。这种约定大于配置的方式既清晰又减少了出错的可能。4. CRUD操作实现详解4.1 插入Create操作与主键回填插入操作看似简单但涉及一个非常实用的需求获取数据库自动生成的主键比如自增ID。在UserMapper.xml中insert idinsertUser parameterTypeUser useGeneratedKeystrue keyPropertyid INSERT INTO t_user (username, email, age) VALUES (#{username}, #{email}, #{age}) /insert这里有几个关键点parameterTypeUser指明传入的参数类型是我们的实体类。在MyBatis中parameterType有时可以省略因为MyBatis可以通过反射推断出来但显式声明更清晰。useGeneratedKeystrue这是告诉MyBatis数据库支持主键自动生成如MySQL的AUTO_INCREMENT。keyPropertyid这是最关键的一步。它指定了将生成的主键值设置到参数对象User user的哪个属性上。执行完这个插入方法后传入的user对象的id属性就会被自动赋值为数据库中新记录的主键值。实操心得keyProperty的值一定要和实体类中的属性名一致这里是id而不是数据库字段名id。我曾经因为写成了keyPropertyuserId而调试了半天发现主键始终没有回填。4.2 查询Read操作与结果映射查询是CRUD中最灵活的部分。我们先看根据ID查询单个用户select idselectUserById parameterTypeLong resultTypeUser SELECT id, username, email, age, create_time FROM t_user WHERE id #{id} /selectparameterTypeLong传入参数是基本包装类型Long。resultTypeUser指定返回值类型。由于我们在全局配置中开启了驼峰转换create_time字段会自动映射到createTime属性。如果没有开启这里就需要使用更复杂的resultMap来手动定义映射关系。#{id}这是MyBatis的参数占位符。它会将传入的Long类型参数安全地设置到SQL中防止SQL注入。它和JDBC中的?占位符是类似的但更强大。查询所有用户和模糊查询select idselectAllUsers resultTypeUser SELECT id, username, email, age, create_time FROM t_user /select select idselectUsersByUsername parameterTypeString resultTypeUser 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 idupdateUserById parameterTypeUser UPDATE t_user SET username #{username}, email #{email}, age #{age} WHERE id #{id} /update这个SQL会把所有字段都更新一遍即使你只想改年龄也必须提供完整的username和email否则它们会被更新为null这显然不符合需求。为了解决这个问题我们需要引入set和if标签。4.4 删除Delete操作删除操作相对直接delete iddeleteUserById parameterTypeLong DELETE FROM t_user WHERE id #{id} /delete这里需要注意的是在实际业务中删除操作往往不是物理删除真从数据库抹掉而是逻辑删除用一个字段如is_deleted来标记。逻辑删除的好处是数据可追溯、可恢复。如果要做逻辑删除这里的SQL就变成了UPDATE t_user SET is_deleted 1 WHERE id #{id}对应的接口方法名和参数类型可能也需要调整。5. 动态SQL实战实现智能更新5.1if 与 set 标签的配合使用回到更新操作的问题。我们希望实现只更新传入对象中不为null的字段。这就要用到动态SQL。改造后的updateUserById语句如下update idupdateUserById parameterTypeUser UPDATE t_user set if testusername ! null and username ! username #{username}, /if if testemail ! null email #{email}, /if if testage ! 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表达式用于判断条件是否成立。testusername ! 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 idselectUsersByCondition parameterTypemap resultTypeUser SELECT * FROM t_user where if testusername ! null AND username LIKE CONCAT(%, #{username}, %) /if if testminAge ! null AND age #{minAge} /if if testmaxAge ! null AND age #{maxAge} /if /where /select使用where标签后即使第一个if条件不成立生成的SQL也不会出现WHERE AND ...的错误where标签会妥善处理。6. Service层与Controller层调用6.1 编写Service层业务逻辑持久层Mapper只负责数据访问业务逻辑应该放在Service层。创建一个UserServiceimport 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 ListUser 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 ListUser 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 idUserWithRolesResultMap typeUser id propertyid columnid/ result propertyusername columnusername/ result propertyemail columnemail/ !-- 集合映射一对多 -- collection propertyroleList ofTypeRole id propertyroleId columnrole_id/ result propertyroleName columnrole_name/ /collection /resultMap select idselectUserWithRolesById resultMapUserWithRolesResultMap 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} /selectresultMap提供了最精细的映射控制。id标签指定主键有助于提高性能如缓存。collection用于映射集合属性。这是处理复杂对象关系查询的标准做法。7.2 分页查询的实现思路分页是高频需求。在纯MyBatis环境不借助MyBatis-Plus等插件中常见的分页方式有两种使用数据库方言的LIMIT语句适用于MySQL、PostgreSQL等select idselectUsersByPage resultTypeUser SELECT * FROM t_user ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} /select接口方法定义为ListUser 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, testexample.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中的testage ! 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复杂问题的基础。