Mybatis多表查询

基于Spring Boot的MyBatis多表查询操作讲义

一、什么是多表查询与关联映射

在实际企业级应用中,数据往往分散在多张数据库表中,例如”用户表”和”订单表”、”文章表”和”评论表”。当我们需要同时获取这些关联数据时,就必须进行多表查询。MyBatis作为持久层框架,提供了两种核心机制来处理多表查询的结果映射:

  • resultMap:自定义结果集映射规则,解决实体类属性名与数据库字段名不一致的问题,更重要的是支持嵌套映射,即通过association(一对一)和collection(一对多)标签将关联表的数据封装到实体类的关联属性中。
  • 分步查询:通过select属性指定另一个Mapper方法,先查询主表,再根据主表的结果”分步”查询关联表。可以配合延迟加载提升性能。

核心标签:

  • <association>:一对一关联(例如:订单 → 用户)
  • <collection>:一对多关联(例如:用户 → 订单列表)
  • resultMapextends 属性可复用映射规则

二、为什么要使用多表与关联映射的优势

  1. 面向对象导航:通过实体类的嵌套对象(如Order.getUser())直接获取关联数据,符合Java面向对象思维,避免手动拼接多个查询结果。
  2. 解耦SQL与代码:复杂的多表查询逻辑写在映射文件中,便于维护和优化。分步查询还能拆分SQL,提高复用性。
  3. 性能可控:嵌套结果(单条SQL连接查询)性能较高;分步查询 + 延迟加载可按需加载关联数据,避免一次性获取大量无用信息。
  4. 解决N+1问题的灵活变通:虽然分步查询可能导致N+1次SQL,但结合lazyLoadingEnabledaggressiveLazyLoading可减轻问题;也可在业务层手动批量查询优化。

三、如何基于Spring Boot环境实现多表查询

环境准备

  • Spring Boot 3.x
  • MyBatis Starter、MySQL驱动
  • 已有UserOrder实体和对应的单表CRUD能力(课前已学)

数据模型示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
create table `tb_order`
(
id bigint auto_increment
primary key,
user_id bigint null,
product_name varchar(100) null,
price decimal(10, 2) null
);

create table user
(
id bigint auto_increment
primary key,
name varchar(50) null,
address varchar(100) null
);

实体类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User implements Serializable {
private Long id;

private String name;

private String address;

// 一个用户对象,可以包含多个订单对象
private List<Order> orders;
}

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Order implements Serializable {


private Long id;

private Long userId;

private String productName;

private Double price;
// 每个订单对象,只能找到一个所属的用户对象
private User user;
}

解释: User 类中持有 List<Order> orders,体现”一个用户对应多个订单”的一对多关系;Order 类中持有 User user,体现”每个订单只属于一个用户”的一对一关系。这种双向关联是后面做关联映射的基础。

1. 一对一映射(使用 association)

场景:查询订单及其对应的用户信息。

方式一:嵌套结果(一条SQL连接查询)

思路:写一条 JOIN 的 SQL,一次性把订单表和用户表的数据都查出来,然后通过 <association> 告诉 MyBatis 如何把结果集中”用户那几列”封装成 Order 对象里的 user 属性。

1
2
3
4
5
6
7
8
9
10
SELECT 
o.id AS oid, -- 订单id起个别名,防止和用户id重名
o.user_id,
o.product_name,
o.price,
u.id AS uid,
u.name,
u.address
FROM tb_order o
JOIN user u ON o.user_id = u.id

执行结果类似这样:

oid user_id product_name price uid name address
101 1 手机 2999.0 1 张三 北京
102 1 耳机 199.0 1 张三 北京
103 2 电脑 5999.0 2 李四 上海

XML映射:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!--嵌套结果方式:一条 SQL 查出所有字段,手动把用户的字段映射到 user 属性-->
<!--
id="orderIncludeUser" : 给这个映射规则起个名字,后面好用
type="order" : 要映射成什么类型的对象(这里就是 Order 类)
-->
<resultMap id="orderIncludeUser" type="order">
<!-- 主键字段用 <id> 标签,MyBatis 用它判断是不是同一个对象 -->
<id property="id" column="oid"/>
<result property="userId" column="user_id"/>
<result property="productName" column="product_name"/>
<result property="price" column="price"/>

<!-- 重点来了:association 表示一对一关联
property="user" : 数据要填到 Order 对象的 user 属性里
javaType="user" : 这个属性的类型是 User 类-->
<association property="user" javaType="user">
<!-- 用户表的主键 -->
<id property="id" column="uid"/>
<result property="name" column="name"/>
<result property="address" column="address"/>
</association>
</resultMap>

这样,MyBatis 执行完 SQL 后,就会返回一个 List<Order>,每个 Order 里面的 user 属性都已经填好了。因为是一条 SQL 查出来的,所以效率很高

Mapper接口:

1
2
3
4
5
6
7
public interface OrderMapper {
// 使用 @ResultMap 引用 XML 中定义的 orderIncludeUser 映射规则
@Select("select o.id oid, user_id, product_name, price, u.id uid, name, address " +
"from tb_order o, user u where o.user_id = u.id")
@ResultMap("orderIncludeUser")
public List<Order> selectAllIncludeUserNested();
}

调用:

1
2
3
4
5
6
7
@Test
void testOrderSelectAllNested() {
orderMapper.selectAllIncludeUserNested().forEach(order -> {
System.out.println(order.getId() + " => " + order.getProductName()
+ " => " + order.getUser());
});
}

解释: SQL 中用别名 oiduid 避免两表 id 列冲突。<association> 里的 <id> 非常重要——它告诉 MyBatis 用哪一列判断”是不是同一个用户”,避免重复对象。嵌套结果只执行 1 条 SQL,性能最好,但 SQL 和映射都比较”重”。

方式二:分步查询(两条SQL)

优势:可复用,支持延迟加载。

思路:

  1. 先查订单表,拿到 user_id
  2. 当代码真正用到 order.getUser() 的时候,再拿着 user_id 去查用户表。

XML映射:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<resultMap id="orderResultMap" type="order">
<id property="id" column="id"/>
<result property="productName" column="product_name"/>
<result property="price" column="price"/>
<result property="userId" column="user_id"/>

<!--
association 里的 select 属性:指定第二步要调用的查询方法(全限定名)
column="user_id" :把第一步查到的 user_id 列的值,作为参数传给第二步
fetchType="lazy" :延迟加载,用到了才查,不用就不查
-->
<association
property="user"
javaType="user"
column="user_id"
select="com.xxx.mapper.UserMapper.selectById"
fetchType="lazy"
/>
</resultMap>

修改OrderMapper接口:

1
2
3
4
5
6
7
8
9
public interface OrderMapper {

@Select("select * from tb_order")
@ResultMap("orderResultMap")
public List<Order> selectAllIncludeUser();

@Select("select * from tb_order where user_id=#{uid}")
public List<Order> selectOrdersByUid(Integer uid);
}

UserMapper定义:

1
2
3
4
5
6
public interface UserMapper {

// 第二步调用的方法:根据用户 id 查用户
@Select("select * from user where id = #{id}")
public User selectById(Long id);
}

执行流程演示:

1
2
3
4
5
6
7
8
9
10
11
12
// 1. 执行这个方法,只发出一条 SQL:select * from tb_order
List<Order> orders = orderMapper.selectAllIncludeUser();

// 2. 遍历订单,但先不访问 order.getUser()
for (Order order : orders) {
System.out.println(order.getProductName()); // 只打印商品名,不会查用户表
}

// 3. 当代码执行到这里,访问了 order.getUser()
for (Order order : orders) {
System.out.println(order.getUser().getName()); // 此时才会发出第二条 SQL 去查用户
}

延迟加载的好处:如果我的页面只展示订单列表,不需要显示买家名字,那第二步 SQL 根本不会执行,省了数据库资源。

<association> 各属性详解:

属性 作用
property 要封装到 Order 实体中的哪个属性(这里是 user)
javaType 这个属性的 Java 类型
column 把主查询结果的哪一列值,作为参数传给下一步查询(这里传 user_id)
select 要调用的第二步方法的全限定名(namespace.方法名)
fetchType lazy=延迟加载(用到 user 时才查), eager=立即加载

开启延迟加载(application.yml):

1
2
3
4
mybatis:
configuration:
lazy-loading-enabled: true # 全局开启延迟加载
aggressive-lazy-loading: false # 按需触发(访问关联属性才加载)

业务层调用:

1
2
3
4
5
6
7
@Test
void testOrderSelectAll() {
orderMapper.selectAllIncludeUser().forEach(order -> {
System.out.println(order.getPrice() + "====>" + order.getProductName()
+ "====>" + order.getId() + "====>" + order.getUser());
});
}

解释: 执行这段代码时,MyBatis 先执行 select * from tb_order 得到 N 条订单。由于开启了 lazy,只有在代码真正访问 order.getUser() 时,才会触发 select * from user where id = ? 的第二条 SQL。如果只关心订单本身,不碰 user,那第二条 SQL 根本不会发,这就是延迟加载省资源的核心价值。

第二部分:一对多查询(用户 → 多个订单)

场景:我想看所有用户,并且每个用户下面要列出他的所有订单。

方法一:一条 SQL 连接查询(嵌套结果)

SQL 语句:

1
2
3
4
5
SELECT 
u.id AS uid, u.name, u.address,
o.id AS oid, o.product_name, o.price
FROM user u
LEFT

结果可能是:

uid name address oid product_name price
1 张三 北京 101 手机 2999.0
1 张三 北京 102 耳机 199.0
2 李四 上海 103 电脑 5999.0

注意:张三这个人出现了两次(因为他有两个订单)。

2. 一对多映射(使用 collection)

场景:查询用户及其所有订单。

方式一:嵌套结果(一条SQL连接查询)

Mapper接口:

1
2
3
4
5
6
7
public interface UserMapper {

@Select("select u.id uid, name, address, o.id oid, user_id, product_name, price " +
"from user u, tb_order o where u.id = o.user_id")
@ResultMap("userIncludeOrders") // 告诉mybatis查询的结果,按我们在 userIncludeOrders 里面指定的规则进行封装!!!!
public List<User> selectAll();
}

XML映射:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<resultMap id="userIncludeOrders" type="user">
<id property="id" column="uid"/>
<result property="name" column="name"/>
<result property="address" column="address"/>

<!--
collection 表示一对多关联
property="orders" : 数据要填到 User 对象的 orders 属性(这是个 List)
ofType="order" : 这个 List 里面装的是 Order 类型的对象
-->
<collection property="orders" ofType="order">
<id property="id" column="oid"/>
<result property="productName" column="product_name"/>
<result property="price" column="price"/>
</collection>
</resultMap>

注意:若一个用户有多个订单,结果集中用户信息会重复,MyBatis通过<id>自动合并到同一个User对象。

解释: 连接查询的结果行数 = 用户数 × 该用户的订单数。例如张三有 3 个订单,在结果集里”张三那行”会出现 3 次。MyBatis 发现外层 <id property="id" column="uid"/> 的值相同,就知道是同一个 User,把后面的订单追加到 orders 集合里,而不是创建新的 User

<collection><association> 的关键区别:collectionofType 指定集合元素类型(因为集合本身已经是 List),associationjavaType 指定属性类型。

方式二:分步查询

UserMapper接口:

1
2
3
4
5
6
7
public interface UserMapper {

// 分步查询:只查用户表,订单留给第二步
@Select("select * from user")
@ResultMap("userIncludeOrders2")
public List<User> selectAll2();
}

XML映射:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!--手动定义一个封装的规则  分步查询 -->
<resultMap id="userIncludeOrders2" type="user">
<id property="id" column="id"/>
<result property="name" column="name"/>
<result property="address" column="address"/>

<collection
property="orders"
ofType="order"
column="id"
select="com.xxx.mapper.OrderMapper.selectOrdersByUid"
fetchType="lazy"
/>
</resultMap>

OrderMapper接口:

1
2
3
4
5
6
public interface OrderMapper {

// 作为一对多分步查询的第二步:根据 user_id 查该用户所有订单
@Select("select * from tb_order where user_id=#{uid}")
public List<Order> selectOrdersByUid(Integer uid);
}

xml

1
2
3
4
<!-- OrderMapper.xml 这里不需要再为 selectOrdersByUid 配置 resultMap,
因为 tb_order 的列名和 Order 实体的属性几乎一致,
只需通过 mybatis.configuration.map-underscore-to-camel-case=true
开启驼峰映射,product_name 就会自动映射到 productName -->

业务层调用示例:

1
2
3
4
5
6
7
@Test
void testUserSelectAll2() {
userMapper.selectAll2().forEach(user -> {
System.out.println(user.getId() + "====>" + user.getName()
+ "====>" + user.getAddress() + "====>" + user.getOrders());
});
}

解释: <collection>column="id" 表示把用户表查询结果的 id 列值,作为参数 #{uid} 传给 OrderMapper.selectOrdersByUid

执行过程:先 select * from user 查出 N 个用户 → 对每个用户触发一次 select * from tb_order where user_id=? → 总共 1 + N 条 SQL,这就是典型的 N+1 问题。因此一对多的分步查询几乎必须搭配 fetchType="lazy",只有真正遍历 user.getOrders() 时才发第二条 SQL。

3. 业务层分步骤实现查询(手动控制)

有时我们不需要ORM自动分步查询,而是由业务层显式控制查询步骤,以达到更精细的事务管理或缓存策略。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Service
public class UserService {

@Autowired
private UserMapper userMapper;
@Autowired
private OrderMapper orderMapper;

/**
* 业务层手动分步查询:先查所有用户,再"一次性"批量查出所有订单,
* 最后在内存中按 userId 分组,装配到各个 User.orders 中。
* 这种做法只发 2 条 SQL,从根本上规避了 N+1 问题。
*/
public List<User> listUsersWithOrders() {
// 第一步:查所有用户
List<User> users = userMapper.selectAllPlain(); // 普通的 select * from user
if (users.isEmpty()) {
return users;
}

// 第二步:收集所有 userId,一次性批量查订单
List<Long> userIds = users.stream()
.map(User::getId)
.collect(Collectors.toList());
List<Order> allOrders = orderMapper.selectByUserIdIn(userIds); // where user_id in (...)

// 第三步:按 userId 分组
Map<Long, List<Order>> ordersGroupByUid = allOrders.stream()
.collect(Collectors.groupingBy(Order::getUserId));

// 第四步:装配到每个 User 对象中
users.forEach(u -> u.setOrders(
ordersGroupByUid.getOrDefault(u.getId(), Collections.emptyList())
));

return users;
}
}

这种做法完全透明,适合需要额外处理(如数据过滤、组装多个来源数据)的场景,也是分步查询的一种替代方案。

解释: 相比 MyBatis 自动分步(1+N 条 SQL),业务层手动分步只需固定 2 条 SQL:一条查用户,一条用 IN 查所有订单,在内存里分组装配。在用户量大时性能提升非常明显,是生产项目里的常见优化套路。

四、测试类完整示例

下面是完整的测试类,串联了前面三种查询方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@SpringBootTest
class Day29MybatisDuoBiaoApplicationTests {

@Autowired
private UserMapper userMapper;

@Autowired
private OrderMapper orderMapper;

/**
* 一对多-嵌套结果:一条 JOIN SQL 查出用户和订单,
* 通过 userIncludeOrders 的 <collection> 把订单合并到 User.orders
*/
@Test
void testUserSelectAll() {
userMapper.selectAll().forEach(System.out::println);
}

/**
* 一对多-分步查询:先查用户,再懒加载订单。
* 只有在访问 user.getOrders() 时,才会触发第二条 SQL。
*/
@Test
void testUserSelectAll2() {
userMapper.selectAll2().forEach(user -> {
System.out.println(user.getId() + "====>" + user.getName()
+ "====>" + user.getAddress() + "====>" + user.getOrders());
});
}

/**
* 一对一-分步查询:先查所有订单,再按 user_id 懒加载用户。
* 打印 order.getUser() 时才会触发 select * from user where id = ?。
*/
@Test
void testOrderSelectAll() {
orderMapper.selectAllIncludeUser().forEach(order -> {
System.out.println(order.getPrice() + "====>" + order.getProductName()
+ "====>" + order.getId() + "====>" + order.getUser());
});
}
}

解释: 三个测试用例分别对应前面讲的三种典型场景。建议在 application.yml 中配上 logging.level.com.zxq.day29_mybatis_duo_biao.mapper=debug,这样能在控制台直接看到 MyBatis 打印的 SQL,帮助你**直观感受”1 条” vs “1+N 条”**的区别,以及延迟加载何时触发。

五、总结:对比与最佳实践

对比维度 嵌套结果(一条SQL) 分步查询(两条或多条SQL)
SQL复杂度 较高(多表连接,注意字段别名) 低(单表查询,清晰简单)
网络/数据库交互 1次 N+1次(N为关联记录数),但延迟加载可减少无效查询
性能 适合数据量不大、关联表较少的情况 适合关联数据访问频率不确定的场景,配合延迟加载可节省资源
代码复用 较低(SQL专用) 高(分步查询的Mapper方法可复用)
调试 单条SQL易于优化,但结果映射复杂 多条SQL定位问题更简单

选择建议:

  1. 优先使用嵌套结果:当需要一次性展示完整对象图,且关联数据量不大(如一页订单列表同时显示用户信息)时,一条连接查询效率最高。
  2. 分步查询 + 延迟加载:当关联数据不是每次都必须使用时(例如管理后台,用户列表不一定要立即显示订单),可开启延迟加载避免浪费。注意避免循环调用导致N+1问题。
  3. 业务层手动分步:适合需要对中间结果进行校验、转换或批量优化的场景。例如查询用户列表后,批量查出所有用户的订单(select ... where user_id in (...)),避免逐个查询。

注意事项:

  • 使用collection时,ofType指定集合元素类型,javaType可省略(默认为ArrayList)。
  • 分步查询的column属性支持传递多个参数:column="{userId=id, orderStatus=status}"
  • 延迟加载需要代理对象,注意序列化问题(JsonIgnore可防止无限递归)。
  • 一对多嵌套结果如果左连接,数据库返回的记录数等于”主表记录 × 从表记录”,当一对多关系层级较深时可能导致结果集膨胀,此时分步查询更优。

通过掌握associationcollection和分步查询,你可以在Spring Boot项目中灵活应对各种复杂关联场景,编写出既符合面向对象思想又高效的数据访问代码。