MyBatis注解映射实战:核心注解、动态SQL与最佳实践
2026/9/15 4:02:25 网站建设 项目流程

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的返回类型可以是实体类、MapListIntegerString等任意类型,MyBatis会自动完成映射和转换。如果查询结果有多条记录但返回类型是普通对象,MyBatis会报TooManyResultsException,这个坑后面排查章节会详细说。

@Insert的返回值是int类型,表示受影响的行数。如果插入时还需要拿到数据库自动生成的自增主键,光靠@Insert不够,要配合@Options注解设置useGeneratedKeyskeyProperty,这个我在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);

这段代码运行时会报错,提示找不到参数nameage。原因是MyBatis对多参数方法默认使用param1param2这样的命名规则,不会智能到自动去匹配方法参数名。除非你编译时加了-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默认以listarray作为参数名。

@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);

几个要点解释一下:

@Resultsid属性给这组映射规则起个名字,方便其他地方复用。复用时用@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);

@Resultid = 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属性里。fetchTypeLAZYEAGER两种,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.StdOutImpl

map-underscore-to-camel-case建议开启,这样数据库user_nameuserName的转换自动完成,@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类的好处是能自动处理空格和逗号,拼接不容易出错。如果你不习惯这种链式风格,也可以返回纯字符串拼接,但那种方式可读性差、容易拼接出错,不建议。

@SelectProvidertype指向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里显式指定的列就映射不上了。默认的autoMappingBehaviorPARTIAL,会自动映射没有显式指定的列,但如果你手动改过配置,就可能漏掉很多字段。检查一下这个配置项。

第四,实体类属性名写错了,比如userName写成了username,而数据库列是user_name。开启驼峰转换的前提下,usernameuser_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是缓存回收策略,默认LRUflushInterval是刷新间隔,单位毫秒;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返回intInteger时,查询结果为空会返回null,而不是0。如果业务上需要判断是否存在,更推荐用select count(1)LIMIT 1的方式,或者直接返回对象判空,语义更清晰。

最后再分享一个扩展方向。注解映射用熟了以后,可以尝试把SQL里反复出现的片段抽成常量,甚至写一个简单的SQL构建工具类。比如所有查询都要过滤deleted = 0这种逻辑删除条件,就定义成常量字符串,插入到各方法SQL里。用Provider方式做就更灵活了,能做到同一套查询逻辑兼容不同表结构。但记住一个原则:方法越简单越不容易出错,过度抽象反而让维护成本上升。注解映射本身就是为了降低复杂度,别把它再搞复杂了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询