基于Spring Boot的MyBatis多表查询操作讲义 一、什么是多表查询与关联映射 在实际企业级应用中,数据往往分散在多张数据库表中,例如”用户表”和”订单表”、”文章表”和”评论表”。当我们需要同时获取这些关联数据时,就必须进行多表查询 。MyBatis作为持久层框架,提供了两种核心机制来处理多表查询的结果映射:
resultMap :自定义结果集映射规则,解决实体类属性名与数据库字段名不一致的问题,更重要的是支持嵌套映射 ,即通过association(一对一)和collection(一对多)标签将关联表的数据封装到实体类的关联属性中。
分步查询 :通过select属性指定另一个Mapper方法,先查询主表,再根据主表的结果”分步”查询关联表。可以配合延迟加载 提升性能。
核心标签:
<association>:一对一关联 (例如:订单 → 用户)
<collection>:一对多关联 (例如:用户 → 订单列表)
resultMap 的 extends 属性可复用映射规则
二、为什么要使用多表与关联映射的优势
面向对象导航 :通过实体类的嵌套对象(如Order.getUser())直接获取关联数据,符合Java面向对象思维,避免手动拼接多个查询结果。
解耦SQL与代码 :复杂的多表查询逻辑写在映射文件中,便于维护和优化。分步查询还能拆分SQL,提高复用性。
性能可控 :嵌套结果(单条SQL连接查询)性能较高;分步查询 + 延迟加载可按需加载关联数据,避免一次性获取大量无用信息。
解决N+1问题的灵活变通 :虽然分步查询可能导致N+1次SQL,但结合lazyLoadingEnabled和aggressiveLazyLoading可减轻问题;也可在业务层手动批量查询优化。
三、如何基于Spring Boot环境实现多表查询 环境准备
Spring Boot 3.x
MyBatis Starter、MySQL驱动
已有User、Order实体和对应的单表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, o.user_id, o.product_name, o.price, u.id AS uid, u.name, u.address FROM tb_order oJOIN 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 <resultMap id ="orderIncludeUser" type ="order" > <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" 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 { @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 中用别名 oid、uid 避免两表 id 列冲突。<association> 里的 <id> 非常重要——它告诉 MyBatis 用哪一列判断”是不是同一个用户”,避免重复对象。嵌套结果只执行 1 条 SQL ,性能最好,但 SQL 和映射都比较”重”。
方式二:分步查询(两条SQL) 优势 :可复用,支持延迟加载。
思路:
先查订单表,拿到 user_id。
当代码真正用到 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 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 { @Select("select * from user where id = #{id}") public User selectById (Long id) ; }
执行流程演示:
1 2 3 4 5 6 7 8 9 10 11 12 List<Order> orders = orderMapper.selectAllIncludeUser(); for (Order order : orders) { System.out.println(order.getProductName()); } for (Order order : orders) { System.out.println(order.getUser().getName()); }
延迟加载的好处 :如果我的页面只展示订单列表,不需要显示买家名字,那第二步 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 uLEFT
结果可能是:
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") 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" 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> 的关键区别:collection 用 ofType 指定集合元素类型 (因为集合本身已经是 List),association 用 javaType 指定属性类型。
方式二:分步查询 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 { @Select("select * from tb_order where user_id=#{uid}") public List<Order> selectOrdersByUid (Integer uid) ; }
xml
业务层调用示例 :
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; public List<User> listUsersWithOrders () { List<User> users = userMapper.selectAllPlain(); if (users.isEmpty()) { return users; } List<Long> userIds = users.stream() .map(User::getId) .collect(Collectors.toList()); List<Order> allOrders = orderMapper.selectByUserIdIn(userIds); Map<Long, List<Order>> ordersGroupByUid = allOrders.stream() .collect(Collectors.groupingBy(Order::getUserId)); 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; @Test void testUserSelectAll () { userMapper.selectAll().forEach(System.out::println); } @Test void testUserSelectAll2 () { userMapper.selectAll2().forEach(user -> { System.out.println(user.getId() + "====>" + user.getName() + "====>" + user.getAddress() + "====>" + user.getOrders()); }); } @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定位问题更简单
选择建议:
优先使用嵌套结果 :当需要一次性展示完整对象图,且关联数据量不大(如一页订单列表同时显示用户信息)时,一条连接查询效率最高。
分步查询 + 延迟加载 :当关联数据不是每次都必须使用时(例如管理后台,用户列表不一定要立即显示订单),可开启延迟加载避免浪费。注意避免循环调用导致N+1问题。
业务层手动分步 :适合需要对中间结果进行校验、转换或批量优化的场景。例如查询用户列表后,批量查出所有用户的订单(select ... where user_id in (...)),避免逐个查询。
注意事项:
使用collection时,ofType指定集合元素类型,javaType可省略(默认为ArrayList)。
分步查询的column属性支持传递多个参数:column="{userId=id, orderStatus=status}"。
延迟加载需要代理对象,注意序列化问题(JsonIgnore可防止无限递归)。
一对多嵌套结果如果左连接,数据库返回的记录数等于”主表记录 × 从表记录”,当一对多关系层级较深时可能导致结果集膨胀,此时分步查询更优。
通过掌握association、collection和分步查询,你可以在Spring Boot项目中灵活应对各种复杂关联场景,编写出既符合面向对象思想又高效的数据访问代码。