1. 项目概述为什么“常用”二字值得深挖接手一个Spring Boot项目数据访问层十有八九会用到MyBatis。网上关于“Spring Boot整合MyBatis”的入门教程铺天盖地教你配个依赖、写个Mapper注解、跑通一个UserMapper的selectById就算大功告成。但当你真正开始写业务代码面对复杂的查询条件、动态的排序字段、需要批量操作的场景或者被安全扫描工具突然报出一个SQL注入漏洞时才会发现仅仅“整合”是远远不够的。真正的战场在于那些“常用”的、高频的、却又暗藏玄机的使用场景。这篇内容我们就跳过“Hello World”式的整合直接切入实战。聚焦在Spring Boot与MyBatis日常开发中那些你一定会遇到处理不好就可能埋下隐患的“常用”操作。比如动态SQL到底该用${}还是#{}为什么奇安信扫描会报警MyBatis-Plus的“存在则更新不存在则插入”到底是怎么实现的XML里如何优雅地处理ListString参数这些问题的背后不仅仅是API的调用更是对MyBatis原理、SQL安全、以及Spring Boot生态整合深度的理解。我结合自己这些年趟过的坑把这些高频、实用的知识点梳理出来目标很明确让你在项目中用MyBatis时不仅能用更能用得稳、用得安全、用得高效。2. 核心依赖与基础配置再审视很多教程止步于引入spring-boot-starter-web和mybatis-spring-boot-starter。但在一个追求稳定和效率的项目中我们需要看得更细。2.1 依赖选型稳定版与功能增强对于生产环境我强烈建议锁定稳定版本而不是使用latest。以当前主流版本为例parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version !-- 选用一个长期支持版本 -- relativePath/ /parent dependencies dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version !-- 分页插件非常常用 -- /dependency !-- 数据库驱动根据实际情况选择 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency /dependencies注意pagehelper是一个极其常用的分页插件它能以极简的方式实现物理分页避免内存分页的性能问题。引入它的starter可以免去大量XML配置。2.2 配置文件中的关键项application.yml里的配置远不止一个url和password。以下几个配置直接影响着开发体验和运行时行为mybatis: # 重要指定mapper.xml文件的位置。如果和Mapper接口在同一目录可省略。 # 但通常建议分离利于管理和打包。 mapper-locations: classpath:mapper/*.xml # 重要配置类型别名包这样在xml里就可以用类名代替全限定名。 type-aliases-package: com.yourdomain.project.entity configuration: # 开启驼峰命名自动映射。数据库字段是user_name实体类是userName靠这个实现自动匹配。 map-underscore-to-camel-case: true # 打印执行的SQL语句及其参数。开发环境必开生产环境务必关闭 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 全局性地启用二级缓存需要实体类实现Serializable。通常按需在mapper.xml中配置。 # cache-enabled: true # PageHelper分页插件配置 pagehelper: helper-dialect: mysql # 指定数据库方言 reasonable: true # 分页参数合理化。pageNum0时查询第一页pageNum总页数时查询最后一页。 support-methods-arguments: true # 支持通过Mapper接口参数来传递分页参数这里有个实操心得关于mapper-locations。如果你的项目结构是Maven标准的src/main/resources那么XML文件放在resources/mapper/下是安全的。但如果你把XML放在src/main/java的某个包内必须确保Maven的pom.xml中配置了resources将.xml文件包含进编译输出否则打包后会找不到XML导致Invalid bound statement异常。我个人的习惯是始终将XML放在resources目录下清晰分离。3. 动态SQL安全与灵活的博弈动态SQL是MyBatis的灵魂功能也是安全漏洞的重灾区。热搜词里提到了“mybatis 动态sql 使用${}”和“奇安信安全扫描报sql注入漏洞”这两者直接相关。3.1#{}与${}的本质区别这是MyBatis面试的必考题但很多人只知其然。#{}预编译占位符MyBatis会将其替换为JDBC的?然后使用PreparedStatement来赋值。这个过程会对传入的参数进行转义和处理从根本上防止了SQL注入。它适用于传入值的场景比如where id #{id}。${}字符串拼接MyBatis会将其内容直接替换到SQL语句中是纯粹的字符串拼接。如果替换的内容来自用户输入且未经验证就会导致SQL注入。它适用于传入SQL语句片段的场景比如动态表名、动态排序字段。3.2 动态SQL标签的安全使用MyBatis提供了if,choose,where,set,foreach等标签来构建动态SQL。安全示例多条件查询select idselectByCondition resultTypeUser SELECT * FROM user where !-- 使用 #{} 安全地传入值 -- if testusername ! null and username ! AND username LIKE CONCAT(%, #{username}, %) /if if teststatus ! null AND status #{status} /if if testroleIds ! null and roleIds.size() 0 AND role_id IN foreach collectionroleIds itemroleId open( separator, close) #{roleId} !-- 这里也必须用 #{} -- /foreach /if /where !-- 动态排序此处是${}的合理使用场景但必须确保orderField是白名单 -- if testorderField ! null and orderField ! ORDER BY ${orderField} if testorderDirection ! null and orderDirection ! ${orderDirection}/if /if /select危险示例导致SQL注入!-- 错误直接将用户输入拼接到LIKE中 -- if testkeyword ! null AND username LIKE %${keyword}% /if !-- 攻击者可以传入keyword值为 OR 11导致整个WHERE条件被绕过 --重要提示对于${}的使用必须建立严格的白名单机制。例如对于动态排序字段orderField在Java代码中先进行校验只允许“create_time”、“username”等有限的几个字段绝不能直接将前端传过来的字符串未经处理就放入${}中。奇安信等安全扫描工具核心就是检测这种直接将参数拼接到SQL中的模式。3.3 处理ListString等集合参数热搜词里有“mybatis xml传list 参数写法”这非常常见。关键在于foreach标签的collection属性。接口方法ListUser selectByUsernames(Param(nameList) ListString usernames);XML映射select idselectByUsernames resultTypeUser SELECT * FROM user WHERE username IN foreach collectionnameList itemname open( separator, close) #{name} /foreach !-- 判断集合是否为空可以在Java层做也可以在XML层做 -- if testnameList null or nameList.size() 0 AND 10 !-- 如果集合为空添加一个永假条件避免语法错误 -- /if /select注意事项collection的值“nameList”必须与Param注解指定的名称一致。如果不使用Param当参数只有一个集合时collection可以写为“list”对于List或“array”对于数组但为了清晰我强烈推荐始终使用Param。在foreach内部使用#{item}来引用当前元素同样是安全的预编译方式。对于空集合的处理上述AND 10是一种方式另一种更优雅的方式是在Java服务层进行判断如果集合为空直接返回空结果避免执行SQL。4. 与MyBatis-Plus的协同提升开发效率MyBatis-PlusMP是国内最流行的MyBatis增强工具它“为简化开发而生”。热搜词中提到了“mybatis plus 存在则跳过 不存在则新增”这正是MP提供的强大功能之一。4.1 快速集成与基础CRUD引入MP后你的Mapper接口只需继承BaseMapperT即可获得几乎所有的单表CRUD方法无需编写对应的XML。Repository public interface UserMapper extends BaseMapperUser { // 无需定义 selectById, insert, updateById, deleteById, selectList等方法 // 可以在此定义自己的复杂查询方法 }在Service层MP也提供了IService和ServiceImpl模板进一步封装。public interface IUserService extends IServiceUser { // 自定义业务方法 } Service public class UserServiceImpl extends ServiceImplUserMapper, User implements IUserService { // 可以直接使用 baseMapper 和一系列lambda查询方法 }4.2 “存在则更新不存在则插入”的实现这个需求对应MP的saveOrUpdate或saveOrUpdateBatch方法。其原理是根据实体类的主键字段默认是id进行判断。如果主键有值非null且非空取决于主键生成策略MP会先执行一次selectById查询。如果查询到记录则执行updateById如果未查询到则执行insert。使用示例User user new User(); user.setId(1L); // 如果id1的记录存在则更新否则新增 user.setUsername(newName); user.setEmail(newemail.com); // 单条操作 userService.saveOrUpdate(user); // 批量操作效率更高 ListUser userList ...; userService.saveOrUpdateBatch(userList);避坑指南主键策略确保你的实体类主键字段通常是id使用了正确的注解如TableId(type IdType.ASSIGN_ID)雪花ID或TableId(type IdType.AUTO)数据库自增。如果主键策略是AUTO你想“更新”一个id为null的记录MP会将其识别为“新增”。并发问题saveOrUpdate不是原子操作先查后改/增在高并发场景下可能存在“先查无后另一线程插入再本线程插入”导致的唯一键冲突风险。对于严格防重的场景应使用数据库的ON DUPLICATE KEY UPDATEMySQL或MERGE INTOOracle语句这可以通过MP的自定义SQL或insertOrUpdate方法需配置实现。4.3 Lambda查询与更新MP的Lambda查询链式API极大地提升了代码的可读性和类型安全性。// 查询username包含‘Tom’且状态为启用的用户列表 ListUser list userService.lambdaQuery() .like(User::getUsername, Tom) .eq(User::getStatus, 1) .list(); // 更新将指定用户的邮箱批量置为null boolean updated userService.lambdaUpdate() .in(User::getId, Arrays.asList(1L, 2L, 3L)) .set(User::getEmail, null) .update(); // 存在则更新另一种写法根据非主键字段判断 boolean saved userService.lambdaUpdate() .eq(User::getUsername, uniqueName) .set(User::getEmail, newmail.com) .update(); // 先尝试更新 if (!saved) { // 如果更新影响行数为0说明记录不存在 User newUser new User(); newUser.setUsername(uniqueName); newUser.setEmail(newmail.com); userService.save(newUser); // 执行插入 }5. 高级应用与性能优化5.1 分页查询的正确姿势使用PageHelper可以极其方便地实现物理分页。// 在查询方法前调用PageHelper.startPage之后的第一个MyBatis查询方法会被分页 GetMapping(/users) public PageInfoUser getUsers(RequestParam int pageNum, RequestParam int pageSize) { // 参数合理化已开启pageNum0会查第一页pageNum过大查最后一页 PageHelper.startPage(pageNum, pageSize); // 紧接着的查询会自动分页 ListUser userList userMapper.selectByCondition(new UserQuery()); // 用PageInfo包装结果包含总记录数、总页数等详细信息 return new PageInfo(userList); }关键点PageHelper.startPage(pageNum, pageSize)必须紧挨着执行SQL的Mapper方法调用之前中间不能有其它数据库查询操作否则分页会失效。5.2 一级缓存与二级缓存一级缓存SqlSession级别默认开启。在同一个SqlSession通常对应一次数据库会话/事务中执行相同的SQL和参数会直接从缓存返回结果。在Spring中由于通常将SqlSession作用域配置为“请求”级别且结合事务一级缓存容易引起“脏读”。例如在同一个事务中你先查询了一条数据然后另一个方法修改了这条数据但未提交你再次查询可能读到旧数据。我的建议是对于需要强一致性的查询可以在Mapper方法上添加Options(flushCache Options.FlushCachePolicy.TRUE)来禁用缓存。二级缓存Mapper级别需要显式开启。在mybatis-config.xml或application.yml中配置cache-enabled: true并在具体的Mapper.xml中添加cache/标签。二级缓存是跨SqlSession的数据以序列化形式存储因此实体类必须实现Serializable接口。二级缓存的问题更多数据一致性难保证除非整个应用只有一台服务、缓存更新策略复杂、容易引发脏数据。在分布式、微服务架构下我几乎从不使用MyBatis自带的二级缓存而是采用集中式的缓存方案如Redis。5.3 复杂结果映射association与collection处理一对一、一对多的关联查询时MyBatis的ResultMap非常强大。!-- 查询订单及其用户信息一对一 -- resultMap idOrderDetailMap typeOrder id propertyid columnorder_id/ result propertyorderNo columnorder_no/ !-- association 映射单个关联对象 -- association propertyuser javaTypeUser id propertyid columnuser_id/ result propertyusername columnusername/ /association !-- collection 映射集合关联对象 -- collection propertyitems ofTypeOrderItem id propertyid columnitem_id/ result propertyproductName columnproduct_name/ result propertyquantity columnquantity/ /collection /resultMap select idselectOrderWithDetails resultMapOrderDetailMap SELECT o.id as order_id, o.order_no, u.id as user_id, u.username, oi.id as item_id, oi.product_name, oi.quantity FROM order o LEFT JOIN user u ON o.user_id u.id LEFT JOIN order_item oi ON o.id oi.order_id WHERE o.id #{orderId} /selectN1查询问题上述是一个联表查询。另一种方式是分两次查询先在select中查订单然后在association或collection中通过select属性执行另一个Mapper查询。这种方式简单但可能导致“N1”问题查1个订单再查N个订单项。联表查询单SQL效率更高但结果映射复杂分次查询多SQL逻辑清晰但可能有性能问题。需要根据数据量和复杂度权衡。6. 常见问题排查与实战技巧6.1 典型异常与解决方案Invalid bound statement (not found)原因99%MyBatis找不到对应的Mapper XML文件。排查检查application.yml中的mybatis.mapper-locations路径是否正确。检查Maven打包后XML文件是否在jar包的对应目录下。可以在target/classes下查看。确认XML文件中的namespace是否与Mapper接口的全限定名完全一致。确认XML中的id是否与接口方法名一致。Cause: java.sql.SQLSyntaxErrorException: Unknown column ‘xxx’ in ‘field list’原因实体类字段与数据库列名映射失败。排查检查是否开启了map-underscore-to-camel-case。如果数据库列是user_name实体字段是userName这个配置会帮你自动映射。如果列名和字段名差异不遵循驼峰规则需要使用Column注解如果你用了JPA注解或在MyBatis的resultMap中显式指定result columndb_column propertyentityField/。查询结果某字段为null原因除了数据本身为null最常见的是映射失败。排查开启MyBatis的SQL日志log-impl: StdOutImpl查看实际查询返回的列名与实体类字段或resultMap中的配置逐一核对。注意大小写和空格。6.2 日志与调试将mybatis.configuration.log-impl设置为StdOutImpl可以在控制台看到完整的SQL执行过程包括预编译的SQL语句和最终传入的参数。这是调试动态SQL、排查传参问题最直接有效的方法。-- 日志输出示例 Preparing: SELECT * FROM user WHERE username LIKE CONCAT(%, ?, %) AND status ? Parameters: Tom(String), 1(Integer)6.3 在Spring Boot中运行初始化SQL脚本如果你需要在应用启动时创建表或初始化数据Spring Boot提供了很好的支持。spring: sql: init: mode: always # 或 embedded仅内存数据库时 schema-locations: classpath:schema.sql # 建表DDL >dependency groupIdorg.flowable/groupId artifactIdflowable-spring-boot-starter/artifactId exclusions exclusion groupIdorg.mybatis/groupId artifactIdmybatis/artifactId /exclusion exclusion groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId /exclusion /exclusions /dependency配置隔离确保你的MyBatis配置如SqlSessionFactory与Flowable使用的配置是分开的可以通过创建不同的Bean并指定不同的DataSource或MapperScannerConfigurer的扫描包路径来实现。整合Spring Boot与MyBatis入门容易但要用到生产级的“常用”水准就需要在这些细节上反复打磨。从安全的动态SQL编写到高效的MyBatis-Plus使用再到复杂的关联映射和问题排查每一个环节都考验着开发者对这两个框架的理解深度。记住框架是工具清晰的思路和对原理的把握才是写出稳健、高效代码的关键。多看看日志多思考SQL的执行过程很多问题都会迎刃而解。
Spring Boot整合MyBatis实战:动态SQL安全、MyBatis-Plus高效操作与生产级配置详解
1. 项目概述为什么“常用”二字值得深挖接手一个Spring Boot项目数据访问层十有八九会用到MyBatis。网上关于“Spring Boot整合MyBatis”的入门教程铺天盖地教你配个依赖、写个Mapper注解、跑通一个UserMapper的selectById就算大功告成。但当你真正开始写业务代码面对复杂的查询条件、动态的排序字段、需要批量操作的场景或者被安全扫描工具突然报出一个SQL注入漏洞时才会发现仅仅“整合”是远远不够的。真正的战场在于那些“常用”的、高频的、却又暗藏玄机的使用场景。这篇内容我们就跳过“Hello World”式的整合直接切入实战。聚焦在Spring Boot与MyBatis日常开发中那些你一定会遇到处理不好就可能埋下隐患的“常用”操作。比如动态SQL到底该用${}还是#{}为什么奇安信扫描会报警MyBatis-Plus的“存在则更新不存在则插入”到底是怎么实现的XML里如何优雅地处理ListString参数这些问题的背后不仅仅是API的调用更是对MyBatis原理、SQL安全、以及Spring Boot生态整合深度的理解。我结合自己这些年趟过的坑把这些高频、实用的知识点梳理出来目标很明确让你在项目中用MyBatis时不仅能用更能用得稳、用得安全、用得高效。2. 核心依赖与基础配置再审视很多教程止步于引入spring-boot-starter-web和mybatis-spring-boot-starter。但在一个追求稳定和效率的项目中我们需要看得更细。2.1 依赖选型稳定版与功能增强对于生产环境我强烈建议锁定稳定版本而不是使用latest。以当前主流版本为例parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version !-- 选用一个长期支持版本 -- relativePath/ /parent dependencies dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version !-- 分页插件非常常用 -- /dependency !-- 数据库驱动根据实际情况选择 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency /dependencies注意pagehelper是一个极其常用的分页插件它能以极简的方式实现物理分页避免内存分页的性能问题。引入它的starter可以免去大量XML配置。2.2 配置文件中的关键项application.yml里的配置远不止一个url和password。以下几个配置直接影响着开发体验和运行时行为mybatis: # 重要指定mapper.xml文件的位置。如果和Mapper接口在同一目录可省略。 # 但通常建议分离利于管理和打包。 mapper-locations: classpath:mapper/*.xml # 重要配置类型别名包这样在xml里就可以用类名代替全限定名。 type-aliases-package: com.yourdomain.project.entity configuration: # 开启驼峰命名自动映射。数据库字段是user_name实体类是userName靠这个实现自动匹配。 map-underscore-to-camel-case: true # 打印执行的SQL语句及其参数。开发环境必开生产环境务必关闭 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 全局性地启用二级缓存需要实体类实现Serializable。通常按需在mapper.xml中配置。 # cache-enabled: true # PageHelper分页插件配置 pagehelper: helper-dialect: mysql # 指定数据库方言 reasonable: true # 分页参数合理化。pageNum0时查询第一页pageNum总页数时查询最后一页。 support-methods-arguments: true # 支持通过Mapper接口参数来传递分页参数这里有个实操心得关于mapper-locations。如果你的项目结构是Maven标准的src/main/resources那么XML文件放在resources/mapper/下是安全的。但如果你把XML放在src/main/java的某个包内必须确保Maven的pom.xml中配置了resources将.xml文件包含进编译输出否则打包后会找不到XML导致Invalid bound statement异常。我个人的习惯是始终将XML放在resources目录下清晰分离。3. 动态SQL安全与灵活的博弈动态SQL是MyBatis的灵魂功能也是安全漏洞的重灾区。热搜词里提到了“mybatis 动态sql 使用${}”和“奇安信安全扫描报sql注入漏洞”这两者直接相关。3.1#{}与${}的本质区别这是MyBatis面试的必考题但很多人只知其然。#{}预编译占位符MyBatis会将其替换为JDBC的?然后使用PreparedStatement来赋值。这个过程会对传入的参数进行转义和处理从根本上防止了SQL注入。它适用于传入值的场景比如where id #{id}。${}字符串拼接MyBatis会将其内容直接替换到SQL语句中是纯粹的字符串拼接。如果替换的内容来自用户输入且未经验证就会导致SQL注入。它适用于传入SQL语句片段的场景比如动态表名、动态排序字段。3.2 动态SQL标签的安全使用MyBatis提供了if,choose,where,set,foreach等标签来构建动态SQL。安全示例多条件查询select idselectByCondition resultTypeUser SELECT * FROM user where !-- 使用 #{} 安全地传入值 -- if testusername ! null and username ! AND username LIKE CONCAT(%, #{username}, %) /if if teststatus ! null AND status #{status} /if if testroleIds ! null and roleIds.size() 0 AND role_id IN foreach collectionroleIds itemroleId open( separator, close) #{roleId} !-- 这里也必须用 #{} -- /foreach /if /where !-- 动态排序此处是${}的合理使用场景但必须确保orderField是白名单 -- if testorderField ! null and orderField ! ORDER BY ${orderField} if testorderDirection ! null and orderDirection ! ${orderDirection}/if /if /select危险示例导致SQL注入!-- 错误直接将用户输入拼接到LIKE中 -- if testkeyword ! null AND username LIKE %${keyword}% /if !-- 攻击者可以传入keyword值为 OR 11导致整个WHERE条件被绕过 --重要提示对于${}的使用必须建立严格的白名单机制。例如对于动态排序字段orderField在Java代码中先进行校验只允许“create_time”、“username”等有限的几个字段绝不能直接将前端传过来的字符串未经处理就放入${}中。奇安信等安全扫描工具核心就是检测这种直接将参数拼接到SQL中的模式。3.3 处理ListString等集合参数热搜词里有“mybatis xml传list 参数写法”这非常常见。关键在于foreach标签的collection属性。接口方法ListUser selectByUsernames(Param(nameList) ListString usernames);XML映射select idselectByUsernames resultTypeUser SELECT * FROM user WHERE username IN foreach collectionnameList itemname open( separator, close) #{name} /foreach !-- 判断集合是否为空可以在Java层做也可以在XML层做 -- if testnameList null or nameList.size() 0 AND 10 !-- 如果集合为空添加一个永假条件避免语法错误 -- /if /select注意事项collection的值“nameList”必须与Param注解指定的名称一致。如果不使用Param当参数只有一个集合时collection可以写为“list”对于List或“array”对于数组但为了清晰我强烈推荐始终使用Param。在foreach内部使用#{item}来引用当前元素同样是安全的预编译方式。对于空集合的处理上述AND 10是一种方式另一种更优雅的方式是在Java服务层进行判断如果集合为空直接返回空结果避免执行SQL。4. 与MyBatis-Plus的协同提升开发效率MyBatis-PlusMP是国内最流行的MyBatis增强工具它“为简化开发而生”。热搜词中提到了“mybatis plus 存在则跳过 不存在则新增”这正是MP提供的强大功能之一。4.1 快速集成与基础CRUD引入MP后你的Mapper接口只需继承BaseMapperT即可获得几乎所有的单表CRUD方法无需编写对应的XML。Repository public interface UserMapper extends BaseMapperUser { // 无需定义 selectById, insert, updateById, deleteById, selectList等方法 // 可以在此定义自己的复杂查询方法 }在Service层MP也提供了IService和ServiceImpl模板进一步封装。public interface IUserService extends IServiceUser { // 自定义业务方法 } Service public class UserServiceImpl extends ServiceImplUserMapper, User implements IUserService { // 可以直接使用 baseMapper 和一系列lambda查询方法 }4.2 “存在则更新不存在则插入”的实现这个需求对应MP的saveOrUpdate或saveOrUpdateBatch方法。其原理是根据实体类的主键字段默认是id进行判断。如果主键有值非null且非空取决于主键生成策略MP会先执行一次selectById查询。如果查询到记录则执行updateById如果未查询到则执行insert。使用示例User user new User(); user.setId(1L); // 如果id1的记录存在则更新否则新增 user.setUsername(newName); user.setEmail(newemail.com); // 单条操作 userService.saveOrUpdate(user); // 批量操作效率更高 ListUser userList ...; userService.saveOrUpdateBatch(userList);避坑指南主键策略确保你的实体类主键字段通常是id使用了正确的注解如TableId(type IdType.ASSIGN_ID)雪花ID或TableId(type IdType.AUTO)数据库自增。如果主键策略是AUTO你想“更新”一个id为null的记录MP会将其识别为“新增”。并发问题saveOrUpdate不是原子操作先查后改/增在高并发场景下可能存在“先查无后另一线程插入再本线程插入”导致的唯一键冲突风险。对于严格防重的场景应使用数据库的ON DUPLICATE KEY UPDATEMySQL或MERGE INTOOracle语句这可以通过MP的自定义SQL或insertOrUpdate方法需配置实现。4.3 Lambda查询与更新MP的Lambda查询链式API极大地提升了代码的可读性和类型安全性。// 查询username包含‘Tom’且状态为启用的用户列表 ListUser list userService.lambdaQuery() .like(User::getUsername, Tom) .eq(User::getStatus, 1) .list(); // 更新将指定用户的邮箱批量置为null boolean updated userService.lambdaUpdate() .in(User::getId, Arrays.asList(1L, 2L, 3L)) .set(User::getEmail, null) .update(); // 存在则更新另一种写法根据非主键字段判断 boolean saved userService.lambdaUpdate() .eq(User::getUsername, uniqueName) .set(User::getEmail, newmail.com) .update(); // 先尝试更新 if (!saved) { // 如果更新影响行数为0说明记录不存在 User newUser new User(); newUser.setUsername(uniqueName); newUser.setEmail(newmail.com); userService.save(newUser); // 执行插入 }5. 高级应用与性能优化5.1 分页查询的正确姿势使用PageHelper可以极其方便地实现物理分页。// 在查询方法前调用PageHelper.startPage之后的第一个MyBatis查询方法会被分页 GetMapping(/users) public PageInfoUser getUsers(RequestParam int pageNum, RequestParam int pageSize) { // 参数合理化已开启pageNum0会查第一页pageNum过大查最后一页 PageHelper.startPage(pageNum, pageSize); // 紧接着的查询会自动分页 ListUser userList userMapper.selectByCondition(new UserQuery()); // 用PageInfo包装结果包含总记录数、总页数等详细信息 return new PageInfo(userList); }关键点PageHelper.startPage(pageNum, pageSize)必须紧挨着执行SQL的Mapper方法调用之前中间不能有其它数据库查询操作否则分页会失效。5.2 一级缓存与二级缓存一级缓存SqlSession级别默认开启。在同一个SqlSession通常对应一次数据库会话/事务中执行相同的SQL和参数会直接从缓存返回结果。在Spring中由于通常将SqlSession作用域配置为“请求”级别且结合事务一级缓存容易引起“脏读”。例如在同一个事务中你先查询了一条数据然后另一个方法修改了这条数据但未提交你再次查询可能读到旧数据。我的建议是对于需要强一致性的查询可以在Mapper方法上添加Options(flushCache Options.FlushCachePolicy.TRUE)来禁用缓存。二级缓存Mapper级别需要显式开启。在mybatis-config.xml或application.yml中配置cache-enabled: true并在具体的Mapper.xml中添加cache/标签。二级缓存是跨SqlSession的数据以序列化形式存储因此实体类必须实现Serializable接口。二级缓存的问题更多数据一致性难保证除非整个应用只有一台服务、缓存更新策略复杂、容易引发脏数据。在分布式、微服务架构下我几乎从不使用MyBatis自带的二级缓存而是采用集中式的缓存方案如Redis。5.3 复杂结果映射association与collection处理一对一、一对多的关联查询时MyBatis的ResultMap非常强大。!-- 查询订单及其用户信息一对一 -- resultMap idOrderDetailMap typeOrder id propertyid columnorder_id/ result propertyorderNo columnorder_no/ !-- association 映射单个关联对象 -- association propertyuser javaTypeUser id propertyid columnuser_id/ result propertyusername columnusername/ /association !-- collection 映射集合关联对象 -- collection propertyitems ofTypeOrderItem id propertyid columnitem_id/ result propertyproductName columnproduct_name/ result propertyquantity columnquantity/ /collection /resultMap select idselectOrderWithDetails resultMapOrderDetailMap SELECT o.id as order_id, o.order_no, u.id as user_id, u.username, oi.id as item_id, oi.product_name, oi.quantity FROM order o LEFT JOIN user u ON o.user_id u.id LEFT JOIN order_item oi ON o.id oi.order_id WHERE o.id #{orderId} /selectN1查询问题上述是一个联表查询。另一种方式是分两次查询先在select中查订单然后在association或collection中通过select属性执行另一个Mapper查询。这种方式简单但可能导致“N1”问题查1个订单再查N个订单项。联表查询单SQL效率更高但结果映射复杂分次查询多SQL逻辑清晰但可能有性能问题。需要根据数据量和复杂度权衡。6. 常见问题排查与实战技巧6.1 典型异常与解决方案Invalid bound statement (not found)原因99%MyBatis找不到对应的Mapper XML文件。排查检查application.yml中的mybatis.mapper-locations路径是否正确。检查Maven打包后XML文件是否在jar包的对应目录下。可以在target/classes下查看。确认XML文件中的namespace是否与Mapper接口的全限定名完全一致。确认XML中的id是否与接口方法名一致。Cause: java.sql.SQLSyntaxErrorException: Unknown column ‘xxx’ in ‘field list’原因实体类字段与数据库列名映射失败。排查检查是否开启了map-underscore-to-camel-case。如果数据库列是user_name实体字段是userName这个配置会帮你自动映射。如果列名和字段名差异不遵循驼峰规则需要使用Column注解如果你用了JPA注解或在MyBatis的resultMap中显式指定result columndb_column propertyentityField/。查询结果某字段为null原因除了数据本身为null最常见的是映射失败。排查开启MyBatis的SQL日志log-impl: StdOutImpl查看实际查询返回的列名与实体类字段或resultMap中的配置逐一核对。注意大小写和空格。6.2 日志与调试将mybatis.configuration.log-impl设置为StdOutImpl可以在控制台看到完整的SQL执行过程包括预编译的SQL语句和最终传入的参数。这是调试动态SQL、排查传参问题最直接有效的方法。-- 日志输出示例 Preparing: SELECT * FROM user WHERE username LIKE CONCAT(%, ?, %) AND status ? Parameters: Tom(String), 1(Integer)6.3 在Spring Boot中运行初始化SQL脚本如果你需要在应用启动时创建表或初始化数据Spring Boot提供了很好的支持。spring: sql: init: mode: always # 或 embedded仅内存数据库时 schema-locations: classpath:schema.sql # 建表DDL >dependency groupIdorg.flowable/groupId artifactIdflowable-spring-boot-starter/artifactId exclusions exclusion groupIdorg.mybatis/groupId artifactIdmybatis/artifactId /exclusion exclusion groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId /exclusion /exclusions /dependency配置隔离确保你的MyBatis配置如SqlSessionFactory与Flowable使用的配置是分开的可以通过创建不同的Bean并指定不同的DataSource或MapperScannerConfigurer的扫描包路径来实现。整合Spring Boot与MyBatis入门容易但要用到生产级的“常用”水准就需要在这些细节上反复打磨。从安全的动态SQL编写到高效的MyBatis-Plus使用再到复杂的关联映射和问题排查每一个环节都考验着开发者对这两个框架的理解深度。记住框架是工具清晰的思路和对原理的把握才是写出稳健、高效代码的关键。多看看日志多思考SQL的执行过程很多问题都会迎刃而解。