ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring整合MyBatis完整链路:从XML配置到事务缓存实战

Spring整合MyBatis完整链路:从XML配置到事务缓存实战 我最近在给组里做技术分享时问了一圈同事谁能在不查资料的情况下手工写一份 Spring 整合 MyBatis 的 XML 配置结果十个人里只有两三个人能写完整而且卡点非常集中——大部分人都不知道mybatis-spring这个桥接包的存在更别说SqlSessionFactoryBean和MapperScannerConfigurer到底在干什么。这个现象在 Spring Boot 时代太常见了。大家平时用mybatis-spring-boot-starter加依赖、配数据源、扫 Mapper三步就完事。但传统 Spring 项目里这些配置全是手写 XML 的。老项目维护要看懂面试要讲清楚想深入理解 Spring Boot 自动配置的原理也得回到这套手动装配上来。这篇文章我就把 Spring 整合 MyBatis 的完整链路讲一遍不写 Spring Boot全部基于传统 XML 配置从 Maven 依赖到事务管理再到缓存实测一次性帮你打通。1. 先回答一个实际问题Spring Boot 时代为什么还要学这套手动整合很多初学者会有个疑问既然 Spring Boot 已经帮我封装好了为什么还非要折腾 XML我用一句话回答mybatis-spring-boot-starter干的事本质上就是用自动配置类把你手写的这些 Bean 定义替换掉了。你不理解底层那套 Bean 是怎么组装的遇到自动配置类覆盖不到的场景比如多数据源、动态数据源、自定义拦截器注 Spring 容器就不知道该从哪里下手。1.1 哪两类人最需要这份教程第一类是还在维护传统 SSM 项目的人。别以为 SSM 已经过时了金融、政务、传统制造业里还有大量运行了五六年的系统用的就是 Spring SpringMVC MyBatis。这些项目里的applicationContext.xml、spring-mvc.xml、mybatis-config.xml是核心资产改一个数据库连接池、加一个 Mapper都需要直接动 XML。你光会 Spring Boot 那一套在这种项目里是寸步难行的。第二类是准备面试的人。MyBatis 相关面试题翻来覆去就那几个方向#{}和${}区别、一级缓存二级缓存、MyBatis 源码里 SQL 是怎么执行的、Spring 怎么管理 MyBatis 的 Mapper。你要真把手工整合配置完整写过一遍这些题基本不用死记硬背。比如别人问你 Mapper 接口为什么能被Autowired注入你直接说“因为MapperScannerConfigurer把接口包装成了MapperFactoryBean注册进 Spring 容器”这一句话就体现出你读过源码。1.2 手动整合和 Spring Boot 的对应关系我列一个对照表看完你就知道 Spring Boot 的 starter 做了什么手工 XML 配置Spring Boot 自动配置作用DataSourcebeanDataSourceAutoConfiguration创建数据库连接池SqlSessionFactoryBeanMybatisAutoConfiguration创建SqlSessionFactoryMapperScannerConfigurerMapperScan或MapperAutoConfiguredMapperScannerRegistrar扫描 Mapper 接口注册到容器DataSourceTransactionManagerDataSourceTransactionManagerAutoConfiguration管理 JDBC 事务所以你今天花半小时把这套 XML 配置走一遍再看 Spring Boot 的MybatisAutoConfiguration源码会觉得它就是在帮你做这些事毫无神秘感。2. 依赖坐标与版本配对这一步错了后面全是报错这一节看起来枯燥但版本问题是我见过新手翻车最多的地方。很多报错不是代码问题而是依赖版本不兼容导致的。2.1 一份能直接跑起来的 Maven 依赖清单先给一份完整可用的pom.xml依赖。我用的是传统 Java 工程最容易遇到的组合JDK 8 Spring 5.3.x MyBatis 3.5.x mybatis-spring 2.1.x。properties spring.version5.3.31/spring.version mybatis.version3.5.13/mybatis.version /properties dependencies !-- Spring 核心容器 -- dependency groupIdorg.springframework/groupId artifactIdspring-context/artifactId version${spring.version}/version /dependency !-- Spring JDBC提供数据源和事务相关的抽象 -- dependency groupIdorg.springframework/groupId artifactIdspring-jdbc/artifactId version${spring.version}/version /dependency !-- Spring 事务管理 -- dependency groupIdorg.springframework/groupId artifactIdspring-tx/artifactId version${spring.version}/version /dependency !-- MyBatis 核心框架 -- dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version${mybatis.version}/version /dependency !-- 关键包mybatis 与 spring 之间的桥 -- dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.1.2/version /dependency !-- MySQL 驱动 -- dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId version8.0.33/version scoperuntime/scope /dependency !-- 单元测试集成 -- dependency groupIdorg.springframework/groupId artifactIdspring-test/artifactId version${spring.version}/version scopetest/scope /dependency /dependencies注意一点mybatis-spring的坐标是org.mybatis:mybatis-spring不要和 Spring Boot 的mybatis-spring-boot-starter搞混后者是另一个 artifact里面通常会传递依赖一个特定版本的 mybatis-spring。2.2 版本配对表JDK 版本决定 Spring 大版本版本匹配有规律其实不用死记按下面这张表查就行环境Spring 版本MyBatis 版本mybatis-spring 版本JDK 85.3.x3.5.x2.1.xJDK 176.x3.5.x3.0.x为什么这么配因为mybatis-spring3.0 是基于 Spring 6 编译的你把它丢到 Spring 5.3 项目里运行时经常报NoClassDefFoundError比如引用到 Spring 6 才有的类而 Spring 5.3 的 classpath 里根本没有。反过来JDK 17 的项目用 Spring 6 却配 mybatis-spring 2.1.x也可能遇到兼容问题因为 2.1.x 没有针对 Spring 6 的 API 变化做适配。除了核心依赖数据库驱动也容易踩坑。MySQL 8 的驱动类名是com.mysql.cj.jdbc.Driver不是老版本的com.mysql.jdbc.Driver。你用 MySQL 8 驱动去配老类名启动就能看到ClassNotFoundException。3. 数据源与 MyBatis 全局配置把最底层的地基打牢依赖引好后先不急着写 Mapper我把装配顺序理一下数据源 → MyBatis 全局配置 → SqlSessionFactoryBean → Mapper 扫描。数据源在最底层它连不上数据库后面全是空谈。3.1 创建 jdbc.properties 并加载先建一个jdbc.properties把数据库连接信息放外面不要硬编码在 XML 里。好处有两个一是环境切换方便二是避免 XML 里字符的转义问题。jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/mybatis_demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse jdbc.usernameroot jdbc.password123456在 Spring 配置文件里用context:property-placeholder加载它。这个标签会在容器启动时读取 properties 文件把里面的 key 暴露成${jdbc.driver}这种占位符供后续 Bean 属性使用。context:property-placeholder locationclasspath:jdbc.properties/为什么要强调 URL 里的如果你把连接串直接写在 XML 里characterEncodingutf8serverTimezone...这一串里的会被 XML 解析器当成实体符号的开始直接启动报错。要么写成amp;转义要么就像现在这样把连接串放 properties 文件里彻底避开这个问题。这也是我把连接信息外置的最直接原因。3.2 数据源 Bean测试用 DriverManagerDataSource生产用连接池最省事的方式是用 Spring 自带的DriverManagerDataSource适合学习和测试环境bean iddataSource classorg.springframework.jdbc.datasource.DriverManagerDataSource property namedriverClassName value${jdbc.driver}/ property nameurl value${jdbc.url}/ property nameusername value${jdbc.username}/ property namepassword value${jdbc.password}/ /bean但这个类的实现逻辑很简单每次getConnection()都新建一个物理连接高并发下性能很差生产环境绝对不能这么干。生产环境通常用 Druid 或 HikariCP。以 Druid 为例bean iddataSource classcom.alibaba.druid.pool.DruidDataSource init-methodinit destroy-methodclose property namedriverClassName value${jdbc.driver}/ property nameurl value${jdbc.url}/ property nameusername value${jdbc.username}/ property namepassword value${jdbc.password}/ property nameinitialSize value5/ property nameminIdle value5/ property namemaxActive value20/ /beaninit-methodinit是让 Druid 在容器启动时初始化连接池destroy-methodclose是容器关闭时释放连接池资源。这两个属性不写连接池也能用但资源回收不彻底长期运行容易出问题。3.3 mybatis-config.xml 里哪些配置值得开MyBatis 的全局配置文件很轻量平时我基本只放settings和typeAliases?xml version1.0 encodingUTF-8? !DOCTYPE configuration PUBLIC -//mybatis.org//DTD Config 3.0//EN http://mybatis.org/dtd/mybatis-3-config.dtd configuration settings !-- 数据库下划线列名自动映射为驼峰属性名 -- setting namemapUnderscoreToCamelCase valuetrue/ !-- 控制台直接打印 SQL学习排查阶段强烈推荐 -- setting namelogImpl valueSTDOUT_LOGGING/ !-- 查询结果为 null 时也调用 setter避免反序列化丢字段 -- setting namecallSettersOnNulls valuetrue/ /settings typeAliases package namecom.example.entity/ /typeAliases /configurationmapUnderscoreToCamelCase必须讲一下数据库字段名通常叫create_timeJava 实体属性叫createTime。没开启驼峰映射的话你写select *查到create_time列MyBatis 不会自动填到createTime属性里结果就是对象里很多字段是 null。很多人排查半天发现是这个问题其实一行配置就能解决。typeAliases配了实体类包名后Mapper XML 里写resultTypeUser就够了不用写完整类名com.example.entity.User少敲很多字。如果不想用全局配置里的别名包也可以在 SqlSessionFactoryBean 上配typeAliasesPackage属性效果一样看个人习惯。4. SqlSessionFactoryBean 与 Mapper 扫描器Spring 容器里发生了什么这是整篇教程的核心。前面配的数据源和全局配置最终都要汇入SqlSessionFactoryBean它在 Spring 容器里扮演SqlSessionFactory的创建者角色。4.1 SqlSessionFactoryBean 的三个关键属性直接看配置bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property nameconfigLocation valueclasspath:mybatis-config.xml/ property namemapperLocations valueclasspath*:mapper/*.xml/ /beandataSource告诉 MyBatis 数据库连接从哪来它会被放入 MyBatis 的Environment中。configLocation指向 MyBatis 全局配置文件我一般放在classpath:mybatis-config.xml。mapperLocations指定 Mapper XML 映射文件的位置这样 MyBatis 才能在启动阶段把 XML 里的 SQL 和接口方法绑定起来。很多人会忽略mapperLocations结果 Mapper 接口写好了XML 也写了启动却报Invalid bound statement (not found)。原因就是 Spring 压根没把 XML 加载进 MyBatisMapper 接口里空有方法声明没有对应的 SQL 定义。4.2 classpath 和 classpath* 的区别配置里我用的是classpath*:mapper/*.xml注意这个星号。classpath:mapper/*.xml只会从当前项目的 classpath 根目录找 mapper 目录下的 XML。如果是多模块 Maven 工程某个 Mapper XML 在依赖模块的 jar 包里这种写法就扫不到。classpath*:mapper/*.xml会扫描整个 classpath 下所有 jar 包里的 mapper 目录多模块场景下更安全。但classpath*:不能用在configLocation上因为 MyBatis 需要加载单个配置文件作为全局配置入口多个会冲突。所以configLocation老老实实用单星号的classpath:mybatis-config.xml。4.3 MapperScannerConfigurer省掉逐条注册的利器有了 SqlSessionFactory还需要把 Mapper 接口注册到 Spring 容器。最原始的方式是每个 Mapper 写一个MapperFactoryBeanbean iduserMapper classorg.mybatis.spring.mapper.MapperFactoryBean property namemapperInterface valuecom.example.mapper.UserMapper/ property namesqlSessionFactory refsqlSessionFactory/ /bean注册 10 个 Mapper 就得写 10 个这样的 bean谁用谁知道又臭又长。MapperScannerConfigurer就是为了解决这个问题bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.example.mapper/ /bean它会在容器启动时扫描basePackage指定包下的所有接口自动把每个接口包装成一个MapperFactoryBean注册到 Spring 容器。所以你在 Service 里才能写出这种代码Autowired private UserMapper userMapper;容器里的userMapper其实是一个 JDK 动态代理对象。调用它的方法时代理会委托给SqlSessionTemplate执行 SQL。这个动态代理机制就是 MyBatis 和 Spring 整合的精髓——接口没有实现类但 Spring 容器里仍然能注入一个可用对象。4.4 SqlSessionTemplate 和 SqlSession 的关系在传统纯 MyBatis 编程中你要手动SqlSessionFactory.openSession()用完还要close()。Spring 整合后用SqlSessionTemplate替代了手动管理。它的特点是线程安全可以在多个线程间共享。但有一点需要注意SqlSessionTemplate 默认的执行策略是“每次执行 Mapper 方法时获取一个 SqlSession方法结束就归还”。如果当前线程有 Spring 事务它就会复用事务绑定的那一个 SqlSession如果没有事务每次都是新的 SqlSession。这个机制直接影响了 MyBatis 一级缓存的效果我会在最后一部分专门演示。5. Mapper 接口与 XML 映射绑定规范和一碰就挂的坑配置层面搞定后剩下的就是写接口和 XML 映射了。这一节我讲绑定规范和几个高频坑特别是那个“单个数字字符比较”的问题几乎每个项目群都有人问过。5.1 namespace 是绑定的第一道门槛接口和 XML 的绑定关系有硬性要求。看例子public interface UserMapper { User findById(Integer id); ListUser findAll(); int insert(User user); }对应 XML?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.mapper.UserMapper select idfindById parameterTypeint resultTypeUser select * from user where id #{id} /select select idfindAll resultTypeUser select * from user /select insert idinsert parameterTypeUser useGeneratedKeystrue keyPropertyid insert into user(user_name, password, create_time) values(#{userName}, #{password}, #{createTime}) /insert /mapper三条规则一条都不能破mapper的namespace必须是 Mapper 接口的全限定类名比如com.example.mapper.UserMapper少一个包名都不行。每个 SQL 标签的id必须等于接口方法名比如findById。方法返回值类型要和 XML 里的resultType或resultMap对得上。报错Invalid bound statement (not found)90% 都是前两条没满足剩下 10% 是第 4 节的mapperLocations没扫到 XML。5.2 #{} 和 ${} 的本质区别#{}是预编译占位符。MyBatis 会把 SQL 中的#{id}解析成?交给 JDBC 的PreparedStatement设置参数。整个过程有类型处理也能防 SQL 注入。${}是字符串直接拼接。MyBatis 会直接把变量的值拼进 SQL 字符串里。比如select idfindById resultTypeUser select * from ${table} where id #{id} /select${table}的作用场景是动态表名、排序字段这类无法用占位符的地方。除此之外传普通参数一律不要用${}。我见过项目里有人用${name}做模糊查询结果参数值里含个单引号SQL 直接炸或者被恶意拼接。这是安全底线不是风格偏好的问题。5.3 动态 SQL 里单个数字字符比较的坑这是很经典的一个问题热词里也在问“单个数字字符比较”。假设你有下面这段动态 SQLif teststatus 1 and status #{status} /if如果status是 String 类型运行时大概率匹配不中甚至可能报NumberFormatException。为什么因为 OGNL 表达式里1默认被当成 Java 的char类型而不是String。一个 String 类型的变量和一个 char 做比较OGNL 会尝试把 String 解析成数字去比一旦字符串内容不是纯数字就会抛异常。正确写法有两种if teststatus 1.toString() and status #{status} /if或者更推荐这种语义一目了然if test1.equals(status) and status #{status} /if这个坑我建议你在本地亲手复现一次体会会比读文章深得多。MyBatis 的 OGNL 处理和 Java 直觉不一样以后写if判断字符串时用equals是最稳的。5.4 多参数方法别偷懒用 Param接口方法如果超过一个参数建议显式加ParamListUser findByNameAndStatus(Param(name) String name, Param(status) Integer status);XML 里就可以直接写select idfindByNameAndStatus resultTypeUser select * from user where user_name #{name} and status #{status} /select如果不加ParamMyBatis 也支持但参数名变成param1、param2SQL 里要写#{param1}、#{param2}可读性差重构时还容易错。还有一个更常见的做法是封装一个查询对象ListUser search(UserQuery query);XML 里用#{userName}、#{status}直接取 POJO 的属性本质是调用 getter 方法。这种写法在查询条件多的时候很实用。6. 事务管理让 Transactional 真正生效的配置与边界条件Spring 整合 MyBatis 之后事务管理是逃不掉的一环。没有事务管理器多条写操作中出现一条失败前面成功的数据就会留在数据库里这是生产事故级别的隐患。6.1 事务管理器与注解驱动配置在 Spring 配置文件中加三样东西DataSourceTransactionManager、tx命名空间、tx:annotation-driven。bean idtransactionManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean tx:annotation-driven transaction-managertransactionManager/tx:annotation-driven的作用是开启Transactional注解的支持Spring 会为标注了该注解的类生成代理对象在方法前后加事务逻辑。注意根元素beans上要补充tx命名空间和 xsd 路径很多新手忘了加结果 Spring 报错说找不到tx:annotation-driven标签。6.2 为什么必须配事务管理器MyBatis 本身也有事务管理机制但它默认是自动提交的每条 SQL 单独一个事务。Spring 整合后SqlSessionFactoryBean在检测到 Spring 环境时会把 MyBatis 的事务工厂切换成SpringManagedTransactionFactory事务的提交、回滚全部交给 Spring 的DataSourceTransactionManager控制。所以如果你只配了数据源和 SqlSessionFactory没有配事务管理器那你的每个 Mapper 方法都是独立的自动提交事务。单独查没问题但批量写操作一旦中间报错前面的写操作不会回滚。6.3 Transactional 放哪层以及两个易踩的失效场景实践上Transactional建议放在 Service 实现类的方法上不要放在 Service 接口上。因为 Spring 默认通过 CGLIB 代理来实现事务增强接口上的注解在实现类代理场景下可能识别不准。Service public class UserServiceImpl implements UserService { Autowired private UserMapper userMapper; Override Transactional(rollbackFor Exception.class) public void createWithLog(User user) { userMapper.insert(user); // 模拟中间出错 int i 1 / 0; } }这里注意rollbackFor Exception.class。Spring 默认只对RuntimeException和Error回滚如果你代码里抛的是受检异常比如IOException不加rollbackFor事务是不回滚的数据会留着等于让你以为失败了其实写进去了。两个特别容易踩的失效场景第一个是自调用。在同一个类里一个方法调用另一个带Transactional的方法事务直接失效。原因很好理解Spring 的事务是通过代理对象实现的但this.createWithLog()里的this是原始对象不是代理所以注解压根没被处理。public void outer() { this.inner(); // 事务失效 } Transactional public void inner() { // ... }解决办法要么把inner()移到另一个 Service 里要么注入自身代理。第二个是异常被吞。方法里 catch 住异常没往上层抛Spring 觉得这方法正常执行完就不会回滚。Transactional public void insert() { try { userMapper.insert(user); } catch (Exception e) { log.error(insert error, e); // 没把异常抛出去事务不会回滚 } }这一点在写批量导入代码时很常见一 catch 不抛数据就变成半成品状态了。6.4 事务和一级缓存的关系加了事务后MyBatis 一级缓存表现会明显不同。我给个结论同一个事务内多次查询SqlSession 是同一个一级缓存生效不同事务或无事务环境每次执行都是新 SqlSession一级缓存基本形同虚设。后面的实测环节会验证这一点。7. 启动验证、SQL 日志与缓存实测确认整合成功的完整清单配置写了这么多最终要的是能跑起来。这一节我带上验证代码、日志开关和缓存实验按顺序来。7.1 最小验证代码我习惯用纯 main 方法做最小验证不依赖 Web 容器public class App { public static void main(String[] args) { ClassPathXmlApplicationContext ctx new ClassPathXmlApplicationContext(spring/applicationContext.xml); UserMapper userMapper ctx.getBean(UserMapper.class); User user userMapper.findById(1); System.out.println(user); ctx.close(); } }applicationContext.xml里如果配置项太多可以用import把 dao 相关的配置独立出来比如import resourcespring-dao.xml/ context:component-scan base-packagecom.example.service/这样数据源、SqlSessionFactory、Mapper 扫描器都放spring-dao.xml里业务扫描和事务配置放主配置里层次清晰一点老项目的运维同事看着也舒服。7.2 让 SQL 打到控制台配置 MyBatis 把 SQL 打印出来分两种情况。如果用的是标准输出在mybatis-config.xml里写setting namelogImpl valueSTDOUT_LOGGING/控制台会直接打印 Preparing、Parameters、Rows 三类信息调试最直观。如果项目里用了 Log4j2把logImpl改成LOG4J2同时要保证 classpath 下确实有 log4j2 的依赖和配置文件否则 MyBatis 找不到对应实现会报错。很多人配置了 Druid 但没配 MyBatis 的 logImpl结果只看到数据源连接日志看不到 SQL 日志误以为 SQL 没执行其实只是没打印而已。7.3 一级缓存与二级缓存的实测结论先说一级缓存。它是 SqlSession 级别的MyBatis 默认开启。同样的查询如果 SqlSession 没变第二次直接取缓存结果不发 SQL 到数据库。在 Spring 整合环境下一级缓存的表现取决于是否有事务。我写个演示方法Transactional public void cacheDemo() { User u1 userMapper.findById(1); User u2 userMapper.findById(1); // 这里第二次不会真正执行 select命中一级缓存 }因为在Transactional方法内两次findById共享同一个 SqlSession所以一级缓存生效。如果你想确认就在日志里看有没有打印两次 SQL。正常只会打印一次。但是如果没有事务public void noTxCacheDemo() { User u1 userMapper.findById(1); User u2 userMapper.findById(1); // 这里会执行两次 select }因为 SqlSessionTemplate 每次方法调用都获取和释放 SqlSession两次查询不在同一个会话里一级缓存自然就失效了。理解这一点很多面试题就迎刃而解他不是问“一级缓存有没有”而是问“Spring 整合后一级缓存为什么好像失效了”。二级缓存默认是关闭的。要开启需要满足几个条件mybatis-config.xml里cacheEnabled为 true默认就是 true、Mapper XML 里加cache/、实体类实现Serializable接口。但配置远比一级缓存复杂还涉及事务提交时机。我的建议是除非是那种读多写极少的表否则二级缓存优先级很低别为了“提升性能”而开它它带来的脏数据风险经常比性能收益更致命。7.4 常见报错与定位路线最后把常见问题归个类至少能覆盖 80% 的整合报错报错信息直接原因排查方向Invalid bound statement (not found)namespace 或 id 不匹配或 XML 没被扫描检查 namespace 是否等于接口全限定名检查mapperLocations是否覆盖 XML 路径NoSuchBeanDefinitionException: UserMapperMapper 接口没有注册到 Spring 容器检查MapperScannerConfigurer的basePackage是否包含接口所在包Could not autowire. No beans of type XxxMapper found同上同上另外确认接口没有 final 或 private 方法Parameter name not found多参数方法没加Param接口方法参数加上Param并在 XML 里使用对应名字ClassNotFound: com.mysql.cj.jdbc.Driver驱动类名配错或驱动依赖缺失确认 MySQL 8 驱动坐标类名写com.mysql.cj.jdbc.Driver中文乱码连接串编码不对jdbc.url加characterEncodingutf8确认数据库表也是 utf8我个人带项目时有个习惯让新人第一次跑通后故意把 namespace 改错一次观察一次Invalid bound statement报错长什么样再改回来。这种“主动犯错”的方式比只看正确配置记忆要深刻得多能帮你把报错和原因真正对上号。之后遇到问题就不是拍脑袋而是按上面表格里的方向一条条排除效率会高很多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进