
简介一份面向Java开发者的Spring Boot与MyBatis整合实战指南适合正在搭建持久层、或需要快速上手XML Mapper配置的中级程序员阅读。资源以PDF文档形式从pom.xml引入mybatis、mybatis-spring及MySQL驱动等依赖开始依次讲解在application.properties和mybatis-config.xml中配置数据源与Mapper位置的方法并给出UserMapper接口、UserMapper.xml映射文件以及Service层注入调用的完整示例基本覆盖Spring Boot整合MyBatis的关键环节。文档中的代码片段均对应实际开发场景且对依赖版本、命名空间等易错点有明确提示可帮助读者减少环境配置和接口绑定方面的常见弯路。包内共1个PDF文件压缩包大小57KB内容紧凑、步骤清晰便于下载后快速查阅或对照实践。该资源已有453人学习下载适合作为整合前随时参考的简洁笔记。1. SpringBoot整合MyBatis为什么这件事值得你亲手做一遍很多人在简历上写着“熟悉SpringBoot整合MyBatis”但真到新环境里从零搭一个项目却迟迟跑不通。原因很简单网上教程大多数是“复制粘贴能跑”的片段遇到版本冲突、驼峰映射失效、XML没编译进去这些情况就完全抓瞎。SpringBoot 2.x之后整合MyBatis已经不需要写繁琐的配置类但“不需要写”不代表“不需要理解”——驱动注册、SQL会话工厂怎么建、Mapper扫描规则是什么这些依然是排查问题的底气。这篇文章就沿着一条主线走为什么要用MyBatis、依赖怎么引、配置怎么拆、代码怎么分层、XML和注解怎么取舍最后落到一组常见的报错和解决办法。适合两类人一是刚接触SpringBoot的开发者想跑通第一个带数据库的项目二是用过MyBatis但没系统梳理过整合流程的人借这篇文章补齐边界知识。跟着做一遍你会得到一个带完整CRUD的最小可运行项目之后在这个骨架上加逻辑就顺手多了。2. 整合前的三条关键决策版本、依赖、配置放哪2.1 版本选择先定SpringBoot版本再定MyBatis Starter版本SpringBoot整合MyBatis的第一步不是写代码而是把版本关系理顺。很多初学者习惯去MyBatis官网找最新的starter版本结果SpringBoot老版本不兼容报出各种莫名其妙的NoClassDefFoundError。常见做法是先用SpringBoot自带的依赖管理约束MyBatis Starter如果项目有特殊需求再去覆盖版本。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies !-- Web启动器提供Restful接口能力 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- MyBatis整合启动器 -- dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency !-- MySQL驱动 -- dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency /dependencies这段pom里有两个关键点。第一mybatis-spring-boot-starter的版本在SpringBoot 2.7.x下对应2.3.x系列如果升级到SpringBoot 3.x就要换3.0的starter并适配Jakarta命名空间。第二mysql-connector-j是MySQL 8.x的新坐标老写法mysql:mysql-connector-java在新版本里也能兼容但已进入维护期。实际项目里我一般先把SpringBoot版本定死然后去starter页面对照兼容矩阵不要闭着眼睛写latest。2.2 application.yml配置数据源、连接池、驼峰映射配置整合里最容易踩坑的就是数据源写法和驼峰映射开关。前者决定你能不能连上库后者决定你查出来的字段能不能正确填充到实体属性。SpringBoot 2.x默认数据源是HikariCP不需要额外引依赖只要把url、username、password写对就行。spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/demo_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: root123 hikari: maximum-pool-size: 10 minimum-idle: 5 connection-timeout: 30000 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.StdOutImplurl参数里serverTimezone必须带上否则MySQL 8.x会报时区错误characterEncodingutf8要比utf8mb4兼容性更稳但存储emoji的话需要单独调数据库字符集。mybatis.configuration下的map-underscore-to-camel-case是整合应用里最容易被忽略的一行数据库字段是user_name实体属性是userName不开这个开关查出来就是null。log-impl设成StdOutImpl开发阶段能看到完整SQL和参数排查问题效率翻倍。2.3 为什么不再需要SqlSessionFactoryBean配置类老Spring项目里整合MyBatis必须手工创建SqlSessionFactoryBean和MapperScannerConfigurerSpringBoot starter把这些都包成了AutoConfiguration。mybatis-spring-boot-starter在启动时会自动读取数据源配置创建SqlSessionFactory、SqlSessionTemplate并扫描启动类所在包及其子包下的Mapper接口。# 如果Mapper接口不在启动类子包下启动时报 # Invalid bound statement (not found)而不是连接错误报错字面意思是“找不到绑定语句”但根因往往是Mapper接口所在的包没被扫描到。解决办法有两种在启动类上加MapperScan(com.example.demo.mapper)或者给每个Mapper接口加Mapper注解。两者选一种就行加了MapperScan就不要重复在接口上写Mapper扫描两遍不影响结果但不够干净。清楚这个自动配置过程后遇到无法启动、找不到Bean的问题就不会一头扎进SQL里排查了。3. 手动跑通一个CRUD从建表到Controller的四层链路3.1 建表与实体类字段命名直接影响三处代码先建一张最简单但字段风格贴近真实业务的表。数据库设计时用下划线命名实体类用驼峰命名这正好用来验证前面说的map-underscore-to-camel-case配置是否生效。CREATE TABLE user_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_name VARCHAR(64) NOT NULL, age INT DEFAULT 0, email VARCHAR(128), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );对应实体类只保留数据库字段不要在实体类里塞业务字段。很多团队喜欢在实体类里加一个List 来放权限数据那是另一个查询场景CRUD最小模型里不用提前设计等真有需要再拆DTO就行。public class UserInfo { private Long id; private String userName; private Integer age; private String email; private LocalDateTime createdAt; private LocalDateTime updatedAt; // getter/setter省略IDEA快捷键自动生成即可 }注意age用了Integer而不是intemail用了对象类型而不是普通字符串变量。Java基础类型在MyBatis查不到值时会直接给null包装类型则能正确表达“数据库里不存在”和“值是0”的区别这在做条件更新时非常关键。3.2 Mapper接口与XML映射绑定关系的两种建立方式Mapper接口只是声明真正执行SQL的是XML文件或者注解里的SQL语句。接口里的方法名必须和XML中statement的id一一对应namespace必须写接口全限定名这两处不一致就是Invalid bound statement的另一个常见来源。public interface UserInfoMapper { UserInfo selectById(Long id); ListUserInfo selectAll(); int insert(UserInfo user); int updateById(UserInfo user); int deleteById(Long id); }XML文件放在src/main/resources/mapper目录下因为yaml里配置了mapper-locations指向classpath:/mapper/*.xml文件名通常和Mapper接口同名便于维护检索。文件头部的namespace、select标签的id、parameterType、resultType是四个最容易写错的地方。?xml version1.0 encodingUTF-8 ? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN https://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.demo.mapper.UserInfoMapper select idselectById resultTypecom.example.demo.entity.UserInfo SELECT * FROM user_info WHERE id #{id} /select select idselectAll resultTypecom.example.demo.entity.UserInfo SELECT * FROM user_info ORDER BY id DESC /select /mapper这里的resultType没有使用别名直接写了全限定类名。虽然type-aliases-package配置了实体包脚手架上能缩短成UserInfo但团队里有人不清楚这个配置时容易把内部类的简写搞混我习惯全限定名代价是字符多收益是IDE里Ctrl左键能直接跳转。3.3 Service层与Controller事务边界应该放在哪一层Service层最容易被轻视但它是事务和业务规则的边界。Controller负责参数接收、返回结构组织Service执行业务逻辑不要让Controller直接调用Mapper否则未来一个业务需要组合两次查询时就得改动接口签名影响面变大。Service public class UserService { Resource private UserInfoMapper userInfoMapper; public UserInfo getById(Long id) { return userInfoMapper.selectById(id); } Transactional(rollbackFor Exception.class) public void updateUser(UserInfo user) { userInfoMapper.updateById(user); } }Transactional(rollbackFor Exception.class)这个写法里rollbackFor很重要。默认情况下Spring事务只对RuntimeException回滚如果业务代码里抛出的是IOException这类受检异常且没有特别声明事务不会回滚数据会出现半更新状态。把rollbackFor设为Exception.class是稳妥的防御策略代价是精度下降但你可以在方法内部捕获异常后决定是抛出去回滚还是吞掉提交。3.4 Controller层输出统一结构第一版不用太复杂Controller层简单直接返回数据没问题但真实项目里一般会包一层Result让前端统一处理code、message、data三个字段。第一版不必设计得很重只要把成功、失败两种状态区分开就行。RestController RequestMapping(/user) public class UserController { Resource private UserService userService; GetMapping(/{id}) public ResultUserInfo getUser(PathVariable Long id) { UserInfo user userService.getById(id); if (user null) { return Result.fail(用户不存在); } return Result.success(user); } }Result类的写法网上有很多模板核心就两个静态方法。这里不贴完整代码了因为不同团队字段风格差异很大有的喜欢int code有的喜欢String code关键是保持全项目统一。写完后用IDEA的HTTP Client或者Postman验证一条数据整个链路就算通了。4. 从注解换成XML动态SQL和映射复杂字段的三种实操场景4.1 什么时候该从注解切换到XML注解写在Mapper接口方法上简单SQL用起来很快没有XML文件和Mapper接口两次维护的成本。可一旦面对动态查询、批量插入、复杂结果映射注解会变得格外别扭——Select里写 标签需要在注解内部拼script脚本语法混乱且IDE不提示错误。真实项目里我遇到的几乎都是XML方案因为需求不会永远停留在单表CRUD。三种场景必须换成XML条件组合不确定的列表查询、批量插入或更新、多表关联映射。下面用一个多条件分页查询来演示XML的真正优势这对应线上最常见的“用户列表按姓名、年龄范围筛选”需求。4.2 动态查询的XML写法与#{}参数传递细节select idselectByCondition resultTypecom.example.demo.entity.UserInfo SELECT * FROM user_info where if testuserName ! null and userName ! AND user_name LIKE CONCAT(%, #{userName}, %) /if if testminAge ! null AND age gt; #{minAge} /if if testmaxAge ! null AND age lt; #{maxAge} /if /where ORDER BY id DESC LIMIT #{offset}, #{pageSize} /select这个例子有三个细节值得说。第一 标签会智能处理第一个AND条件都不满足时不会报SQL语法错误。第二XML里小于号必须转义成在XML中转义的小于符号直接写会把文件解析成非法标签。第三LIMIT子句里的偏移量在列表页要用(当前页-1)*每页条数提前算好传进来不要在SQL里做乘法。参数接收方面如果Mapper接口方法参数超过一个并且没有用Param注解MyBatis会拿不到参数名。SpringBoot 2.x默认编译参数是强制开启的但保险起见多参数方法都建议加上ParamListUserInfo selectByCondition(Param(userName) String userName, Param(minAge) Integer minAge, Param(maxAge) Integer maxAge, Param(offset) Integer offset, Param(pageSize) Integer pageSize);加不加Param编译后运行是两种行为。加了之后XML里可以直接用名字引用不加快默认规则下用arg0、arg1或者param1、param2代码可读性极差。团队规范一般强制加Param没有例外。4.3 批量插入的foreach写法与主键回填批量插入是XML最典型的省事场景。循环调用单条insert可以跑通但性能相差几十倍尤其连接池配置不大时循环插入很容易把连接占满。foreach拼一条大SQL是最常见的优化方式。insert idbatchInsert parameterTypelist INSERT INTO user_info (user_name, age, email) VALUES foreach collectionlist itemitem separator, (#{item.userName}, #{item.age}, #{item.email}) /foreach /insertforeach的collection属性在参数是List类型时要写list参数是数组时写array参数是Map时写Map的key。这里的坑在于如果Mapper接口方法写了Param(list)collection要写对应值没写集合类型的情况下MyBatis约定为list。最稳妥的写法是接口上标Param(list)XML里collection写list两者保持一致。主键回填是另一个高频诉求。batchInsert后要拿到自增ID需要在insert标签里加useGeneratedKeys和keyPropertyinsert idbatchInsert parameterTypelist useGeneratedKeystrue keyPropertyiduseGeneratedKeys让JDBC把数据库生成的主键回写到实体对象的id属性上。注意keyProperty指的是Java实体属性名id不是数据库列名id。这个配置只对支持自增的数据库有效Oracle序列场景需要另外用selectKey。4.4 resultMap解决字段映射和嵌套查询数据库表和实体字段都规范时resultType加驼峰映射基本够用。但当查询跨表比如用户要带上部门名称就不能直接映射到UserInfo实体里因为它没有deptName这个属性。两种常见处理方式一是建VO类把user_info联查的结果字段放进去二是用resultMap配置嵌套映射一次查询把关联对象组装出来。resultMap idUserWithDeptMap typecom.example.demo.vo.UserVO id propertyid columnid/ result propertyuserName columnuser_name/ result propertydeptName columndept_name/ /resultMap select idselectUserWithDept resultMapUserWithDeptMap SELECT u.id, u.user_name, d.dept_name FROM user_info u LEFT JOIN dept_info d ON u.dept_id d.id WHERE u.id #{id} /selectresultMap的id标签必须写它告诉MyBatis哪一列是唯一主键在嵌套映射和缓存场景下用来判断对象是否相同。column是数据库列名property是Java属性名。这里没有开启驼峰也能正确填充因为resultMap显式声明了映射关系优先级更高。5. 整合路上的避坑指南7个真实出现过的运行时报错5.1 启动报错Invalid bound statement但Mapper接口和XML都看着没问题现象启动不报错运行时调用Mapper方法抛出org.apache.ibatis.binding.BindingException。原因XML文件没有编译到classes目录。IDEA或Maven打包时默认只把src/main/resources下的文件当资源处理如果你把XML放在src/main/java下和Mapper接口同包构建后不会复制到target目录。当然也有人放对了位置但名字拼错或者namespace写错。解决把XML统一放在src/main/resources/mapper目录下检查target目录确认文件是否存在然后核对namespace是否与接口全限定名一致statement的id是否与接口方法名一致。一个细节如果项目里同时有application.yml和application.properties记得确认mybatis.mapper-locations配置写在生效的那个文件里改错文件会浪费很久。5.2 查询结果字段全是null数据明明存在现象SQL用SELECT * 查询结果对象返回了但所有属性都是nullid也是null。原因数据库字段user_name和Java属性userName之间没有映射map-underscore-to-camel-case未开启MyBatis默认把数据库列的user_name直接匹配Java属性user_name但实体类属性名是userName两者不一致。解决yaml中加入mybatis.configuration.map-underscore-to-camel-case: true。如果你不想全局开启也可以用resultMap显式声明映射。另一种情况是数据库返回的别名和实体属性不一致比如SQL里写了SELECT COUNT(*) result没起别名MyBatis映射不到count属性需要给SQL列起和实体属性一致的别名。5.3 插入数据时报SQLException: Field xxx doesnt have a default value现象插入时数据库报某个字段没有默认值但这个字段在业务里明明可以不填。原因数据库表设计时字段设置为NOT NULL且没有默认值而插入SQL没带这个字段。常见于时间字段created_at和updated_at如果数据库版本低于5.6DEFAULT CURRENT_TIMESTAMP对DATETIME类型不生效。解决建表SQL调整字段定义给created_at加DEFAULT CURRENT_TIMESTAMP或者在插入SQL中显式写入数据库当前时间用NOW()函数。另外检查实体类对应字段是否为基本数据类型int型在插入时没赋值默认0数据库层一般不会报错但如果是Integer空指针到SQL层MyBatis会传nullNULL约束就触发了。5.4 更新数据时全字段更新把不需要动的列也覆盖了现象updateById传入的实体只改了userName结果email、age也被重置成了空值或默认值。原因XML里的update语句是UPDATE user_info SET user_name#{userName}, age#{age}, email#{email}MyBatis会把所有字段都set一遍即使实体属性为null。解决第一种方案SQL改为动态更新使用标签配合 只更新非空字段。第二种方案应用层先查一次原数据把要更新的值set上再传入但这样多一次查询并发场景还会遇到覆盖问题。推荐第一种方案对存量系统改动小且逻辑直观update idupdateById parameterTypecom.example.demo.entity.UserInfo UPDATE user_info set if testuserName ! nulluser_name #{userName},/if if testage ! nullage #{age},/if if testemail ! nullemail #{email},/if /set WHERE id #{id} /update这个方案有一个副作用如果线上业务需要把字段值从非空改成空字符串或null会被if条件过滤掉无法完成更新。真实项目里这两种需求都存在所以在设计更新接口时要分开一个是“全量更新”语义适合表单提交允许置空一个是“选择性更新”语义适合接口局部修改。全量更新时用不带if的固定SQL选择性更新时用上面的动态SQL不要让一个接口同时承担两种语义翻车概率极大。5.5 模糊查询LIKE不走索引或者报错现象用了LIKE %关键字%的前后模糊匹配数据量上来后查询很慢。原因前导百分号导致索引失效这是数据库通用问题不是MyBatis配置问题。还有一些开发者会在Java层拼好%再传入这样MyBatis里看不出查询意图排查SQL时更费劲。解决MySQL 5.7之后全文索引、8.0的全文检索都是备选。如果业务必须前后模糊且性能敏感建议改用搜索引擎方案或者把关键字拆词。小数据量场景直接用CONCAT(%, #{userName}, %)的先写对功能后续数据量上来了再谈优化不要过度设计。5.6 多数据源切换后报Error updating database. Cause: java.sql.SQLException现象项目从单数据源切到主从分离多数据源后插入或更新时报SQLException但查询正常。原因主从架构下事务管理器或者SqlSessionFactory绑定了从库连接而从库通常是只读权限。SpringBoot多数据源手动配置时Transactional默认用的是主事务管理器如果数据源路由配置有误写操作就落到了只读库上。解决检查数据源路由规则确认Transactional指定了正确的transactionManager名称。常见做法是配置两个SqlSessionFactory主库命名为primarySqlSessionFactory并标注Primary从库单独命名在Service层按业务方法指定事务管理器。这个排查过程比较耗时建议在开发环境从库就开启普通写的权限做验证把问题前置暴露。5.7 本地能跑通部署到测试环境报时区错乱现象本地插入一条记录时间字段比实际时间少了8小时或者多了8小时测试环境出现同样问题。原因数据库连接url里的serverTimezone参数只对当前连接生效应用所在JVM的时区和数据库的时区如果不一致时间在写入时会被转换两次。解决连接url统一带上serverTimezoneAsia/Shanghai同时确保测试环境数据库的时区设置一致排查方法是在测试环境执行SELECT NOW()看返回的是不是当前时间。如果偏差8小时数据库OS时区要同步修改。Docker部署场景还要注意容器内的时区环境变量常见做法是在docker run时加-e TZAsia/Shanghai。6. 从能跑到顺手事务日志、分页插件和代码生成的衔接研究到这里项目已经可以增删改查了。再往前推进真实开发里还有三件事会高频遇到事务边界不清晰、分页手写易错、实体类字段一多写起来就烦。它们不在“整合”的强制范围内但决定了整合完成后项目能不能持续高效迭代。第一件事是事务失效的隐性场景。很多人在Service里写Transactional然后习惯性地在方法内部用this调用另一个方法结果事务没有生效但代码看着没问题。原因是Spring事务基于代理this调用走的是当前对象而不是代理对象事务织入就没发生。正确的做法是通过注入自身的代理对象调用或者把需要事务的第二个方法放进另一个Bean里。更建议的思路是事务边界放在独立的Service方法上一个方法一个事务不要让事务注解出现在父子调用链的多个层上否则内层方法加了REQUIRES_NEW会和外层事务互相干扰。第二件事是分页查询。手写LIMIT offset, pageSize在上文已经演示过数据量超过几十万后深翻页性能下降明显业界通用做法是换成分页插件。分页插件目前主流是MyBatis-PageHelper或者国产的PageHelper平替方案不管用哪个理解拦截器原理很重要插件在Executor层面拦截SQL自动拼接LIMIT子句然后帮你查COUNT。使用时有个约定俗成的注意点插件的分页逻辑紧跟在startPage调用后面的第一条查询语句中间不要穿插其他SQL操作否则分页会套在错误的SQL上。这个坑真实项目里翻车率极高我见过多个团队在startPage和Mapper调用之间加了一条日志查询结果列表数据和总数全部错乱。第三件事是代码生成。手写实体类、Mapper接口、XML在表很多时是纯体力活而且容易漏字段。整合阶段建议配置好代码生成器主流方案有MyBatis Generator和MyBatis-Plus的代码生成器。生成器的意义不只是节省时间更是统一规范主键策略、实体类风格、XML格式模板固定后团队里每个新表产出的代码风格一致Code Review成本显著下降。如果你使用MyBatis-Plus作为增强方案它的BaseMapper帮你内置了单表CRUD方法再把分页插件集成进去日常单表接口几乎不用写SQL。但要注意用了增强方案后原有XML方案仍然保留兼容性复杂的多表查询还是走XML两者是互补关系。我自己维护的项目里现在建新表的流程是写建表脚本配置好代码生成器连接信息一键生成实体类和Mapper骨架然后主键部分、逻辑删除字段单独过一遍。这套流程稳定后我基本不再手写单表Mapper层的代码把精力全部放在Service的业务规则和Controller的参数校验上。血泪经验是代码生成器生成的代码不要手工改字段命名风格宁可生成后删掉重来也不要破坏模板一致性否则下次重新生成时会冲突。回到最开始的问题。SpringBoot整合MyBatis本质上是把一个ORM框架嵌入Spring的依赖注入和事务体系里。理解starter背后的自动装配逻辑、理解Mapper接口与XML的绑定机制、理解事务回滚的边界条件每一层都比跑通一个Demo重要。希望这篇文章能帮你少走几个弯路把整合过程从“照着抄能跑”变成“出问题知道去哪查”。本文还有配套的精品资源点击获取