1. 项目概述与核心价值这块内容的核心就是解决一个很实际的问题怎么让 SpringBoot 项目和 MyBatis 顺利“牵手”。很多刚开始接触这套组合的朋友最直观的感受是“配置怎么这么多”“为什么我的 Mapper 接口扫不到”“明明 SQL 没问题但就是报错”。这很正常因为 SpringBoot 整合 MyBatis 并不是把两个依赖加进去就完事它涉及自动配置的触发条件、SqlSessionFactory 的创建方式、Mapper 扫描机制、事务管理等一系列隐含约定。把这些底层逻辑理清楚你在排查问题时就不会像无头苍蝇一样乱转。我最早接触这个整合是在一个内部管理系统上工期紧、文档少当时踩了不少坑。后来做过的项目多了发现只要把几个关键点吃透整个整合过程基本就是“一遍过”。这篇内容就是把我实际用过的方案、踩过的坑、验证过的配置全部梳理出来希望能帮你少走弯路。不管你是刚入门的新手还是已经写过一些 CRUD 但没系统研究过整合原理的开发者这都值得花几分钟看完。2. 整合前需要搞清楚的几个底层概念2.1 MyBatis 到底替我们省了什么在真正动手配置之前得先说清楚 MyBatis 的核心价值。它本质上是一个半自动化的 ORM 框架所谓“半自动”是指 SQL 仍然由你自己编写只是框架帮你完成了 JDBC 那些重复机械的活连接的获取与释放、PreparedStatement 的参数填充、ResultSet 到实体对象的映射、异常处理等等。相比 JPA/Hibernate 那种“全自动”的 ORMMyBatis 最吸引人的地方在于你对最终 SQL 有完全的控制权遇到复杂查询、多表关联、动态 SQL 时写起来非常顺手。举个例子在一个订单统计场景里你需要根据多个可选条件动态拼接查询语句。用 JPA 你需要写 Specification 或者 QueryDSL而 MyBatis 直接用where加if标签就能搞定而且写出来的逻辑任何懂 SQL 的人都看得懂。这个特性让 MyBatis 在老项目维护和新项目开发中都有很高的接受度。2.2 SpringBoot 自动配置的核心逻辑SpringBoot 的自动配置并不是什么黑魔法它核心依靠的是spring.factories或新版的AutoConfiguration.imports文件中声明的配置类。对于 MyBatis 来说SpringBoot 官方并没有提供整合包我们通常使用的是 MyBatis 团队自己出的mybatis-spring-boot-starter。这个 starter 里最重要的配置类是MybatisAutoConfiguration它对SqlSessionFactory和SqlSessionTemplate进行了自动装配。这个自动配置有一个触发条件你在类路径下引入了mybatis-spring-boot-starter并且项目里存在至少一个DataSourceBean。也就是说如果你只加了 MyBatis 依赖但没配置数据源SpringBoot 会直接告诉你找不到 DataSource启动直接失败。理解了这一点你就知道为什么整合的第一步永远是先把数据源搞定。2.3 SqlSessionFactory 和 SqlSessionTemplate 的角色SqlSessionFactory是所有 MyBatis 操作的老祖宗它负责读取配置文件、加载 Mapper 映射、创建 SqlSession。SqlSessionTemplate则是 Spring 整合后的一个线程安全的 SqlSession 代理它替你管理了 SqlSession 的生命周期还自动参与 Spring 的事务管理。这也是为什么在 SpringBoot 项目里你不需要自己写SqlSession sqlSession factory.openSession()这样的代码直接注入 Mapper 接口就能用底层就是靠 SqlSessionTemplate 实现的。理解这两个东西对排查问题特别有帮助。比如你遇到“Invalid bound statement (not found)”这个经典报错本质上就是 SqlSessionFactory 没有加载到对应的 Mapper XML 文件或者 Mapper 接口和 XML 的 namespace 不匹配。这类问题光看报错找不到原因但当你明白它是发生在 SqlSessionFactory 初始化阶段时就能快速锁定排查方向。3. 从零整合的完整配置方案3.1 依赖引入与版本选择这一步看似简单但版本问题最容易让人崩溃。我推荐的做法是如果你的项目是直接用 Spring Initializr 创建的SpringBoot 的父级依赖统一管理了常用组件的版本但 MyBatis 的 starter 不归 SpringBoot 管理需要自己指定版本。这里我用的是当前比较稳定的版本组合parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.5/version relativePath/ /parent 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 /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency /dependencies这里有几个细节值得注意。第一SpringBoot 3.x 对应的是mybatis-spring-boot-starter3.x 版本如果你用了 SpringBoot 2.x就要选 2.x 版本的 starter乱配的话经常会出现ClassNotFoundException或者NoSuchMethodError。第二MySQL 驱动我是用 SpringBoot 父依赖统一管理的版本是 8.x它完全兼容 MySQL 5.7 和 8.0 的数据库除非你有特殊要求不建议自己指定。3.2 application.yml 中的核心配置解析接下来是最容易出问题的地方。很多新手一上来就把配置写得很复杂其实最基础、最稳妥的配置只需要几行spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/demo_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: yourpassword mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.demo.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl逐项解释一下。driver-class-name是 MySQL 8.x 的驱动类老项目里常见的com.mysql.jdbc.Driver已经废弃了不要再用。url中必须带上serverTimezoneAsia/Shanghai否则在 8.x 驱动下会报时区错误。mapper-locations声明了 Mapper XML 文件的位置type-aliases-package则是让 XML 里的 resultType 可以直接写实体类的短名称不用写包全路径。map-underscore-to-camel-case开启后数据库字段create_time会自动映射到实体属性createTime省去大量手写映射的功夫。log-impl设置为 StdOutImpl 是因为在开发调试阶段可以直接在控制台看到 MyBatis 执行的完整 SQL 和参数实测非常好用。如果你用过 MyBatis 原生的mybatis-config.xml你会发现这里没有指定environment、transactionManager这些配置因为它们已经由 Spring 接管了。SpringBoot 会自动创建一个 SpringManagedTransactionFactory 来管理事务这也是整合后事务注解Transactional能正常工作的前提。3.3 Mapper 接口扫描的三种方式把 Mapper 接口注册到 Spring 容器里有几种常见方式。第一种是在启动类上直接加MapperScan注解SpringBootApplication MapperScan(com.example.demo.mapper) public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }第二种是在每一个 Mapper 接口上单独加Mapper注解这种方式在接口数量少的时候很直观但接口多了之后每加一个都要记得标注解容易漏掉。第三种是两者结合在启动类上用了MapperScan之后接口上就不需要再加Mapper了。我个人的习惯是统一用MapperScan原因很简单扫描路径一目了然新加一个 Mapper 接口时不用再单独标注。需要注意的是MapperScan的路径一定要写准确它支持扫不到就留空但如果你不小心写到了实体类的包路径下面启动时会因为扫描到了没有Mapper注解的类而报错。4. 核心环节实现与实战细节4.1 一个完整 CRUD 的落地过程为了让你更直观地看到整合效果我这里用一个最基础的“用户信息表”来演示完整的实现路径。首先建一张很简单的表CREATE TABLE user_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, email VARCHAR(100), create_time DATETIME );对应实体类public class UserInfo { private Long id; private String username; private String email; private Date createTime; // getter/setter 省略 }Mapper 接口public interface UserInfoMapper { UserInfo selectById(Long id); ListUserInfo selectList(); int insert(UserInfo user); int update(UserInfo user); int deleteById(Long id); }对应 XML 文件放到resources/mapper目录下?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.UserInfoMapper resultMap idBaseResultMap typecom.example.demo.entity.UserInfo id columnid propertyid/ result columnusername propertyusername/ result columnemail propertyemail/ result columncreate_time propertycreateTime/ /resultMap select idselectById resultMapBaseResultMap SELECT id, username, email, create_time FROM user_info WHERE id #{id} /select select idselectList resultMapBaseResultMap SELECT id, username, email, create_time FROM user_info /select insert idinsert useGeneratedKeystrue keyPropertyid INSERT INTO user_info(username, email, create_time) VALUES(#{username}, #{email}, #{createTime}) /insert update idupdate UPDATE user_info SET username #{username}, email #{email} WHERE id #{id} /update delete iddeleteById DELETE FROM user_info WHERE id #{id} /delete /mapper这里我特意用了resultMap而不是直接写resultType目的是让你看到显式的字段映射。虽然前面配置了map-underscore-to-camel-case之后create_time能自动映射到createTime但对复杂查询来说自定义 resultMap 是更可控的方案。实际开发中简单的单表查询用自动映射完全够用但多表关联、字段名与属性名差异较大时resultMap 几乎是必须的。4.2 动态 SQL 的实用写法MyBatis 最强大的功能之一就是动态 SQL。实际业务里搜索列表的过滤条件往往是可选的比如用户列表可能按用户名查询也可能按邮箱查询还可能是组合查询。这种场景用字符串拼接 SQL 非常容易出错但用 MyBatis 的where标签就很优雅select idselectByCondition resultMapBaseResultMap SELECT id, username, email, create_time FROM user_info where if testusername ! null and username ! AND username LIKE CONCAT(%, #{username}, %) /if if testemail ! null and email ! AND email #{email} /if /where ORDER BY id DESC /selectwhere标签有个聪明的地方它会自动去除第一个条件前面多余的AND或OR。就算你第一个条件不满足只出现第二个条件生成出来的 SQL 也是WHERE email ?而不是WHERE AND email ?。这个设计非常实用我再也没有因为拼接 SQL 时漏写空格或者多写 AND 而头疼了。4.3 事务机制与 MyBatis 的结合SpringBoot 整合 MyBatis 后事务处理变得异常简单你只需要在 Service 方法上加上Transactional注解即可。但有一个底层逻辑需要你清楚MyBatis 的 SqlSessionTemplate 是线程安全的而且它内部会自动判断当前是否存在 Spring 管理的事务。如果存在它就会复用当前事务上下文中绑定的 SqlSession如果不存在每次操作就会新开一个会话。我在一个转账场景里测试过这个机制。用户 A 扣款成功了但给用户 B 加款时抛了异常如果没有TransactionalA 的扣款不会被回滚。加上注解之后整个操作作为一个原子事务异常时完整回滚数据一致性得到保证。有一点必须注意Transactional是加在 public 方法上才有效且不能是同一个类内部的互调方法因为 Spring 的 AOP 代理不会拦截内部自调用。Service public class UserService { Transactional public void transferAmount(Long fromUserId, Long toUserId, BigDecimal amount) { userMapper.decreaseBalance(fromUserId, amount); userMapper.increaseBalance(toUserId, amount); } }4.4 注解方式与 XML 方式的取舍很多人会纠结到底用注解写 SQL 还是用 XML 写 SQL。我的建议是简单的增删改查可以用注解比如Select(SELECT * FROM user_info WHERE id #{id}) UserInfo selectById(Long id);但稍微复杂一点的动态 SQL除非你用script标签硬写否则可读性会非常差。XML 的优势在于 SQL 与 Java 代码分离动态 SQL 的标签结构清晰多人协作时不容易产生冲突。而且 XML 文件可以做到热加载配合 DevTools 的 restart不用重启应用就能生效调试效率高很多。还有一个隐藏的坑如果你同时用了注解和 XML且同一个方法在两个地方都写了 SQLXML 中的定义会覆盖注解中的定义。这个行为很隐蔽建议一个 Mapper 方法只使用一种 SQL 定义方式不要混用。5. 常见问题与排查技巧实录5.1 Mapper 接口扫描不到或包路径写错现象启动时报Consider defining a bean of type xxxMapper in your configuration.这个问题十有八九是MapperScan的路径没有覆盖到 Mapper 接口所在包或者扫到了但不认识。排查顺序是确认 Mapper 接口是否在MapperScan指定包的子包下。确认接口上是否有Mapper注解如果用了MapperScan就不需要。确认 IDEA 是否真正编译了接口可以查看target/classes下是否存在对应的.class文件。我遇到过最诡异的一次是代码完全没有问题但 Mapper 就是注入不进来。最终发现是 IDEA 的缓存问题执行mvn clean compile后项目重新编译问题就消失了。所以遇到这种“理论上不可能”的问题时先清理重新编译往往能省下大把时间。5.2 Invalid bound statement (not found) 报错现象Mapper 接口已经注入成功但调用任何方法都报类似org.apache.ibatis.binding.BindingException: Invalid bound statement (not found): com.example.demo.mapper.UserInfoMapper.selectById。这个报错的根源就是 SqlSessionFactory 初始化时没有把接口对应的 XML 映射文件绑定进去。常见原因有XML 文件没有放在mapper-locations对应的目录下。比如配置是classpath:mapper/*.xml但 XML 实际放在resources/mapper/的上一级或子目录。XML 文件的namespace与接口全限定名不一致。这个非常容易手滑多写一个字母或少写一个包名结果就是找不到。XML 文件的 id 与方法名不一致或者参数类型对不上。我的排查经验是先看启动日志中是否有“Parsed mapper file”相关的记录。如果日志里压根没提到某个 XML 文件说明那个文件根本没被加载如果加载了但调用时报 not found那基本就是 namespace 或 id 的问题了。5.3 时区与连接报错现象启动时提示The server time zone value Öйú±ê׼ʱ¼ä is unrecognized或者连接超时。这个是 MySQL 8.0 驱动导致的时区问题。解决方案是在数据库连接串中显式声明时区url: jdbc:mysql://localhost:3306/demo_db?serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrueallowPublicKeyRetrievaltrue也是很多新人在本地连接 MySQL 8.x 时容易缺的配置不加这个偶尔会报Public Key Retrieval is not allowed的错误。当然了生产环境建议走 SSL 连接useSSLtrue会更安全开发环境用 false 图方便。5.4 参数为 null 时 SQL 动态条件失效现象调用列表查询时明明条件没传值生成出来的 SQL 把空字符串作为条件了导致查不到数据。这个就是我前面提到的if test... ! null and ... ! 联合判断。很多新手只写了username ! null结果请求参数传来一个空字符串null判断通过了SQL 里多出一个AND username 当然查不出来。所以在开发时只要涉及用户输入的查询条件就要同时判 null 和空字符串。还有一种更简单的方式是把参数封装成一个查询对象在 Service 层统一把空字符串转成 null这样 XML 里只判 null 就足够。5.5 驼峰命名映射不生效现象数据库字段是create_time实体属性是createTime但查出来 createTime 是 null。其他字段都正常。这种问题通常是map-underscore-to-camel-case没生效。我建议你在配置时使用mybatis.configuration.map-underscore-to-camel-casetrue注意层级。有些人会写成mybatis.map-underscore-to-camel-casetrue这也是一开始集成时容易犯的错误。Starter 中配置项的正确层级要么在mybatis.configuration下要么在mybatis.configuration-properties里写错了自然不生效。5.6 生产环境的 SQL 日志泄露风险开发时开 SQL 日志好排查问题但生产环境一定记得把日志级别调为 warn 或 error建议使用像logback的分环境配置文件在application-prod.yml中覆盖mybatis: configuration: log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl这样可以避免 SQL 语句、参数值、返回结果通过日志泄露出去。尤其是一些敏感数据表哪怕只是某次 API 调试日志打多了迟早会出问题。6. 工程结构优化与扩展建议6.1 模块划分的参考思路项目做到后面Mapper 接口和 XML 文件多了如果没有规划查找和维护都是一场灾难。我通常会把 Mapper 组件按业务模块分包比如user、order、pay每个模块下建一个mapper子包放对应的接口和 XML 文件。MapperScan直接扫描到最外层包即可MapperScan(com.example.demo.modules) public class DemoApplication { // ... }当然这里有个细节MapperScan只能扫到接口但如果你扫描的包里面有普通接口它也会尝试注册成 Mapper可能会报错。为了稳妥我还是建议把 Mapper 接口集中放在一个统一的包路径下比如com.example.demo.mapper然后按模块建子包。6.2 分页插件与多数据源的引入时机等你的基础整合跑通之后最值得加的第一个插件是分页插件。以前写分页要自己拼 LIMIT 参数还得写 count 查询现在用 PageHelper 能大幅提升效率。但注意它的使用是有讲究的调用PageHelper.startPage(pageNum, pageSize)之后必须紧跟你要分页的查询语句中间不能插入其他数据库操作。这个插件底层是拦截 MyBatis 的执行器如果你在分页前额外执行了一个无关查询分页拦截器就作用于那个查询结果完全不对了。多数据源则是另一个大话题。如果你的项目确实有多个库的需求建议先确认是否真的需要因为多数据源的复杂度在于事务管理和 SqlSessionFactory 的隔离。简单场景下可以用 Spring 的抽象动态数据源但要注意事务只会绑定第一个数据源跨库事务是不能保证的。真要跨库事务最好引入分布式事务方案那是一个独立的领域这里就不展开讲了。6.3 代码生成器的取舍集成完成后手动写实体类和 Mapper XML 会非常枯燥。市面上的代码生成器有很多但我不建议直接套用那种一键生成全表所有业务代码的工具。原因很简单实际业务中表结构变更频繁生成出来的东西五花八门不够灵活。我更推荐只生成实体类和最基础的 Mapper 骨架动态 SQL 和业务查询尽量手动维护。毕竟 MyBatis 的优势就在于你能控制 SQL如果全让生成器代劳还不如直接用 JPA。7. 写在最后的个人实践心得整合 SpringBoot 与 MyBatis 这件事单独看只是一个依赖和配置的组合但真正决定项目上限的是你对底层机制的理解深度。我刚开始没太在意 SqlSessionFactory 的加载细节遇到报错就瞎试浪费了很多时间。后来习惯了从报错倒推配置项每次启动报错都会先看日志前面有没有关于 Mapper 加载的记录效率提升不是一点半点。另外一个很实用的经验是开发阶段一定要开启 SQL 日志。我见过太多人在控制台看不见 SQL全靠猜业务逻辑开发起来极度痛苦。把log-impl配好之后你不仅能看 SQL还能看到传入的参数和查询返回的行数绝大多数调试场景都无需再打断点。到了生产环境再关掉把日志交给系统自己处理即可。最后想对你说一句配置百遍不如实战一遍。建议你找一个简单的业务比如做一个带列表搜索和详情展示的用户模块亲自动手把整合流程完整走一遍。踩过几个坑之后你会对这套组合有种豁然开朗的感觉。后面再遇到多表查询、复杂分页、批量操作这些进阶场景心里就有底了。