做业务系统久了,你会觉得多表查询这关是真的绕不过去。以前写 JDBC,两个表字段一多,手动一个字段一个字段 set 进对象,能累到怀疑人生。后来换成 MyBatis,用 Map 接收结果集倒是快,可一旦遇到嵌套对象、集合,很多人就卡在“association 和 collection 到底怎么配”上了。这篇是 Mybatis 源码分析的第 11 篇,咱们把多表查询里那点事从头捋一遍:一对一到多对多,用 @One 和 @Many 做分步查询,以及 fetchType 到底怎么控制延迟加载。看完之后,你不仅能配好 ResultMap,还能从源码层面知道为什么会出现那一条条“神秘”的额外 SQL。
这不是一篇教你怎么复制粘贴的教程,而是把模式选型和背后原理讲透。适合已经会写基本 CRUD、但遇到复杂映射就发怵的同学,也适合想在面试里把“MyBatis 嵌套查询”讲出深度的开发者。
1. 多表查询映射模型:四种关系到底对应什么标签
1.1 从 resultType 到 resultMap:为什么联表必须换思路
单表查询时,MyBatis 允许直接用 resultType 指定一个 JavaBean,然后根据列名和属性名自动映射。这个操作在字段少的时候很爽,但多表一 join,问题立刻出现:两个表可能出现同名列,比如 user 表有 id,order 表也有 id,默认映射会把两个 id 塞到同一个属性里,后一个值覆盖前一个值,数据看起来就是错的。
所以多表查询的第一个原则就是:别再用 resultType 糊弄了,改用 resultMap 显式声明每一列去哪个属性。另一个原因是,JavaBean 里如果有一个“用户包含订单列表”这种属性,它不是简单扁平结构,必须用嵌套映射才能填进去。
一个典型的一对多 ResultMap 长这样:
<resultMap id="userWithOrders" type="User"> <id property="id" column="id" /> <result property="name" column="name" /> <collection property="orders" ofType="Order"> <id property="id" column="order_id" /> <result property="amount" column="amount" /> </collection> </resultMap>用 resultMap 之后,SQL 列名就算写的是别名,也能通过 column 一一绑到正确属性上。这个阶段最容易踩的坑是漏配<id>,导致 MyBatis 在做嵌套结果映射时无法判断哪些记录属于同一个父对象,于是同一个用户会被重复实例化多次,列表看起来像“数据膨胀”。
1.2 四种关系如何对应 association 和 collection
MyBatis 处理多表关系时只有两个嵌套标签:<association>和<collection>。一个表示“单个对象”,一个表示“集合”。但组合出来的业务关系有四种:
- 一对一:比如用户和身份证,User 里含 IdCard 对象,用
<association>。 - 多对一:比如多个 Order 属于一个 User,查询 Order 时嵌套一个 User 对象,也是
<association>。 - 一对多:比如一个 User 有多个 Order,User 里含 List<Order>,用
<collection>。 - 多对多:比如 User 和 Role,中间靠 user_role 表关联,最终表现为一个 User 里有一个 List<Role>,可以拆成“User 一对多 Role”来理解,映射上仍然是
<collection>。
很多人搞混多对一和一对多,根源在于“数据库看关系”和“对象模型看方向”。查询 Order 表,每个 order 只有一个 user 字段,所以 Order 的 User 属性用 association;而 User 的 orders 列表是一个集合,用 collection。同样一张 user 表、order 表,方向不同,标签就不同。
1.3 分步查询出现的理由:联表 SQL 的三大痛点
既然可以用 join 一次查出来,为什么 MyBatis 还要搞出分步查询?因为联合查询在真实项目里有几个绕不开的痛点。
第一是复杂 join 的 SQL 难维护。一个“用户+订单+订单明细+商品+类目”的五层嵌套,SQL 写出来几十行,一个字段别名写错就可能影响整个 ResultMap。
第二是过滤条件导致 resultMap 结果集重复。如果查询条件在子表上,比如“查所有下过单的用户”,join 后同一用户会出现多行。虽然 MyBatis 能通过<id>去重,但如果主表没有唯一键映射,重复不可避免。
第三是性能可控性差。join 一次把大树拖回来,如果业务只要主表数据,这种大结果集就浪费了。分步查询则可以先查主表,等真正要用关联数据时再发第二条 SQL。
分步查询的核心思想并不复杂:主查询先执行,得到主对象后,根据主对象里的一个或几个列值,再去执行另一条查询,把结果填到嵌套属性里。MyBatis 的 @One 和 @Many 就是专门为这条路设计的。
2. @One 和 @Many 分步查询:注解用法与源码串联逻辑
2.1 注解方式怎么配:一个例子看懂全流程
假设有 User 和 Order 两张表,需求是“查用户时顺便把该用户的订单也查出来”。纯注解可以这样做:
public interface UserMapper { @Select("SELECT id, name FROM user WHERE id = #{id}") @Results(id = "userMap", value = { @Result(id = true, property = "id", column = "id"), @Result(property = "name", column = "name"), @Result(property = "orders", column = "id", many = @Many(select = "com.example.mapper.OrderMapper.selectByUserId", fetchType = FetchType.LAZY)) }) User selectUserWithOrders(int id); }对应的 OrderMapper:
public interface OrderMapper { @Select("SELECT id, amount, user_id FROM order WHERE user_id = #{userId}") List<Order> selectByUserId(int userId); }注意@Many的 select 必须写全限定方法名,MyBatis 会根据这个名字去找到对应的 Mapper 方法并执行。column = "id"表示把主查询结果里的 id 列值,作为参数传给selectByUserId的userId参数。这里参数名能对应上是 MyBatis 通过@Param或反射参数名的机制,如果子查询方法有多个参数,就得用column = {}语法做显式映射。
@One的用法几乎一样,只是语义上对应 association:
@Result(property = "user", column = "user_id", one = @One(select = "com.example.mapper.UserMapper.selectById", fetchType = FetchType.EAGER))2.2 分步查询在源码里是如何被识别的
注解只是入口,真正干活的是 MyBatis 的解析和结果处理链路。先说解析阶段。
MapperAnnotationBuilder解析接口方法时,会扫描@Results里的每个@Result,如果发现其中配置了one或many,就会构造一个ResultMapping。这个ResultMapping内部会有nestedQueryId,同时hasNestedQueries设为 true。这就是分步查询的“身份标记”。
执行主查询时,DefaultResultSetHandler会遍历每个ResultMapping。当碰到hasNestedQueries == true的映射,它会把当前行的某些列取出来,交给ResultLoader。ResultLoader封装了 SqlSession、目标方法、参数值和加载类型。如果 fetchType 是 EAGER,就会立刻调用 executor 去执行子查询;如果是 LAZY,就先不执行,只把ResultLoader存进ResultLoaderMap,准备后续触发。
所以当你看到控制台先打印主 SQL,隔了一会儿才打印子 SQL,不用慌,这是 MyBatis 在按fetchType控制执行时机。
2.3 多个参数传给子查询:column 的扩展语法
业务复杂的场景下,子查询往往需要两个以上参数。比如 Order 明细想按“用户ID + 订单状态”过滤,可以这样写:
@Result(property = "orders", column = "{userId = id, status = order_status}", many = @Many(select = "com.example.mapper.OrderMapper.selectByUserAndStatus"))对应的子查询方法:
@Select("SELECT id, amount FROM order WHERE user_id = #{userId} AND status = #{status}") List<Order> selectByUserAndStatus(@Param("userId") int userId, @Param("status") int status);这里column使用了{参数名 = 列名}的格式。注意等号左边是子查询方法的参数名,右边是主查询结果集中的列名。这个语法经常被写反,导致 MyBatis 运行时告诉你找不到属性或参数不匹配。我在项目里见过最典型的错误就是把{userId = id}写成{id = user_id},结果子查询接到的参数值为 null,查出来的数据自然不对。
源码层面,解析column时 MyBatis 会把它拆成一个个ColumnPrefix和属性映射,最终在ResultLoader里通过MetaObject从主对象中取值,再组装成子查询的参数。
3. fetchType 控制延迟加载:配置、源码、代理对象机制
3.1 fetchType 三态与全局配置的关系
FetchType枚举只有三个值:DEFAULT、LAZY、EAGER。其中DEFAULT不是“默认立即加载”的意思,而是“跟随全局配置”。
全局配置有两个开关:
lazyLoadingEnabled:是否开启延迟加载。默认是 false,也就是说如果你不主动设置 fetchType,所有嵌套查询都会在查询主对象时立刻执行。aggressiveLazyLoading:激进延迟加载。默认在新版本里是 false。为 true 时,只要访问主对象任意属性,就会把全部懒加载属性都加载;为 false 时,只有访问到特定懒加载属性,才触发对应的子查询。
这三者结合后的行为:
| fetchType 值 | lazyLoadingEnabled=false | lazyLoadingEnabled=true |
|---|---|---|
| DEFAULT | EAGER(立即加载) | LAZY(延迟加载) |
| EAGER | 立即加载 | 立即加载 |
| LAZY | 延迟加载 | 延迟加载 |
也就是说,fetchType的优先级高于全局配置,你写了LAZY,即使全局开关没开,它仍然是懒加载。这一点很多人会记混,误以为必须开启全局lazyLoadingEnabled后@One的 LAZY 才生效。
3.2 源码里的代理对象是怎么诞生的
真正的延迟加载并不是“先不给属性赋值,等访问时再 set”,而是用代理对象罩住整个结果对象。
看DefaultResultSetHandler的createResultObject和applyNestedResultMappings附近代码,可以发现当某个ResultMapping的fetchType为 LAZY 时,MyBatis 并不会立刻执行子查询,而是创建了一个ResultLoader,里面保存了:
- 当前 SqlSession
- 主对象对应的
ResultMap - 子查询方法的
MappedStatement - 子查询参数
然后这个ResultLoader被放进ResultLoaderMap,通过ProxyFactory生成一个目标对象的代理实例。MyBatis 默认的代理工厂是 JavassistProxyFactory,老项目里也可能配置 CGLIB。代理对象里持有一份ResultLoaderMap,并且重写了目标对象的所有 getter。
当你调用user.getOrders()时,代理逻辑会先检查ResultLoaderMap里有没有名为orders的加载器。如果有,就调用loader.load(),执行子查询,把结果 set 进真正的目标对象中,然后从ResultLoaderMap移除这个加载器。下次再调用getOrders(),属性已经有值,不会再次发 SQL。
3.3 触发加载的边界:哪些方法会点燃子查询
延迟加载不是“只要访问对象任何方法就会加载”,也不是“只有访问该属性 getter 才加载”。它有一套具体规则:
- 访问配置了 LAZY 的那个属性对应的方法,会触发该属性的加载。
- 访问其他属性,默认不会触发加载。
- 如果配置了
aggressiveLazyLoading=true,访问任何属性都会触发所有懒加载属性的加载。 - 调用
lazyLoadTriggerMethods指定的方法,也会触发所有懒加载。这个配置默认值是equals,clone,hashCode,toString。
项目中常见的“怪现象”是:明明只打印了一个 user 对象,结果控制台一下子刷新了好几条子查询 SQL,十有八九是调用了toString()。因为日志类经常在输出时自动调用 toString,toString 属于默认的触发方法,于是所有懒加载属性跟着全被加载了。这个不能算是 MyBatis 的 bug,而是你的日志语句把代理对象整个“烤”了一遍。
4. 实操验证:日志里看 SQL 执行顺序和延迟加载效果
4.1 搭一个可复现的 Demo 工程
纸上谈兵没意思,直接搭个能跑的小项目。表结构就三张:
- user:id, name
- orders:id, user_id, amount
- role:id, role_name
- user_role:user_id, role_id
工程里只需要 MyBatis 和对应驱动,加上一个能输出 SQL 的日志实现。我用的是 logback +org.apache.ibatis日志级别设为 DEBUG。
实体类示意:
public class User { private Integer id; private String name; private List<Order> orders; private List<Role> roles; // getter/setter 省略 }然后定义两个查询方法,一个用 @Many 做分步查询,一个用 XML 的 collection 做 join 查询。通过日志对比即可。
4.2 立即加载与延迟加载的 SQL 时间线对比
先看 fetchType 为 EAGER 的例子:
@Result(property = "orders", column = "id", many = @Many(select = "com.example.mapper.OrderMapper.selectByUserId", fetchType = FetchType.EAGER))执行selectUserWithOrders(1)时,控制台会立刻输出两条 SQL:
SELECT id, name FROM user WHERE id = ? SELECT id, amount, user_id FROM order WHERE user_id = ?时间上几乎是连续的,没有间隔。
把 fetchType 改成 LAZY 后,执行同样一句代码,控制台只会先出现第一条 user SQL,然后立刻返回。等代码里真正调用user.getOrders()时,第二条 order SQL 才会打印出来。如果你用 debug 在getOrders()前后打断点,还能观察到代理对象的方法调用栈里出现JavassistLazyLoadingLoader之类的内容。
这个对比非常直观地印证了 MyBatis 的实现思路:延迟加载不是“提前把 SQL 缓存起来”,而是“把执行 SQL 的动作推迟到属性访问那一刻”。
4.3 分步查询的 N+1 问题与分页陷阱
分步查询不是银弹,它最大的副作用是 N+1 问题。假设你查出来 100 个用户,然后在循环里挨个访问user.getOrders(),自然会产生 1 条主查询 + 100 条子查询,数据库压力瞬间拉满。
怎么规避?两个思路:
- 如果确定页面一定会展示关联数据,就别用 LAZY,而是用 EAGER 或直接 join 查询,一次拿全。
- 如果一定要用分步查询,尽量避免在循环内触发懒加载,改成提前在事务内批量查询好关联数据,再组装。
另外还要小心分页。用 PageHelper 做分页时,如果主查询是分步查询,它只能对第一层 SQL 做 limit 处理,子查询不受分页约束。这种场景下列表总条数和实际业务数据可能对不上。我在一个报表项目里就吃过这个亏:用户列表分了页,但每个用户的订单集合是全部加载的,页面显示“共 10 条”,实际主表有 100 条,只是 limit 截断得很巧妙,让人误以为分页生效。
5. 常见问题与排查技巧实录
5.1 延迟加载“失效”的四种典型场景
很多人反馈“我明明配了 LAZY,怎么一查主表,子查询还是立刻执行了”。排查时先按顺序检查四件事。
一是看 fetchType 是否真的生效。如果fetchType没写,全局lazyLoadingEnabled又是 false,那默认就是立即加载,完全不奇怪。
二是看是不是用了@Results但没被正确引用。MyBatis 注解模式下,如果类上还配了@ResultMap或者多个方法共用同一个@Results,可能出现配置被覆盖。
三是看访问方式。service 层如果先调用了user.toString()或user.equals(...),默认触发方法会把所有懒加载属性全部加载,看起来就像“没有懒加载”。想要避免,可以把lazyLoadTriggerMethods改成空集合,或者确保日志输出时避开这些触发方法。
四是看代理对象是否真的创建。如果实体类没有公共构造器,或者实体类是 final 类,Javassist 代理可能失败,MyBatis 会退化为无代理模式,此时延迟加载配置不生效。
5.2 @One 和 @Many 传参错误导致查不出数据
分步查询常见报错或数据为空,根源往往是 column 传参问题。
第一种错误是 column 只传了一个列,但子查询方法需要多个参数,MyBatis 找不到参数时可能抛ReflectionException: There is no setter for property named,也可能静默地把参数值置为 null。
第二种错误是子查询方法的参数名没有用@Param标注,而 MyBatis 在某些版本下通过反射获取参数名失败,导致#{userId}找不到。解决办法是在子查询方法参数上显式加@Param("userId")。
第三种错误是 column 引用的列名在主查询 SQL 中不存在。比如主查询里 SELECT id, name,但 column 写了 user_id,结果集里没有这个列,MyBatis 取不到值。
5.3 关闭 SqlSession 后访问懒加载属性导致异常
这个异常在 web 项目里非常常见,尤其是在手动管理 SqlSession 的程序里:
- 先在一个方法里查出了 User,SqlSession 正常关闭。
- Controller 层直接拿 User 去渲染视图,模板引擎调用
user.getOrders()。 - 结果抛
org.apache.ibatis.exceptions.PersistenceException,提示Executor was closed。
原因很简单:代理对象持有的ResultLoader依赖原来的 SqlSession,SqlSession 关闭后,子查询没有可用的执行器。解决方案有几种:
- 在 SqlSession 关闭之前,主动先访问一遍所有需要的懒加载属性,把数据加载出来。
- 使用 Spring 管理事务时,确保懒加载过程发生在事务方法内部。Spring 提供的
OpenSessionInView模式能在一定程度上延后 SqlSession 生命周期,但这属于治标,如果涉及跨线程访问仍然无效。 - 最稳妥的办法是在需要完整数据的接口里,改用 EAGER 或 join 查询,从源头消灭延迟加载的需求。
5.4 快速定位延迟加载问题的调试技巧
我调试这类问题一般按住三个地方看。
第一,看 SQL 日志的时间线。何时打印主查询、何时打印子查询,能直接判断 fetchType 是否生效。如果子查询在主查询返回前就出现,说明配置或全局开关有问题。
第二,在DefaultResultSetHandler.createResultLoader方法上打断点。这个方法是生产ResultLoader的入口,命中一次就能看到当前 ResultMapping 的 nestedQueryId、fetchType、参数来源。能看到这些信息,基本能定位 80% 的分步查询问题。
第三,打印目标对象的运行时类型。如果看到类似User$JavassistLazyLoadingProxy的类名,说明代理生效;如果打印出来是原生User,延迟加载肯定没走通。
最后分享一个我自己的习惯:报表、列表、需要批量展示关联数据的场景,我几乎不用分步查询,老老实实写 join + resultMap,性能可控且 SQL 容易调优;只有树形菜单、详情页这种可能只查一次主记录、关联数据按需展现的场景,才用 @One/@Many 做分步查询,再配合 LAZY。这个选择不是技术上的绝对优劣,而是业务场景决定 SQL 模式。想清楚这一点,再回头去看源码就不会迷路。