MyBatis中关联查询的四种写法速览
先说一个最常见的业务场景,后面所有写法都围绕它:
- 表:
user(用户)、dept(部门)、role(角色)、user_role(用户角色中间表) - 需求:分页查用户列表,每行带出部门名称,以及该用户的角色列表(多对多)
一对一、多对一、多对多各占一个,标准 RBAC 结构,每个后台系统都有。
MyBatis 能做关联查询吗?能,至少有四种写法。但每一种都有它疼的地方。这篇把四种写法挨个过一遍,最后说说为什么四条路走到的都是同一个地方。
一、写法一:JOIN + 嵌套 resultMap,教科书答案
<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>能跑,文档里也是这么教的。但三个坑,用过的人都熟:
- resultMap 是纯手工活。主键列必须标
<id>,MyBatis 靠它对 join 结果去重,把一个用户的多行折叠回一个对象;列名冲突全靠别名,user.name、dept.name、role.name三列同名,不起别名直接互相覆盖。关联每多一层,resultMap 再长一截,而且是从头到尾的手工配置。 - 一对多加分页,结果是错的。分页插件把 LIMIT 追加在这条 join SQL 上,而 LIMIT 限制的是 join 之后的行数。每页 10 行、每个用户平均挂 2.5 个角色,这一页实际只有 4 个用户;count 统计的也是 join 行数而不是用户数。页面上的表现就是"每页条数忽多忽少",数据一多才发现。
- 结果集膨胀。用户 × 角色的行数在网络传输和内存里都乘了个系数,字段一多,传输量相当可观。
于是有了第二种写法。
二、写法二:分步查询,用 N+1 换 resultMap
把关联拆成子查询,主查询只查 user,关联按需触发:
<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 都省掉:
@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 里自己拼。
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 这类关联注解,方向对了,但也从侧面印证了:这个缺口是真的,而且是框架级的。)
六、另一种思路:关联声明一次,处处生效
后来我换了一种思路:关联是实体上的声明,不是每次查询的配置。
@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,都攒成了比较完整的机制。
结语
关联查询这件事,四种写法没有一种是"错的",选哪种都能交付。真正的问题是表达关联的劳动被框架留在了每一次查询里,于是一代代项目在同一个地方反复手工劳动。
以上就是MyBatis中关联查询的四种写法速览的详细内容,更多关于MyBatis关联查询的资料请关注脚本之家其它相关文章!
相关文章
Maven将代码及依赖打成一个Jar包的方式详解(最新推荐)
这篇文章主要介绍了Maven将代码及依赖打成一个Jar包的方式,本文通过实例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下2023-05-05
spring一个项目多个模块聚合打包问题解决方案(最新推荐)
最近遇到个需求,针对后端解耦模块较多的项目,想在云端启动时简洁些只启动一个jar文件的情景,本文重点给大家介绍spring一个项目多个模块聚合打包问题解决方案,感兴趣的朋友一起看看吧2023-09-09


最新评论