1. 为什么Spring操作MyBatis会有注解和XML两套玩法先说个结论这两套玩法不是甲方乙方的关系而是互补关系。MyBatis作为半自动ORM框架核心工作就是两件事把Java方法映射成SQL语句把SQL查询结果映射回Java对象。Spring要操作MyBatis本质上就是要把MyBatis的SqlSessionFactory交给Spring容器管理再让Mapper接口能被自动扫描注册成Bean。这个过程中SQL语句放哪里、怎么放就是注解和XML两条路的分水岭。我见过不少刚接触MyBatis的朋友一上来就问到底学注解还是学XML其实这个问题本身就问偏了。真实项目里几乎没有纯注解或纯XML的极端情况绝大多数是混着用。更准确的说法是XML负责复杂SQL和动态SQL注解负责简单CRUD和参数映射明确的场景。这个分工背后有很实在的理由后面我会详细拆。MyBatis的注解体系从3.0版本开始引入现在的主流注解有Select、Insert、Update、Delete四大金刚加上Param、Options、Results、ResultMap、MapperScan这些辅助注解。它们的作用就是让你在一个Java接口方法上直接把SQL写死省掉XML映射文件。但一旦SQL开始复杂——比如需要动态拼条件、批量插入、关联查询映射结果集——注解写法就开始吃力了。MyBatis的注解虽然支持SelectProvider这类注解里嵌脚本的方案但可读性和维护性真的不如XML里的动态SQL标签直观。XML方式则是MyBatis从1.x时代起家的老本行。一个mapper namespace绑定一个接口、 、 、 标签对应接口方法里面可以写完整的动态SQL用 、 、 、 这些标签来拼SQL。XML的好处是SQL与Java代码分离DBA可以直接打开XML文件审查SQL复杂查询的层级结构一览无余。坏处也很明确——多了一层文件项目里随之多了文件路径配置、namespace和id的管理成本漏了任何一环就启动报错。Spring在这中间扮演的角色是胶水。不管是注解还是XML最终都要靠Spring把Mapper接口代理成可执行的Bean注入到Service层中。Spring的核心装配逻辑是通过SqlSessionFactoryBean创建MyBatis的SqlSessionFactory实例再通过MapperScannerConfigurer或MapperScan把接口包扫描出来每个接口生成一个动态代理对象。注解和XML的差异只体现在代理对象拿到接口方法后去哪里找对应的SQL语句上——XML写在映射文件里注解直接写在方法上。这篇文章我打算从实际项目视角把Spring通过注解和XML方式操作MyBatis的完整链路走一遍环境怎么搭、两种方式的运行原理、混用时的优先级规则、常见坑怎么避。内容偏实战适合已经用过一点MyBatis但想搞明白底层机制的人也适合正在面试前梳理MyBatis知识体系的同学。2. 环境准备Spring整合MyBatis的最小工程长什么样2.1 依赖引入别漏了spring-jdbc先说环境。即便你用的是Spring Boot也得清楚底层依赖的本质。Spring整合MyBatis需要三类依赖Spring基础、MyBatis本体、MyBatis与Spring的桥接包。dependency groupIdorg.springframework/groupId artifactIdspring-context/artifactId version5.3.29/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-jdbc/artifactId version5.3.29/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version3.5.13/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.8/version /dependencyspring-jdbc最容易漏。MyBatis本身不依赖它但Spring管理事务、DataSourceUtils获取连接时都会用到。你不加它启动时可能不报错等事务一开就翻车。数据库驱动按实际选MySQL就加mysql-connector-jPostgreSQL加postgresql连接池一般用HikariCP或Druid。这里不搞花活直接用Spring内置的DriverManagerDataSource做演示比较清楚真实项目请务必换连接池。2.2 Spring配置文件装配SqlSessionFactoryBeanXML配置方式的核心是这段bean iddataSource classorg.springframework.jdbc.datasource.DriverManagerDataSource property namedriverClassName valuecom.mysql.cj.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/test_db?useSSLfalseamp;serverTimezoneAsia/Shanghai/ property nameusername valueroot/ property namepassword value123456/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ !-- 指定XML映射文件位置 -- property namemapperLocations valueclasspath*:mapper/*.xml/ !-- 指定类型别名包 -- property nametypeAliasesPackage valuecom.demo.entity/ !-- 可选指定MyBatis全局配置 -- property nameconfiguration bean classorg.apache.ibatis.session.Configuration property namemapUnderscoreToCamelCase valuetrue/ /bean /property /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.demo.mapper/ property namesqlSessionFactoryBeanName valuesqlSessionFactory/ /bean这段配置的核心逻辑要讲清楚。SqlSessionFactoryBean是MyBatis的SqlSessionFactory在Spring里的替身#buildSqlSessionFactory()会在初始化时调用加载所有mapperLocations指向的XML文件、解析typeAliasesPackage下的类型别名、把configuration的全局设置写入MyBatis配置。MapperScannerConfigurer则在BeanDefinition注册阶段扫描basePackage包把每个接口注册成MapperFactoryBean。用Spring Boot的话这段配置就变成了application.yml里的mybatis.mapper-locations和MapperScan注解本质没变换了个马甲而已。2.3 最基础的Mapper接口两种写法对比假设有一张user表字段为id、name、email。先定义实体public class User { private Long id; private String name; private String email; // getter/setter 省略 }XML方式的接口和映射文件public interface UserMapper { User selectById(Long id); ListUser selectAll(); }?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.demo.mapper.UserMapper select idselectById resultTypecom.demo.entity.User SELECT id, name, email FROM user WHERE id #{id} /select select idselectAll resultTypecom.demo.entity.User SELECT id, name, email FROM user /select /mapper注解方式的接口Mapper public interface UserMapper { Select(SELECT id, name, email FROM user WHERE id #{id}) User selectById(Long id); Select(SELECT id, name, email FROM user) ListUser selectAll(); }到这里能直观看出两种方式的第一差异XML方式的接口干干净净SQL在XML里注解方式的SQL和接口绑定在一起。有人觉得注解方式省事省文件有人觉得XML方式井井有条。别急着站队接着看原理。注意注解方式的Mapper不是MyBatis注解是org.apache.ibatis.annotations.Mapper。Spring Boot里也常用这个注解来标识要被扫描的Mapper接口。它和MapperScan是两种不同的扫描策略后面会专门讲。3. XML方式的核心机制namespace到SqlSessionFactory的完整链路3.1 MyBatis解析XML映射文件的底层行为XML方式不是简单地把XML文件放在那就行了。Spring容器启动时SqlSessionFactoryBean会调用XMLConfigBuilder和XMLMapperBuilder来做两级解析。第一级是全局配置解析。XMLConfigBuilder处理MyBatis的全局配置比如settings、typeAliases、typeHandlers然后通知XMLMapperBuilder去解析所有映射文件。第二级才是mapper映射文件解析。XMLMapperBuilder对每个 标签做parse()它会读取namespace属性注册一个MapperRegistry的knownMappers一个Class引用然后解析这个namespace指向的接口上的所有方法。有个细节经常被忽略MyBatis会把XML里的SQL和注解里的SQL同时记住但存的地方不一样。XML的语句会存入Configuration.mappedStatements注解的语句也会在接口方法解析时加入mappedStatements只不过一个的来源是XML映射文件一个的来源是AnnotationBuilder。它们的key都是namespace id。如果两边key冲突MyBatis的处理逻辑是XML的mappedStatement会覆盖注解的。这个规则很实用后面讲混用时会重点用上。#{}和${}的差异是XML方式里最容易踩的坑。#{}走PreparedStatement参数占位安全防注入${}是直接字符串拼接只适合表名、排序字段这种没法预编译的场景。真实项目里${}的使用必须严格限制最好对传入值做白名单校验。3.2 Spring如何把Mapper接口变成可注入的Bean这是Spring整合MyBatis的核心秘密。MapperScannerConfigurer在Spring容器refresh阶段会注册一堆BeanDefinition每个Mapper接口对应一个MapperFactoryBean。这个MapperFactoryBean的getObject()方法返回的不是接口的实现类而是JDK动态代理对象。整个调用链是这样的Service层注入UserMapper时Spring发现它是一个FactoryBean于是调用getObject()返回代理对象。代理对象通过mapperInterface.getId()去Configuration.mappedStatements里查对应的MappedStatement然后交给SqlSession执行。SqlSession拿到MappedStatement后按statement的sqlSource类型执行SQLXML方式的sqlSource是XMLStatementBuilder构建的DynamicSqlSource或RawSqlSource注解方式的是ProviderSqlSource或RawSqlSource。换句话说不管你用注解还是XML最终到了运行期都统一变成了MappedStatement里有SQL代理对象去执行差异只在构建MappedStatement的源头。理解这一点很多疑惑都会迎刃而解。3.3 XML动态SQL为什么复杂查询一定要回到XML我举个实际例子一个带条件查询的用户列表select idsearchUsers resultTypecom.demo.entity.User SELECT id, name, email FROM user where if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if if testemail ! null and email ! AND email #{email} /if /where ORDER BY id DESC if testoffset ! null and limit ! null LIMIT #{offset}, #{limit} /if /select这段SQL逻辑如果用注解写你大概只能写Provider类用字符串拼接SQL或者直接用Select加上 script 标签。先不说可读性光是字符串拼接时的引号、空格、AND的组装就够你喝一壶了。XML的 标签会自动去掉前导AND或OR 标签做条件判断 做集合遍历清楚又稳定。动态SQL是MyBatis当初区别于普通JDBC工具类的重要原因。你在XML里写的 、 标签运行时会被动态SQL解析器翻译成一个个SqlNode节点执行时按条件动态拼接成最终的BoundSql。这套完整的标签式动态文本处理是MyBatis最核心的价值之一。真实项目中超过80%的业务SQL都只是基础查询可变条件用XML天然好使没必要自虐式地全用注解。提示如果你用Spring Boot别忘了在application.yml里加mybatis.mapper-locations: classpath:mapper/*.xml。漏了这行XML方式直接失效接口调用会报Invalid bound statement。4. 注解方式Select到动态代理的执行链路拆解4.1 注解SQL如何变成MappedStatement注解方式看似跟XML是两套东西实际上在MyBatis初始化时已经完成了归拢。前面说过XMLMapperBuilder在解析接口时会在parseStatement()里既找XML标签ID也扫描方法上的注解。具体到注解构建SQL是MapperAnnotationBuilder在方法上同时读取Select、Insert、Update、Delete等注解通过AnnotationBuilder生成对应的SqlSource统一put进mappedStatements。SQL注解和XML映射还有一个关键配合点一个接口方法只要没有对应的XML标签注解上的SQL就会被使用如果XML里有同id的标签XML覆盖注解的。这一点很多人不知道但它是混用两种方式的最关键机制。也就是说注解加XML双写在MyBatis里是允许的而且XML优先级高。4.2 注解家族常用成员逐一说明直接上代码把注解方式的完整CRUD和常见扩展写法列出来Mapper public interface UserMapper { Select(SELECT id, name, email FROM user WHERE id #{id}) Results(id userMap, value { Result(column id, property id, id true), Result(column name, property name), Result(column email, property email) }) User selectById(Long id); Select(SELECT id, name, email FROM user) ResultMap(userMap) ListUser selectAll(); Insert(INSERT INTO user(name, email) VALUES(#{name}, #{email})) Options(useGeneratedKeys true, keyProperty id) int insert(User user); Update(UPDATE user SET name #{name}, email #{email} WHERE id #{id}) int update(User user); Delete(DELETE FROM user WHERE id #{id}) int deleteById(Long id); Select(SELECT id, name, email FROM user WHERE name LIKE CONCAT(%, #{name}, %)) ListUser findByName(Param(name) String name); }几个容易被忽视的点Results注解的id属性用于定义一个结果映射的引用ID配合ResultMap引用。它对应XML里的 。当查询列名和实体属性名不一致时比如数据库是user_name实体是userName一种方式是开启mapUnderscoreToCamelCase一种方式是显式写Results。如果项目中既有列名不一致、又有嵌套对象映射建议还是用XML的resultMap注解的Results面对复杂嵌套会写到你怀疑人生。Param注解负责给参数起名字。方法只有一个参数时可以不加Param直接用#{id}也能解析到但参数是两个及以上时不加Param会报错或只能通过arg0、param1这种丑爆的位置名访问。我自己的习惯是只要方法里能看见参数名一律加Param省得后续重构时炸得莫名其妙。Options的useGeneratedKeys和keyProperty用来处理自增主键回填。加了这两个属性insert执行后MyBatis会把数据库生成的自增ID回填到传入对象User的id字段。有的同学insert完想拿新ID查了半天发现自己插入的对象ID还是null多半就是这个属性没配。4.3 注解方式在复杂映射和动态SQL上的边界注解方式不是坏事它非常适合简单业务和快速原型开发。但它有几个明确的短板第一动态SQL写起来恶心。注解里支持