MyBatis 关联查询的四种写法,为什么最后都变回了手写 XML

MyBatis 关联查询的四种写法,为什么最后都变回了手写 XML

先说一个最常见的业务场景,后面所有写法都围绕它:

  • 表:user(用户)、dept(部门)、role(角色)、user_role(用户角色中间表)
  • 需求:分页查用户列表,每行带出部门名称,以及该用户的角色列表(多对多)

一对一、多对一、多对多各占一个,标准 RBAC 结构,每个后台系统都有。

MyBatis 能做关联查询吗?能,至少有四种写法。但每一种都有它疼的地方。这篇把四种写法挨个过一遍,最后说说为什么四条路走到的都是同一个地方。


一、写法一:JOIN + 嵌套 resultMap,教科书答案

xml 复制代码
<resultMap id="userWithRelationsMap" type="User">
    <id column="id" property="id"/>
    <result column="name" property="name"/>
    <association property="dept" javaType="Dept">
        <id column="dept_id" property="id"/>
        <result column="dept_name" property="name"/>
    </association>
    <collection property="roleList" ofType="Role">
        <id column="role_id" property="id"/>
        <result column="role_name" property="name"/>
    </collection>
</resultMap>

<select id="selectUserPage" resultMap="userWithRelationsMap">
    select u.id,
           u.name,
           d.id   as dept_id,
           d.name as dept_name,
           r.id   as role_id,
           r.name as role_name
    from user u
    left join dept d       on u.dept_id = d.id
    left join user_role ur on u.id = ur.user_id
    left join role r       on ur.role_id = r.id
    order by u.id
</select>

能跑,文档里也是这么教的。但三个坑,用过的人都熟:

  1. resultMap 是纯手工活。主键列必须标 <id>,MyBatis 靠它对 join 结果去重,把一个用户的多行折叠回一个对象;列名冲突全靠别名,user.namedept.namerole.name 三列同名,不起别名直接互相覆盖。关联每多一层,resultMap 再长一截,而且是从头到尾的手工配置。

  2. 一对多加分页,结果是错的。分页插件把 LIMIT 追加在这条 join SQL 上,而 LIMIT 限制的是 join 之后的行数。每页 10 行、每个用户平均挂 2.5 个角色,这一页实际只有 4 个用户;count 统计的也是 join 行数而不是用户数。页面上的表现就是"每页条数忽多忽少",数据一多才发现。

  3. 结果集膨胀。用户 × 角色的行数在网络传输和内存里都乘了个系数,字段一多,传输量相当可观。

于是有了第二种写法。

二、写法二:分步查询,用 N+1 换 resultMap

把关联拆成子查询,主查询只查 user,关联按需触发:

xml 复制代码
<resultMap id="userMap" type="User">
    <id column="id" property="id"/>
    <result column="name" property="name"/>
    <association property="dept"
                 column="dept_id"
                 select="com.example.mapper.DeptMapper.selectById"/>
    <collection property="roleList"
                column="id"
                select="selectRolesByUserId"
                fetchType="lazy"/>
</resultMap>

<select id="selectUserPage" resultMap="userMap">
    select id, name, dept_id from user order by id
</select>

<select id="selectRolesByUserId" resultType="Role">
    select r.id, r.name
    from role r
    join user_role ur on ur.role_id = r.id
    where ur.user_id = #{userId}
</select>

resultMap 瘦了,分页也对了(LIMIT 作用在 user 表上)。代价转移到了 SQL 数量上:

查一页 100 个用户,就是 1 条主查询加 100 条部门加 100 条角色,201 条 SQL。这就是 N+1。

fetchType="lazy" 能把触发时机推迟到真正访问 getRoleList() 的那一刻,但"推迟"本身又带来新的不确定性:

  • 接口返回 User 直接序列化成 JSON,序列化器访问 roleList,懒加载在序列化阶段才触发,接口耗时抖动、行数不可预期;
  • 跨线程或异步任务里访问关联属性,SqlSession 已关闭,直接抛异常;
  • 给子查询传多个参数要用 column="{userId=id, deptId=dept_id}" 这种 map 语法,第一次见的人都会愣一下。

三、写法三:@One / @Many 注解,上限来得比想象中早

注解版,试图连 XML 都省掉:

java 复制代码
@Select("select id, name, dept_id from user where id = #{id}")
@Results({
    @Result(column = "id", property = "id", id = true),
    @Result(column = "name", property = "name"),
    @Result(column = "dept_id", property = "dept",
            one = @One(select = "com.example.mapper.DeptMapper.selectById")),
    @Result(column = "id", property = "roleList",
            many = @Many(select = "com.example.mapper.UserMapper.selectRolesByUserId"))
})
User selectById(Long id);

只有一个关联时,注解确实干净。再多就不行了:

  • 角色还要嵌套菜单(多级关联)?注解套注解,一行写到三百字符;
  • 需要列别名、需要 <id> 去重?注解的表达能力都有,但堆在一起已经没法排版,也没法 review;
  • 大多数注解流的最终结局,是方法上出现 @ResultMap("com.example.mapper.UserMapper.userWithRelationsMap"),绕了一圈,指回了 XML。

四、写法四:内存组装,用业务代码补偿框架

最后一派干脆不用 MyBatis 的关联能力:查询拆开,Java 里自己拼。

java 复制代码
public List<UserVo> listUserWithRelations(int page, int size) {
    List<User> users = userMapper.selectPage(page, size);          // 第 1 条
    if (users.isEmpty()) {
        return Collections.emptyList();
    }

    Set<Long> deptIds = users.stream()
            .map(User::getDeptId).collect(Collectors.toSet());
    Map<Long, Dept> deptMap = deptMapper.selectBatchIds(deptIds)   // 第 2 条
            .stream().collect(Collectors.toMap(Dept::getId, d -> d));

    List<Long> userIds = users.stream()
            .map(User::getId).collect(Collectors.toList());
    Map<Long, List<Role>> roleMap = roleMapper.selectByUserIds(userIds)   // 第 3 条
            .stream().collect(Collectors.groupingBy(RoleVo::getUserId));

    return users.stream().map(user -> {
        UserVo vo = new UserVo(user);
        vo.setDept(deptMap.get(user.getDeptId()));
        vo.setRoleList(roleMap.getOrDefault(user.getId(), Collections.emptyList()));
        return vo;
    }).collect(Collectors.toList());
}

平心而论,这是四种写法里 SQL 形态最健康的:三条批量语句,没有 N+1,没有行膨胀,分页正确。很多团队认真权衡之后,就停在这里。

但代价原样转移到了业务代码上:

  • 这六步样板,每个关联、每个接口都要重写一遍。用户带角色写了,角色带菜单再写,订单带明细又写;
  • SQL 的条数和顺序靠人肉编排,漏掉一步,就是页面上悄悄空着的字段。不报错,只是空着;
  • 装配逻辑散在 Service,两段查询之间的数据一致性靠自觉;
  • 十个人能写出十种组装风格,三年后没人敢动。

说白了,写法四是用 Java 代码手工补上了 MyBatis 缺失的那个关联装配层。

五、为什么殊途同归

把四种写法的账摆在一起:

写法 XML 量 SQL 形态 主要代价
JOIN + 嵌套 resultMap 最多 1 条 join 手配映射、列别名、分页错乱、行膨胀
分步查询 1 + N N+1;懒加载触发时机不可控
@One / @Many 注解 初期为零 1 + N 复杂后无法排版,最终指回 XML
内存组装 2~3 条批量 装配样板 × 每个关联,散落业务代码

看起来是四个选项,其实背后是同一个缺口。关联本来是对象结构上的事,"用户有哪些角色"是 User 自己的结构属性,声明一次就该管所有查询。MyBatis 却把它做成了每次查询时的一次性手工配置。

resultMap、column 传参、内存组装,本质都是"每个查询重新手工表达一遍关联"。表达关联的劳动没有消失,只是在 XML、注解、Java 三个地方轮流转,转到最后还是 XML 最能打,所以"最后都变回了手写 XML"。

MyBatis-Plus 的态度更干脆:只做单表增强,关联查询请回 XML。于是 MP 项目也一样,单表用 Wrapper,一到 join,殊途同归。

(MyBatis-Flex 后来补了 @RelationOneToMany 这类关联注解,方向对了,但也从侧面印证了:这个缺口是真的,而且是框架级的。)

六、另一种思路:关联声明一次,处处生效

后来我换了一种思路:关联是实体上的声明,不是每次查询的配置。

java 复制代码
@Entity
@Table(name = "user")
public class User {

    @Id
    private Long id;
    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "dept_id")
    @Fetch(FetchMode.BATCH)
    private Dept dept;

    @ManyToMany(mappedBy = "userList", fetch = FetchType.LAZY)
    @Fetch(FetchMode.BATCH)
    private List<Role> roleList;
}

声明一次,之后 userDao.findById(id) 直接返回装配好的对象,没有 resultMap,也没有组装代码。

抓取策略是个枚举,按场景换值:SIMPLE(逐条抓取,存在 N+1)、JOIN(联表,N+1 变 1+1)、BATCH(先查主表,再用 IN 批量抓关联,N+1 变 1+M)、NONE(这个查询不抓)。前四种写法各自的痛点,映射手配、N+1、注解排版、装配样板,到这里都退化成换一个枚举值的问题。

这套关联体系我陆陆续续做了一年多:一对一、一对多、多对多、抓取策略,还有那个绕不开的 N+1,都攒成了比较完整的机制。

结语

关联查询这件事,四种写法没有一种是"错的",选哪种都能交付。真正的问题是表达关联的劳动被框架留在了每一次查询里,于是一代代项目在同一个地方反复手工劳动。

评论区聊聊:你们项目现在用的是四种里的哪一种?有没有第五种?


相关链接

相关推荐
泡海椒40 分钟前
SpringBoot 集成 JQuick-Curl:Spring 环境完整接入教程,第三方接口调用别再到处手写模板类
后端
csdn2015_40 分钟前
java springboot项目,接收到的参数多个逗号
java·开发语言·spring boot
cui_hao_nan42 分钟前
Spring AOP 自调用、代理与 private 方法引发的诡异 NPE
java·后端·spring·ai开发
春风解人意1 小时前
从零开始学习嵌入式P35----数据库
数据库·嵌入式硬件·学习
sunoo-2291 小时前
【网络编程 + 数据库】select 与 epoll 核心区别详解 + SQLite 入门到 C 接口全攻略
linux·网络·数据库·vscode·学习·sqlite
TDengine (老段)1 小时前
TDengine 错误处理 — 错误码、异常传播、故障恢复
大数据·数据库·物联网·时序数据库·tdengine·涛思数据
码艺-Alimjan1 小时前
维吾尔语数字转文本
java·javascript·python·算法·go
zbyyd1 小时前
Linux 系统编程 :select/epoll 多路复用与 SQLite 数据库实战
linux·数据库·sqlite
俊昭喜喜里1 小时前
c#中的构造函数
开发语言·数据库·c#