1. 注解映射的整体思路与选型背后那些事
1.1 注解映射到底解决了什么问题
先聊点实际的。以前做MyBatis项目,最常见的姿势是写一个Mapper接口,再配一个XML文件,接口里定义方法,XML里写SQL,然后通过namespace把两者绑起来。这套玩法本身没毛病,但有一个很现实的问题:项目一旦大起来,Mapper接口和XML文件的数量会快速增长,每次改动接口方法签名,都得同步去翻XML,稍不留神就出现“接口方法加了参数,XML里忘了写”这种低级报错。
注解映射就是为了解决这个割裂感出现的。它的核心思路很简单:把SQL直接写在接口方法上,用注解代替XML里的标签。比如你要查一条用户记录,直接在接口方法上写@Select("select * from user where id = #{id}"),MyBatis就明白这个方法要执行什么SQL,返回什么类型。接口即SQL,SQL即接口,不再需要维护两套文件。
从实际开发场景看,注解映射特别适合下面几类情况:
- 简单CRUD占了绝大多数,单表操作居多,SQL一眼能看明白;
- 接口数量多但每个方法都很短,XML文件往往几十行就为了包一个
select; - 团队规范要求代码审查效率高,注解方式能在一个文件里看全所有数据库操作;
- 快速原型和中小型项目,不想引入一层额外的XML解析和路径配置。
当然,注解映射不是银弹。复杂动态SQL、超长联表查询、需要复用同一段SQL片段的场景,XML反而更合适。这也是为什么很多团队采用“注解+XML”混合模式——简单操作用注解,复杂操作用XML。关于这个选型,我后面会详细讲。
1.2 注解和XML怎么选,我的判断标准
很多新手一上来就问“到底用注解还是用XML”,其实这个问题没有标准答案,得看场景。我先说下自己的判断标准,也算是一点经验:
第一,SQL复杂程度。如果SQL里出现大量动态判断,比如<if>、<choose>、<foreach>这些标签嵌套,注解里写起来非常痛苦。MyBatis注解虽然也支持动态SQL,但要借助@SelectProvider或者@SqlProvider,写起来比XML里的标签复杂得多。这时候果断用XML,别硬扛。
第二,SQL复用程度。同一个查询条件可能在不同查询里反复出现,XML里可以用<sql>标签定义片段然后<include>引用。注解方式没有对应的简洁处理,只能把公共SQL写成一个常量字符串,然后用Java的字符串拼接。说实话,拼起来容易出错,也不直观。
第三,团队习惯和代码评审流程。如果团队里大部分人都习惯打开XML看SQL,那统一XML风格对维护更友好。如果是小型团队或者个人项目,我倾向于注解,因为文件少,定位快。
第四,性能上两者没有本质区别。这点经常有人误解,觉得注解SQL解析会慢,其实MyBatis对注解和XML的SQL解析都发生在启动阶段,运行时走的是同一套执行器,性能差距可以忽略。
所以我的建议是:以注解为主,XML兜底。简单操作用注解,遇到复杂动态SQL再单独写XML文件,两者可以通过@ResultMap互相引用,不存在非此即彼的问题。这个混合思路接下来会贯穿全文,实操部分我会演示具体怎么切换。
2. 核心注解逐个拆解,看完就能上手
2.1 四个基础SQL注解:@Select、@Insert、@Update、@Delete
MyBatis注解映射的基石就是这四个注解,分别对应查询、插入、更新、删除四种操作。用法直接写在接口方法上,value值就是SQL语句。
public interface UserMapper { @Select("select * from user where id = #{id}") User selectById(Long id); @Insert("insert into user(name, age, email) values(#{name}, #{age}, #{email})") int insert(User user); @Update("update user set name = #{name}, age = #{age} where id = #{id}") int update(User user); @Delete("delete from user where id = #{id}") int deleteById(Long id); }写入数据库时,这几个注解要注意几个容易被忽略的细节:
@Select的返回类型可以是实体类、Map、List、Integer、String等任意类型,MyBatis会自动完成映射和转换。如果查询结果有多条记录但返回类型是普通对象,MyBatis会报TooManyResultsException,这个坑后面排查章节会详细说。
@Insert的返回值是int类型,表示受影响的行数。如果插入时还需要拿到数据库自动生成的自增主键,光靠@Insert不够,要配合@Options注解设置useGeneratedKeys和keyProperty,这个我在3.3小节里专门演示。
@Update和@Delete同理,返回值都是影响行数。一个常见的误区是有的同学把返回值写成boolean,MyBatis虽然支持,但含义是“影响行数大于0则为true”,如果有更新0行的情况,返回false可能会误导判断,建议统一用int。
四个注解的SQL里都可以用#{}占位符,MyBatis会创建PreparedStatement参数占位符,防止SQL注入。这一点比字符串拼SQL安全得多,务必养成用#{}的习惯,不要为了省事用${},除非是动态传递表名、列名这类无法用占位符的场景。
2.2 参数传递的细节:@Param与多参数绑定
用注解写SQL,参数传递是最容易踩坑的地方。单参数场景没什么问题,比如上面的selectById,方法只有一个Long id参数,SQL里的#{id}能直接对应上。但一旦方法有多个参数,情况就变了。
先看一个反面例子:
@Select("select * from user where name = #{name} and age = #{age}") List<User> selectByNameAndAge(String name, Integer age);这段代码运行时会报错,提示找不到参数name或age。原因是MyBatis对多参数方法默认使用param1、param2这样的命名规则,不会智能到自动去匹配方法参数名。除非你编译时加了-parameters参数并且MyBatis开启了相关配置,否则老老实实加@Param注解最稳妥。
正确的写法:
@Select("select * from user where name = #{name} and age = #{age}") List<User> selectByNameAndAge(@Param("name") String name, @Param("age") Integer age);加了@Param之后,SQL里的#{name}和#{age}就能准确绑定到对应参数了。
这里多说一句:即使只有一个参数,如果参数是Map或者List,也有讲究。传Map的时候,SQL里的#{key}会去Map里按key取值;传List或者数组时,通常配合<foreach>动态SQL使用,此时需要在@Param里指定一个别名,否则MyBatis默认以list或array作为参数名。
@Select("<script>select * from user where id in " + "<foreach collection='ids' item='id' open='(' separator=',' close=')'>" + "#{id}" + "</foreach></script>") List<User> selectByIds(@Param("ids") List<Long> ids);这里虽然用了字符串拼SQL,但注意#{}仍然是预编译占位符,<foreach>是MyBatis的XML标签,在注解里用<script>标签包起来之后就能识别,SQL注入风险依然可控。
2.3 结果映射全家桶:@Results、@Result、@ResultMap
查询结果如何映射成对象,这是注解映射里最核心也最容易出问题的地方。先说最简单的场景:如果数据库列名和实体类属性名完全一致,比如数据库列name对应实体类属性name,MyBatis会自动映射,什么都不用写。
麻烦的是字段名对不上的情况。比如数据库列叫user_name,实体类属性叫userName。以前用XML时,要么开启mapUnderscoreToCamelCase自动驼峰转换,要么在resultMap里手动映射。注解方式对应的就是@Results和@Result。
@Select("select id, user_name, age, email from user where id = #{id}") @Results(id = "userResultMap", value = { @Result(id = true, column = "id", property = "id"), @Result(column = "user_name", property = "userName"), @Result(column = "age", property = "age"), @Result(column = "email", property = "email") }) User selectByIdWithResultMap(Long id);几个要点解释一下:
@Results的id属性给这组映射规则起个名字,方便其他地方复用。复用时用@ResultMap("userResultMap"),注意这个注解引用的是上面定义的id值。如果你在另一个查询方法上想复用同一套映射,直接写:
@Select("select id, user_name, age, email from user where user_name like concat('%', #{name}, '%')") @ResultMap("userResultMap") List<User> selectByNameLike(String name);@Result的id = true表示这个字段是主键,对后面讲到的嵌套映射和二级缓存都有影响。column对应数据库列名,property对应实体类属性名,方向别搞反了。
还有一个常见痛点:查询返回Map<String, Object>时,列名会以数据库原生列名作为key,即使开了驼峰转换也不会自动变成驼峰风格,这个需要注意。如果需要按实体类风格返回,要么建一个接收对象,要么在SQL里给列名起别名,比如select user_name as userName。
2.4 关联查询与嵌套映射:@One和@Many
关联查询在XML时代用<association>和<collection>标签实现,注解方式对应的是@One和@Many,配合@Result使用。
先看一个典型的场景:一个订单对应一个用户,查订单时希望把用户信息也带出来。用注解实现如下:
public class Order { private Long id; private String orderNo; private Long userId; private User user; // getter/setter 省略 } @Select("select * from orders where id = #{id}") @Results(id = "orderResultMap", value = { @Result(id = true, column = "id", property = "id"), @Result(column = "order_no", property = "orderNo"), @Result(column = "user_id", property = "userId"), @Result(property = "user", column = "user_id", one = @One(select = "com.example.mapper.UserMapper.selectById", fetchType = FetchType.LAZY)) }) Order selectOrderWithUser(Long id);这里的逻辑是:查完订单后,把user_id这一列的值作为参数,调用UserMapper.selectById方法再查一次用户信息,填充到Order.user属性里。fetchType有LAZY和EAGER两种,LAZY代表懒加载,即真正访问到user属性时才去执行第二个查询;EAGER则立即查询。
一对多场景类似,用@Many:
public class User { private Long id; private String name; private List<Order> orders; // getter/setter 省略 } @Select("select * from user where id = #{id}") @Results(id = "userWithOrdersResultMap", value = { @Result(id = true, column = "id", property = "id"), @Result(column = "name", property = "name"), @Result(property = "orders", column = "id", many = @Many(select = "com.example.mapper.OrderMapper.selectByUserId", fetchType = FetchType.LAZY)) }) User selectUserWithOrders(Long id);这里column = "id"表示把查询结果里的id列值作为selectByUserId方法的参数,传入的是当前用户的id,以此查询该用户的所有订单。
@One和@Many的嵌套查询要注意N+1问题。懒加载能缓解一部分性能压力,但如果循环遍历集合挨个触发子查询,数据库压力会很大。复杂报表场景建议还是用XML写一次性联表查询,或者用@SelectProvider写动态SQL来控制。
3. 从零搭建一个注解映射的完整实操
3.1 环境准备和项目结构
这一节我用一个Spring Boot + MyBatis的完整示例,带你把注解映射从依赖引入到跑通CRUD整个流程走一遍。为了减少干扰,这里不引入MyBatis-Plus等增强框架,就用原生MyBatis,把注解映射的本真逻辑看清楚。
项目基础环境:
- JDK 8+
- Spring Boot 2.x(3.x也兼容,主要是mybatis-spring-boot-starter的版本要对应)
- MySQL 5.7/8.0
- Maven 3.6+
pom.xml里核心依赖如下:
<dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>注意:Spring Boot 3.x和Spring Boot 2.x对mybatis starter的版本要求不一样,3.x要用mybatis-spring-boot-starter 3.0+,否则可能出现兼容问题。我这里用的是2.3.2,对应Spring Boot 2.7.x,稳定性最好。
application.yml里配置数据源和MyBatis相关参数:
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password mybatis: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case建议开启,这样数据库user_name到userName的转换自动完成,@Result映射可以少写很多。log-impl配置成StdOutImpl,控制台直接打印SQL和参数,排查问题非常方便。
项目结构上,接口和实体类分层清晰就行:
com.example.demo ├── DemoApplication.java ├── entity │ └── User.java └── mapper └── UserMapper.java启动类加@MapperScan("com.example.demo.mapper")扫描Mapper接口,或者每个Mapper接口上单独加@Mapper注解,两种方式二选一。接口多了建议用@MapperScan,省得每个接口都写一遍。
3.2 单表CRUD的注解实现
建一张简单的用户表:
CREATE TABLE `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_name` varchar(50) NOT NULL, `age` int(11) DEFAULT NULL, `email` varchar(100) DEFAULT NULL, `create_time` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意表名我用了user,这在某些数据库里是保留字,如果报错可以改成t_user或者加上反引号。这里为了演示简洁,假设环境允许。
对应的实体类:
public class User { private Long id; private String userName; private Integer age; private String email; private Date createTime; // getter/setter 省略 }UserMapper接口,完整的单表CRUD:
public interface UserMapper { @Select("select * from user where id = #{id}") User selectById(Long id); @Select("select * from user where user_name = #{userName}") User selectByUserName(String userName); @Select("select * from user order by id desc limit #{limit}") List<User> selectRecentList(@Param("limit") int limit); @Insert("insert into user(user_name, age, email, create_time) values(#{userName}, #{age}, #{email}, #{createTime})") int insert(User user); @Update("update user set user_name = #{userName}, age = #{age}, email = #{email} where id = #{id}") int update(User user); @Delete("delete from user where id = #{id}") int deleteById(Long id); }这里有个细节值得展开:selectByUserName只有一个String参数,但没有加@Param。这个能不能正常工作?答案是能。MyBatis对单个简单类型参数有默认处理,#{userName}会从唯一参数对象中取值。但为了统一规范,我建议单参数也写上@Param,尤其是参数名和SQL占位符不一致的时候,能少踩很多坑。
插入时createTime如果为null,MyBatis会原样插入null,不会报错。如果数据库字段有默认值,实体类里没设置,MyBatis仍然会把null传给SQL,导致默认值不生效。解决方法是插入时判断一下,或者用数据库的NOW()函数,比如:
@Insert("insert into user(user_name, age, email, create_time) values(#{userName}, #{age}, #{email}, NOW())") int insert(User user);这样createTime就可以完全不依赖实体类属性,数据库自动填当前时间。
3.3 返回自增主键的几种写法
插入后马上要用到自增主键,这个需求太常见了。注解方式有两种主流写法,我一个个说。
第一种,@Options注解:
@Insert("insert into user(user_name, age, email, create_time) values(#{userName}, #{age}, #{email}, #{createTime})") @Options(useGeneratedKeys = true, keyProperty = "id") int insert(User user);执行完insert后,MyBatis会把数据库生成的自增id回填到user.getId()里。keyProperty指定回填到实体类的哪个属性,这里就是id。注意是回填,不是返回值。方法的返回值int仍然是受影响行数。
第二种,SQL里写SELECT LAST_INSERT_ID(),然后配合@SelectKey注解:
@Insert("insert into user(user_name, age, email, create_time) values(#{userName}, #{age}, #{email}, #{createTime})") @SelectKey(statement = "SELECT LAST_INSERT_ID()", keyProperty = "id", before = false, resultType = Long.class) int insert(User user);before = false表示在insert执行之后查询主键,resultType要跟主键字段类型对应。
实际开发中,@Options的方式更简洁,推荐优先使用。这里还要提醒一个细节:如果表的主键不是自增的,而是应用层生成的ID,比如雪花算法生成的Long型ID,那就不需要@Options,直接把ID设到实体类属性上,SQL里正常写入即可。不要画蛇添足去配置useGeneratedKeys。把简单场景复杂化,这是新手很容易犯的毛病。
3.4 动态SQL与Provider注解处理复杂查询
前面说过,注解方式写动态SQL不如XML直观,但也不是不行。MyBatis提供了@SelectProvider、@InsertProvider、@UpdateProvider、@DeleteProvider四个Provider注解,把SQL的构建逻辑抽到单独的类里,用Java代码拼接。
看一个按条件查询用户的例子。用户传入的参数是可选的,可能传name,可能传age,也可能都不传:
public interface UserMapper { @SelectProvider(type = UserSqlProvider.class, method = "selectByCondition") List<User> selectByCondition(UserQuery query); } public class UserSqlProvider { public String selectByCondition(UserQuery query) { return new SQL() {{ SELECT("*"); FROM("user"); if (query.getName() != null) { WHERE("user_name = #{name}"); } if (query.getAge() != null) { WHERE("age = #{age}"); } ORDER_BY("id desc"); }}.toString(); } }这里用了MyBatis自带的org.apache.ibatis.jdbc.SQL类,提供了一种类似流式的SQL构建方式。SQL类的好处是能自动处理空格和逗号,拼接不容易出错。如果你不习惯这种链式风格,也可以返回纯字符串拼接,但那种方式可读性差、容易拼接出错,不建议。
@SelectProvider的type指向Provider类,method指向类里的方法,方法返回值是String类型的SQL。注意Provider方法的参数:如果Mapper方法有@Param注解,Provider方法可以声明对应的参数,比如:
@SelectProvider(type = UserSqlProvider.class, method = "selectByNameAndAge") List<User> selectByNameAndAge(@Param("name") String name, @Param("age") Integer age); public String selectByNameAndAge() { return new SQL() {{ SELECT("*"); FROM("user"); WHERE("user_name = #{name}"); AND(); WHERE("age = #{age}"); }}.toString(); }Provider方法本身不一定要声明参数,因为SQL里的#{name}、#{age}占位符是通过MyBatis的参数绑定的,Provider只需要返回SQL模板就行。
但说实话,如果条件组合特别多、嵌套特别深,我还是倾向于建一个XML文件来处理。比如查询条件有十几个可选字段、需要多表关联、还有排序分页组合,Provider里写起来头大,XML里用<where>、<if>天然支持这些场景,阅读和维护都更省心。这跟我前面说的“注解为主,XML兜底”策略一致。
3.5 一对多关联查询的完整示例
继续用订单和用户的例子,把一对多关联查询完整跑通。先建订单表:
CREATE TABLE `orders` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_no` varchar(50) NOT NULL, `user_id` bigint(20) NOT NULL, `amount` decimal(10,2) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;实体类:
public class Order { private Long id; private String orderNo; private Long userId; private BigDecimal amount; private User user; // getter/setter 省略 } public class User { private Long id; private String userName; private Integer age; private List<Order> orders; // getter/setter 省略 }OrderMapper:
public interface OrderMapper { @Select("select * from orders where user_id = #{userId}") List<Order> selectByUserId(@Param("userId") Long userId); }UserMapper中增加查用户并带出订单列表的方法:
@Select("select * from user where id = #{id}") @Results(id = "userWithOrdersResultMap", value = { @Result(id = true, column = "id", property = "id"), @Result(column = "user_name", property = "userName"), @Result(column = "age", property = "age"), @Result(property = "orders", column = "id", many = @Many(select = "com.example.mapper.OrderMapper.selectByUserId", fetchType = FetchType.LAZY)) }) User selectUserWithOrders(Long id);这段嵌套查询的调用链路是:先执行select * from user where id = #{id}拿到用户信息,然后把这一行的id列值取出来,作为参数去执行OrderMapper.selectByUserId,最后把返回的订单列表设置到user.orders属性上。
我实际测试过这个例子,配合log-impl配置,控制台会打印两条SQL:第一条查用户,第二条在访问user.getOrders()时才触发查订单。这就是懒加载的效果。
需要注意,懒加载默认情况下未必生效。MyBatis的懒加载需要满足两个条件:一是fetchType设置为LAZY,二是在全局配置里没有关闭懒加载,aggressiveLazyLoading默认值为false才是预期行为,如果被改成true,懒加载就变成了“访问任意字段就加载全部”。建议在application.yml里显式配置一下:
mybatis: configuration: aggressive-lazy-loading: false还有一个常见问题:@One和@Many中的select属性写的是Mapper接口的全限定名加方法名,必须保证方法名拼写正确。如果接口和注解方法不在同一个包,写错路径时会报BindingException,提示找不到对应的映射语句。排查时重点核对是否为“接口全限定名.方法名”。
4. 常见问题与排查技巧实录
4.1 注解和XML同名方法冲突怎么办
有同学在Mapper接口里用注解写了一个selectById,同时又创建了XML文件,里面也定义了selectById,启动时发现直接报错。这是正常的,因为MyBatis规定同一个Mapper接口的同一个方法只能绑定一种SQL定义方式,重复定义会抛出IllegalArgumentException之类的错误。
实际项目里最稳妥的做法是明确规约:同一个Mapper接口,要么全注解,要么全XML,或者一个接口里部分方法用注解、部分方法用XML关联,但绝对不能出现“同一个方法既有注解又有XML”的情况。接口里SQL简单、没有动态判断的用注解;有复杂动态SQL、需要<sql>片段复用的用XML。
如果确实需要在注解方法上引用XML里的resultMap,可以用@ResultMap注解,它接受的是XML中<resultMap>的id值。这种情况下注解方法负责写SQL,XML只负责结果映射定义,两者职责不同,不算冲突。
4.2 结果集映射不上的那些经典原因
注解映射跑起来,最容易遇到的就是查出来的列映射不到Java对象属性上,字段全是null。我总结了几类高频原因,排个序:
第一,列名和属性名不一致,而且没开驼峰转换。比如数据库列user_name,实体类属性userName,MyBatis不会自动把下划线风格转成驼峰风格,除非你设置了map-underscore-to-camel-case: true,或者用@Result手写映射关系。这个最简单,也最容易忽略。
第二,嵌套查询的目标方法本身没写对SQL,或者目标方法返回类型和期望类型不一致。比如@Many指向的方法返回的是List<Order>,但实际写返回了Order,类型不匹配启动时不一定报错,运行时可能出现类型转换异常。
第三,@Results定义了一组映射后,下方的方法只对指定的列做映射,其他列保持默认Auto Mapping。当autoMappingBehavior设置为NONE时,那些没在@Result里显式指定的列就映射不上了。默认的autoMappingBehavior是PARTIAL,会自动映射没有显式指定的列,但如果你手动改过配置,就可能漏掉很多字段。检查一下这个配置项。
第四,实体类属性名写错了,比如userName写成了username,而数据库列是user_name。开启驼峰转换的前提下,username和user_name无法对应上,会得到null。这种低级错误尤其在复制粘贴时容易出,排查半天不如直接打印实体类toString看一眼。
给一个建议:排查结果映射问题时,先把MyBatis的日志打开(StdOutImpl),看SQL打印出来的列名,再对照实体类属性名,一目了然。我遇到过很多次,纠结了半天配置,最后发现就是列名和属性名大小写差了一个字母。
4.3 用到注解后缓存和事务还能正常工作吗
这个问题经常被问到,我可以明确回答:注解方式没有改变MyBatis的底层执行流程,一级缓存、二级缓存、Spring事务照常生效。
先看一级缓存。MyBatis的一级缓存是SqlSession级别的,默认开启,无法直接关闭(可以调localCacheScope为STATEMENT来达到类似效果)。注解方式执行SQL同样走SqlSession,所以同一个SqlSession内执行两次相同查询,第二次会命中缓存。Spring管理下,每次请求通常对应一个独立的SqlSession,一级缓存的生命周期跟这个SqlSession绑定。
二级缓存默认关闭,需要手动开启。注解方式开启二级缓存,在Mapper接口上加@CacheNamespace注解即可,类似XML里的<cache>标签:
@CacheNamespace(eviction = LruCache.class, flushInterval = 60000, size = 512, readWrite = true) public interface UserMapper { // ... }配置项含义:eviction是缓存回收策略,默认LRU;flushInterval是刷新间隔,单位毫秒;size是缓存对象个数;readWrite指定缓存是否序列化存取。开启后,同一个Mapper的查询结果会进入二级缓存,注意缓存的key包含SQL语句、参数和rowBounds,所以相同SQL相同参数才能命中。
二级缓存有个坑:如果开启了@CacheNamespace,但表数据被其他Mapper更新,而其他Mapper没有加入同一个缓存区域,就会产生脏读。因为MyBatis可能不知道这张表的数据已经被改了,返回给用户的是旧缓存。解决方案是让所有操作同一张表的Mapper共享同一个缓存namespace,可以用@CacheNamespaceRef指向同一个Mapper,或者统一用XML的<cache-ref>。这块稍不注意就会踩雷,建议小项目干脆别开二级缓存,省心。
再看事务。注解方式的Mapper方法一旦被Spring管理,和XML方式没有区别,直接在Service层方法上加@Transactional(rollbackFor = Exception.class)即可。事务的开启、提交、回滚都由Spring的DataSourceTransactionManager管理,跟SQL是写在注解里还是XML里毫无关系。
@Service public class UserService { private final UserMapper userMapper; private final OrderMapper orderMapper; public UserService(UserMapper userMapper, OrderMapper orderMapper) { this.userMapper = userMapper; this.orderMapper = orderMapper; } @Transactional(rollbackFor = Exception.class) public void createUserWithOrder(User user, Order order) { userMapper.insert(user); orderMapper.insert(order); } }这里有一个需要留意的点:如果在同一个类里内部调用createUserWithOrder,事务注解其实是失效的,因为Spring事务基于AOP代理,内部调用绕过了代理对象。这是Spring事务的经典问题,跟MyBatis无关,但很多人在Mapper用注解后遇到事务不生效,容易误解成注解映射的问题。排查事务问题时,先确认是不是内部调用导致代理失效。
5. 一些值得收藏的实战建议
代码写到后面,拼的不是会不会用某个注解,而是能不能稳定地不出问题。分享几个我在注解映射上积累的小习惯。
第一,每个Mapper接口的@Results尽量给id命名,后续方法用@ResultMap复用。这样即使实体类字段很多,映射规则也可以集中维护,不会每个方法都复制一大段@Result。改一次,所有引用到的地方都生效。
第二,SQL字符串里出现多个空格、换行时,用<script>标签包围,然后像写XML一样写SQL。MyBatis会把它当XML解析,<if>、<where>、<foreach>都能用。虽然代码看着像“注解里套XML”,但总比用Provider写一堆Java拼接要直观:
@Select("<script>" + "select * from user " + "<where>" + "<if test='name != null'>and user_name like concat('%', #{name}, '%')</if>" + "<if test='age != null'>and age = #{age}</if>" + "</where>" + "</script>") List<User> selectByCondition(@Param("name") String name, @Param("age") Integer age);第三,接口方法命名尽量见名知意,SQL注解上的注释别省。注解方式把SQL和Java放在一起,信息密度高,但如果不写注释,后来维护的人要一行行看SQL才能知道查询意图。我习惯在每个方法上加一行中文注释,写明业务含义和参数说明,对团队协作很有帮助。
第四,配置打印SQL的日志,locally只在开发环境保留,生产环境改成NoLoggingImpl或者去掉。日志打太多也有性能损耗,线上排查问题可以用动态开关的方式临时打开,记得用完关掉。
第五,一个容易被忽视的小点:@Select返回int或Integer时,查询结果为空会返回null,而不是0。如果业务上需要判断是否存在,更推荐用select count(1)带LIMIT 1的方式,或者直接返回对象判空,语义更清晰。
最后再分享一个扩展方向。注解映射用熟了以后,可以尝试把SQL里反复出现的片段抽成常量,甚至写一个简单的SQL构建工具类。比如所有查询都要过滤deleted = 0这种逻辑删除条件,就定义成常量字符串,插入到各方法SQL里。用Provider方式做就更灵活了,能做到同一套查询逻辑兼容不同表结构。但记住一个原则:方法越简单越不容易出错,过度抽象反而让维护成本上升。注解映射本身就是为了降低复杂度,别把它再搞复杂了。