先说一个最常见的业务场景,后面所有写法都围绕它:
- 表:
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关联查询的资料请关注代码网其它相关文章!
发表评论