做后端这些年,MyBatis的关联映射翻车现场我见过太多:明明SQL join出来了,结果对象里子集合是空的;加了resultMap之后,分页总数突然对不上了;还有那种日志里疯狂刷同一条SQL的N+1问题。这中间最核心的两个标签就是association和collection,一个管“有一个”,一个管“有多个”。这篇我就把这两个标签从基础配置、嵌套查询、性能问题到源码级别的组装逻辑彻底讲透,适合刚接触MyBatis关联查询的人入门,也适合写了好几年XML但一直靠复制粘贴、没系统捋过的人查漏补缺。
1. 关联映射不是“会写JOIN”就万事大吉:三个翻车现场
很多人的第一反应是:关联映射不就是SQL写个JOIN,然后MyBatis自动把结果装进对象里吗?但实际跑起来会发现完全不是这么回事。我先说三个我真实遇到过的坑,每个都对应后面会展开的某个核心机制。
1.1 翻车现场一:Article对象里死活装不进那个Author
我当时接手的项目里,文章列表要展示作者名,SQL铁定是JOIN了author表,结果集里也有author_name列。但Article实体里的Author属性始终是null。
原因在于MyBatis的自动映射只处理“扁平”字段,它把author_name映射到Author.authorName需要绕过一层对象引用,自动映射根本没有这个能力。这时就必须用association标签告诉MyBatis:“这个author列,请给我填到Article里面的Author对象里去”。
1.2 翻车现场二:所有订单的ID都变成了用户ID
这是association里最经典的错误:父表和子表都有id列。默认情况下,MyBatis遇到同名列,后面列的值会把前面列的值覆盖掉。当你查询SELECT u.*, o.* FROM user u LEFT JOIN order o ON ...时,如果Order对象里的id没有显式指定来源列,它拿到的极可能是user.id。
我当时排查了很久,最后发现order表的数据全乱套了,不是SQL错了,是association内部的列映射没写清楚,导致MyBatis按同名规则“张冠李戴”。这也是为什么我后面会一再强调:关联映射的列必须显式映射,哪怕你觉得字段名一样。
1.3 翻车现场三:一对多查询后,分页插件统计出的数量完全不对
一对多场景更隐蔽:比如一个订单有3个商品,你JOIN出来后结果集有3条物理记录,但业务上这是1个订单。PageHelper这类分页插件在对SQL做COUNT时,统计的是JOIN后的行数,于是你会看到“总记录数=3”,可实际上只有一个订单。
这个问题不是分页插件的bug,而是关联映射本身会放大结果集行数。后面第三章我会专门讲一对多场景下分页的正确处理思路,这里先记住一个结论:一对多JOIN天然会放大行数,分页前必须先想清楚你要分页的粒度是什么。
2. association:一对一/多对一场景的细节拆解
association对应的是“has one”语义:一个Article属于一个Author,一个Order属于一个User。它有两种主要使用方式:嵌套结果映射和嵌套查询。
2.1 嵌套结果映射:字段怎么对应,列别名怎么起
嵌套结果映射的意思是:SQL里已经把关联数据查出来了,MyBatis负责把结果集里的列“分拣”到两个对象里。典型的写法是这样:
<resultMap id="ArticleWithAuthorMap" type="com.example.Article"> <id property="id" column="article_id"/> <result property="title" column="title"/> <association property="author" javaType="com.example.Author"> <id property="id" column="author_id"/> <result property="name" column="author_name"/> </association> </resultMap> <select id="selectArticleWithAuthor" resultMap="ArticleWithAuthorMap"> SELECT a.id AS article_id, a.title AS title, au.id AS author_id, au.name AS author_name FROM article a LEFT JOIN author au ON a.author_id = au.id WHERE a.id = #{id} </select>这里有几个关键点:
第一,SQL里一定要给关联字段起别名。a.id AS article_id、au.id AS author_id,这样结果集里每个列的名字都是唯一的,不会出现我前面说的“订单ID变成用户ID”的情况。
第二,association里的column必须和SQL别名一致。上面的column="author_id"对应的是AS author_id,也就是说,resultMap里列的对应关系是直接跟SQL输出列名挂钩的,而不是跟实体字段名挂钩。这个关系搞错,关联对象的值就是null。
第三,javaType其实可以不写,因为MyBatis可以通过反射从Article.author属性的类型推断出来。但写上有两个好处:一是让阅读XML的人一眼看清类型,二是在某些没有泛型信息或构建工具链的极端场景下避免推断失败。我的建议是写上,成本很低。
association还支持在内部继续嵌套result、association甚至collection,所以一个文章实体可以同时携带作者、分类、评论列表。层级越深,结果集列名规划越要提前设计,后面第三章我会单独展开。
2.2 嵌套查询:什么时候该用,什么时候千万别用
嵌套查询是另一种思路:主查询先把Article查出来,然后针对每条Article再去执行另一条SQL查Author。写法如下:
<resultMap id="ArticleWithAuthorSelectMap" type="com.example.Article"> <id property="id" column="id"/> <result property="title" column="title"/> <association property="author" column="author_id" select="com.example.mapper.AuthorMapper.selectById"/> </resultMap> <select id="selectArticleWithAuthorSelect" resultMap="ArticleWithAuthorSelectMap"> SELECT id, title, author_id FROM article </select>column="author_id"表示把当前结果集中的author_id列的值取出来,作为参数传给select指向的那条SQL。
嵌套查询最大的好处是主查询和子查询解耦:子查询可以被多个地方复用,SQL本身也很好维护。但它的致命缺点是N+1问题:查出N篇文章,就会执行N次作者查询,日志里会看到同一条SQL刷屏。如果文章列表有100条,就要额外执行100次数据库查询。
所以我的经验是:嵌套查询只适合在确认数据量小、或必须按需加载(比如子对象很少被用到)的场景使用。查询列表页这种高频场景,要么用嵌套结果映射一把梭,要么干脆拆成两条SQL在Service层组装。
2.3 fetchType和全局懒加载配置:一个参数决定性能
association的select方式可以配合fetchType="lazy"实现懒加载:
<association property="author" column="author_id" select="com.example.mapper.AuthorMapper.selectById" fetchType="lazy"/>加了lazy之后,查询Article列表时并不会立刻去查Author,而是等代码里真正调用article.getAuthor()时才触发那条SQL。这里有个前提:全局配置里要允许懒加载。相关配置有两项:
mybatis.configuration.lazy-loading-enabled=true mybatis.configuration.aggressive-lazy-loading=falselazy-loading-enabled不言而喻,aggressive-lazy-loading的作用很多新手会忽略。当它等于true时,哪怕只访问article.getTitle(),也会把整个对象的所有懒加载属性全部加载出来,等于没懒。要把它设为false,才能做到“访问哪个懒属性才加载哪个”。
不过这只是理论上的“按需”,实际项目中我会默认关掉懒加载,原因很简单:懒加载必须在SqlSession还开着的时候触发。如果你在Service层事务内访问没问题,但一旦出了事务,或者用了某些异步线程,再碰懒加载属性就会抛org.apache.ibatis.exceptions.PersistenceException,提示SqlSession is already closed。这种运行时错误比N+1更难排查。
3. collection:一对多场景的正确打开方式
collection对应的是“has many”语义:一个Author有多篇文章,一个Order有多个商品。用法和association高度相似,但有几个独特的坑。
3.1 ofType、javaType到底怎么填
先看一段标准配置:
<resultMap id="AuthorWithArticlesMap" type="com.example.Author"> <id property="id" column="author_id"/> <result property="name" column="author_name"/> <collection property="articles" ofType="com.example.Article"> <id property="id" column="article_id"/> <result property="title" column="title"/> </collection> </resultMap>ofType表示集合里装的是哪种元素类型,这里是Article。很多新手会问:那javaType呢?javaType是表示集合本身的类型,默认MyBatis会给你填一个ArrayList。只有当你的实体属性声明的是接口类型(如List<Article>)且你希望MyBatis帮你new一个特定实现类时才需要指定,比如javaType="java.util.LinkedList"。
需要记住的口诀是:javaType说的是“容器”,ofType说的是“容器里的内容”。绝大多数场景只写ofType就够了。
3.2 嵌套结果实现一对多的列前缀策略
用嵌套结果实现一对多时,核心还是列名唯一性。看这个例子:
<resultMap id="OrderWithItemsMap" type="com.example.Order"> <id property="id" column="order_id"/> <result property="orderNo" column="order_no"/> <collection property="items" ofType="com.example.OrderItem"> <id property="id" column="item_id"/> <result property="productName" column="product_name"/> <result property="quantity" column="quantity"/> </collection> </resultMap> <select id="selectOrderWithItems" resultMap="OrderWithItemsMap"> SELECT o.id AS order_id, o.order_no AS order_no, oi.id AS item_id, oi.product_name AS product_name, oi.quantity AS quantity FROM orders o LEFT JOIN order_item oi ON o.id = oi.order_id WHERE o.id = #{id} </select>这里我用了order_id、item_id这样的语义化前缀,而不是o_id、oi_id,因为这不仅是给MyBatis看的,也是给半年后的自己看的。一旦你忘了加前缀,导致id列重复,后果立刻就来了——下一节讲。
3.3 三层嵌套:先设计好列别名,再动手写XML
真实业务中,层级往往不止两层。比如:查询文章详情,需要带出作者信息,还要带出这条文章的所有评论,每条评论要带出评论者的用户信息。这就是一个典型的“association里套collection,collection里再套association”结构。
我现在的习惯是先画“列名规划表”再写XML。比如这种结构:
- 文章:
a_id, a_title - 作者:
au_id, au_name - 评论列表:
c_id, c_content - 评论者:
u_id, u_nickname
然后SQL大概长这样:
SELECT a.id AS a_id, a.title AS a_title, au.id AS au_id, au.name AS au_name, c.id AS c_id, c.content AS c_content, u.id AS u_id, u.nickname AS u_nickname FROM article a LEFT JOIN author au ON a.author_id = au.id LEFT JOIN comment c ON c.article_id = a.id LEFT JOIN user u ON c.user_id = u.id WHERE a.id = #{id}XML里逐层嵌套。层级一多,最容易出的问题是:某个子对象的id列写错了前缀,导致结果集里的评论数不对、用户信息串了。所以我的建议很简单——嵌套层数超过两层时,优先考虑用嵌套查询,或者在Service层手动装配。别硬刚,硬刚的代价是排查成本指数级上升。
3.4 一对多场景碰上分页插件:经典翻车与标准解法
回到开头说的分页问题。一个订单有3个商品,JOIN后3行,PageHelper的count会算成3,limit也会作用在JOIN后的临时表上,最终导致每页订单数不符。
我总结过三种解法,按推荐程度排序:
| 方案 | 做法 | 适用场景 |
|---|---|---|
| 先分页主表,再查子表 | 先用分页插件查orders主表ID列表,再用WHERE order_id IN (...)查所有子表数据,最后在内存中组装 | 大多数业务场景,推荐 |
| 嵌套查询+懒加载 | collection的select配合fetchType="lazy",只对当前页的订单加载商品 | 数据量小、可接受N+1的场景 |
| JOIN后内存去重 | 直接JOIN查询,然后对结果集去重 | 数据量小、只查单页的场景 |
第一种是性能和可控性最均衡的。步骤是:
- 对
orders表本身执行分页查询,得到当前页订单。 - 收集订单ID列表。
- 用
SELECT * FROM order_item WHERE order_id IN (...)查出所有相关商品。 - Service层把商品按订单ID分组,set进对应订单对象。
这套方案的SQL简单清晰,也能配合缓存,比任何高级映射技巧都稳。MyBatis的关联映射确实能省事,但它不是用来包治百病的。
4. 性能红线:N+1查询、懒加载和二级缓存之间的博弈
关联映射做得多了,你就会发现真正难的从来不是怎么写resultMap,而是怎么控制SQL执行次数和数据一致性。
4.1 N+1怎么定位:不要凭感觉,先看SQL日志
N+1问题的症状非常典型:页面上一个列表接口,撑死查了10条主记录,结果数据库连接池被打满,日志里刷了一模一样的SQL几十遍。我在项目里帮同事定位过这类问题,最快的方法不是看代码逻辑,而是把MyBatis的SQL日志打出来,看执行序列:
==> Preparing: SELECT * FROM article WHERE ... ==> Preparing: SELECT * FROM author WHERE id = ? ==> Parameters: 1(Long) ==> Preparing: SELECT * FROM author WHERE id = ? ==> Parameters: 2(Long) ...看到这种“一条主查询后面跟着N条相同子查询”的规律,基本可以断定是嵌套查询写法没控制好触发时机。这时候优先确认:是否用了fetchType="lazy"?代码里是否在循环里调用了getAuthor()?如果都在合理范围内,那就要考虑换成嵌套结果映射或拆两条SQL。
4.2 懒加载的代理机制与事务边界
MyBatis实现懒加载,并不是简单地“先不查”,而是给关联对象生成了一个代理对象。当你调用author.getName()这类getter时,代理对象会拦截调用,触发后续SQL。这个代理机制的底层是ResultLoader和动态代理/CGLIB。
但这套机制有一个硬约束:触发SQL时必须有一个活着的SqlSession。我在实际项目中踩过的坑是:Service层方法加了@Transactional,事务内一切正常;后来把查询逻辑抽到一个没有事务的私有方法里,或者用@Async异步线程去访问懒加载属性,直接报Session已关闭。
所以如果项目是Spring Boot + MyBatis,用懒加载之前一定要想清楚事务边界。另外,@Transactional只是其中一环,真正决定生命周期的是SqlSession的开启和关闭时机。MyBatis-Spring整合时,SqlSessionTemplate通常每个事务/每次请求会打开和关闭SqlSession,事务外访问懒加载属性风险很高。
4.3 二级缓存对关联对象“不友好”的第二个原因
很多人以为加了二级缓存就万事大吉,但二级缓存有个隐藏问题:它缓存的是查询出来的对象本身,如果这个对象内部含有懒加载代理,就可能出现两种情况:
- 代理对象脱离SqlSession后不可用,缓存命中后直接取出来却访问不了子属性。
- 对象需要被序列化(比如你用Redis做二级缓存),但MyBatis生成的代理对象不一定能正常序列化。
另外,二级缓存是基于namespace的。如果你的ArticleMapper查询带出了作者信息,底层SQL涉及了author表,但AuthorMapper的缓存没失效,当author表被更新时,ArticleMapper缓存里的旧数据依然是脏的。这就是“关联查询与缓存失效”的矛盾。我在生产环境里吃过这个亏,所以项目里大多时候只开一级缓存,二级缓存只在单表查询、并发读多写少的场景下启用,关联查询尽量不用。
4.4 我的优化顺序:先减查询次数,再谈缓存
做关联查询性能优化,我的顺序一直很固定:
- 打开SQL日志,数清楚“一次业务请求实际执行了多少条SQL”。
- 如果执行条数是“1 + N”,优先合并:能用JOIN就JOIN,不能JOIN就用
IN查询批量组装。 - 如果合并后单条SQL数据量过大(结果集膨胀严重),改用“主表查询 + IN子查询 + 内存组装”。
- 最后才是考虑加缓存,而且优先加业务层缓存(比如Redis),不是无脑开MyBatis二级缓存。
这个顺序帮我避开了很多为了性能优化反而引入新bug的情况。
5. 源码级复盘:MyBatis到底怎么把JOIN结果拼成对象图
想彻底搞懂association和collection的边界行为,光会用不够,我建议花点时间看一眼MyBatis的结果集处理源码,不少“玄学”问题都能解释清楚。
5.1 从MapperProxy到DefaultResultSetHandler的调用链
一条Mapper接口方法调用,内部经过的路径大致是:
MapperProxy.invoke() -> MapperMethod.execute() -> SqlSession.selectOne/selectList() -> DefaultSqlSession.selectList() -> Executor.query() -> StatementHandler.query() -> ResultSetHandler.handleResultSets() -> DefaultResultSetHandler最终,结果集组装的重活全在DefaultResultSetHandler里。它是MyBatis里另一个容易被人忽略的核心类,如果你经常排查结果映射问题,一定要会看它的代码。
5.2 getRowValue与applyNestedResultMappings:重活都在这
DefaultResultSetHandler处理一行数据时,主要做几件事:
handleRowValues() -> handleRowValuesForSimpleResultMap() // 普通映射(无嵌套) -> handleRowValuesForNestedResultMap() // 嵌套结果映射(association/collection)在handleRowValuesForNestedResultMap里,有一个非常关键的设计:它会根据<id>列的值构造一个CacheKey,用来判断“当前这一行是否属于前面已经构建过的那个父对象/子对象”。如果CacheKey命中,MyBatis会把新行数据并入已有对象;如果不命中,才会创建新对象。
我贴一段这段逻辑简化后的示意(不是完整源码,但足够理解思路):
// 伪代码:判断当前行是否已经构建过 if (cacheKey != null && alreadyThere == null) { // 说明没构建过,创建新对象并放入结果 } else { // 说明是同一对象的不同关联记录,复用已有对象,继续填充集合 }这段机制解释了几乎所有嵌套结果映射的行为特征:<id>标记不是摆设,它是MyBatis用来“去重和聚合”的钥匙。
5.3 没写id标记,为什么一对多结果莫名“多行”
回到一个实际问题:缺失<id>标记会导致什么?源码逻辑看下来,答案很直接——MyBatis判断不了“这两行是不是同一个订单”,于是每一行都会被当成新订单来创建对象。最终,一个3行商品的订单,会被组装成3个一模一样的订单对象。
所以代码审查时,我第一眼看嵌套结果映射的resultMap,一定是先找每个层级有没有<id>标记。没有的话,不管当时跑起来对不对,早晚会出问题。这里说的<id>标记,是resultMap里的<id property="id" column="xxx"/>,不是数据库里的主键注释。
5.4 顺带聊聊ResultLoader:懒加载代理对象的诞生
当association或collection配置了嵌套查询且fetchType="lazy"时,MyBatis会创建一个ResultLoader对象,再把它包装成代理对象。一切的关键都在这个ResultLoader身上:它会在被代理的方法被调用时,拿到之前保存的SqlSession、MappedStatement、参数值去执行真正的查询。
这也是为什么“SqlSession必须活着”会成为硬性要求——ResultLoader持有的是SqlSession或能从SqlSession中获取信息的能力,Session一关,加载动作便无从执行。了解了这层原理,你就知道前面说的“懒加载必须在事务内使用”并不是危言耸听。
6. 多年下来沉淀的关联映射自查清单
最后把这些年踩过的坑沉淀成一份清单。每次写完关联映射,或者Review同事代码,我都会走一遍这套检查。
6.1 动手前先回答这5个问题
- 业务语义是“属于”还是“包含”?属于用
association,包含用collection。 - 关联字段和父表字段会重名吗?会重名就一定要在SQL里起别名,并且在
resultMap里显式映射。 - 一次查询会产生多少行物理记录?如果超过1行,需要确认这是不是业务想要的粒度,尤其涉及分页时必须先想清楚。
- 数据量级多大?小表随便嵌套查询,大表优先考虑合并SQL或分批查询。
- 关联对象是否会被事务外访问?是的话不要用懒加载。
6.2 容易被忽略的配置组合
有几个配置单独看都很基础,但组合起来经常让人栽跟头。
首先是mapUnderscoreToCamelCase。设为true时,列名author_name能自动映射到authorName。但它只对普通result属性有效,对嵌套对象里的列名依然要求“列名和resultMap里的column一致”或“列名能自动映射到子对象属性”。所以听到“我开了驼峰映射为什么关联对象还是null”这种问题,先查association里的column是否跟SQL列名一致。
其次是autoMappingBehavior。默认是PARTIAL,会自动映射非嵌套对象的属性。但一旦你使用了resultMap,并且这个resultMap没有写全所有属性,自动映射行为可能会让你的字段“时有时无”。我的做法是:核心字段全显式映射,附属字段不写死,交给自动映射,但要清楚自动映射的边界。
最后是Oracle数据库。如果你用Oracle,未起别名的列在结果集里是大写的(比如ID),而MyBatis的column映射是区分大小写的?其实MyBatis内部有大小写转换逻辑,但在嵌套映射场景下我依然遇到过大小写不匹配的问题,建议还是显式起别名、显式写column,不要依赖隐式行为。
6.3 调试时我是怎么快速定位的
碰到关联映射结果不对,我的调试步骤是:
- 先打印完整SQL日志,确认SQL本身查出来的数据对不对。可以用数据库客户端直接执行这条SQL,数一下行数和值。
- 看resultMap的id标记是否齐全,尤其是collection场景。
- 把resultMap简化到最小可复现,先只保留一个层级,跑通了再加一层。多层嵌套怎么都查不出问题时,用这套“减法”往往一下就定位到了。
- 检查别名是否重复,尤其是手写了
AS xxx但后面还有同名字段的情况。
最后说个不算技巧的技巧:MyBatis的XML写多了,有时候报错信息很笼统,像“Result Maps collection does not contain value for ...”,这种一般是resultMap的id写错了,或者是<association>里的select属性指向的Mapper方法不存在。先把这些拼写层面的事情确认完,再往业务逻辑上找,效率会高很多。
关联映射本身不难,真正考验人的是对数据粒度、SQL执行次数、对象生命周期这些横切面的把控。我写完这篇之后,自己再看老项目里的各种resultMap,发现大多数性能隐患和诡异数据问题,都能从“有没有滥用嵌套查询”、“id标记是否齐全”、“是不是在JOIN结果上做分页”这三件事里找到答案。希望你也能带着这几个问题去review自己的代码,少踩几个坑。