
做后端这些年MyBatis的关联映射翻车现场我见过太多明明SQL join出来了结果对象里子集合是空的加了resultMap之后分页总数突然对不上了还有那种日志里疯狂刷同一条SQL的N1问题。这中间最核心的两个标签就是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 idArticleWithAuthorMap typecom.example.Article id propertyid columnarticle_id/ result propertytitle columntitle/ association propertyauthor javaTypecom.example.Author id propertyid columnauthor_id/ result propertyname columnauthor_name/ /association /resultMap select idselectArticleWithAuthor resultMapArticleWithAuthorMap 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别名一致。上面的columnauthor_id对应的是AS author_id也就是说resultMap里列的对应关系是直接跟SQL输出列名挂钩的而不是跟实体字段名挂钩。这个关系搞错关联对象的值就是null。第三javaType其实可以不写因为MyBatis可以通过反射从Article.author属性的类型推断出来。但写上有两个好处一是让阅读XML的人一眼看清类型二是在某些没有泛型信息或构建工具链的极端场景下避免推断失败。我的建议是写上成本很低。association还支持在内部继续嵌套result、association甚至collection所以一个文章实体可以同时携带作者、分类、评论列表。层级越深结果集列名规划越要提前设计后面第三章我会单独展开。2.2 嵌套查询什么时候该用什么时候千万别用嵌套查询是另一种思路主查询先把Article查出来然后针对每条Article再去执行另一条SQL查Author。写法如下resultMap idArticleWithAuthorSelectMap typecom.example.Article id propertyid columnid/ result propertytitle columntitle/ association propertyauthor columnauthor_id selectcom.example.mapper.AuthorMapper.selectById/ /resultMap select idselectArticleWithAuthorSelect resultMapArticleWithAuthorSelectMap SELECT id, title, author_id FROM article /selectcolumnauthor_id表示把当前结果集中的author_id列的值取出来作为参数传给select指向的那条SQL。嵌套查询最大的好处是主查询和子查询解耦子查询可以被多个地方复用SQL本身也很好维护。但它的致命缺点是N1问题查出N篇文章就会执行N次作者查询日志里会看到同一条SQL刷屏。如果文章列表有100条就要额外执行100次数据库查询。所以我的经验是嵌套查询只适合在确认数据量小、或必须按需加载比如子对象很少被用到的场景使用。查询列表页这种高频场景要么用嵌套结果映射一把梭要么干脆拆成两条SQL在Service层组装。2.3 fetchType和全局懒加载配置一个参数决定性能association的select方式可以配合fetchTypelazy实现懒加载association propertyauthor columnauthor_id selectcom.example.mapper.AuthorMapper.selectById fetchTypelazy/加了lazy之后查询Article列表时并不会立刻去查Author而是等代码里真正调用article.getAuthor()时才触发那条SQL。这里有个前提全局配置里要允许懒加载。相关配置有两项mybatis.configuration.lazy-loading-enabledtrue mybatis.configuration.aggressive-lazy-loadingfalselazy-loading-enabled不言而喻aggressive-lazy-loading的作用很多新手会忽略。当它等于true时哪怕只访问article.getTitle()也会把整个对象的所有懒加载属性全部加载出来等于没懒。要把它设为false才能做到“访问哪个懒属性才加载哪个”。不过这只是理论上的“按需”实际项目中我会默认关掉懒加载原因很简单懒加载必须在SqlSession还开着的时候触发。如果你在Service层事务内访问没问题但一旦出了事务或者用了某些异步线程再碰懒加载属性就会抛org.apache.ibatis.exceptions.PersistenceException提示SqlSession is already closed。这种运行时错误比N1更难排查。3. collection一对多场景的正确打开方式collection对应的是“has many”语义一个Author有多篇文章一个Order有多个商品。用法和association高度相似但有几个独特的坑。3.1 ofType、javaType到底怎么填先看一段标准配置resultMap idAuthorWithArticlesMap typecom.example.Author id propertyid columnauthor_id/ result propertyname columnauthor_name/ collection propertyarticles ofTypecom.example.Article id propertyid columnarticle_id/ result propertytitle columntitle/ /collection /resultMapofType表示集合里装的是哪种元素类型这里是Article。很多新手会问那javaType呢javaType是表示集合本身的类型默认MyBatis会给你填一个ArrayList。只有当你的实体属性声明的是接口类型如ListArticle且你希望MyBatis帮你new一个特定实现类时才需要指定比如javaTypejava.util.LinkedList。需要记住的口诀是javaType说的是“容器”ofType说的是“容器里的内容”。绝大多数场景只写ofType就够了。3.2 嵌套结果实现一对多的列前缀策略用嵌套结果实现一对多时核心还是列名唯一性。看这个例子resultMap idOrderWithItemsMap typecom.example.Order id propertyid columnorder_id/ result propertyorderNo columnorder_no/ collection propertyitems ofTypecom.example.OrderItem id propertyid columnitem_id/ result propertyproductName columnproduct_name/ result propertyquantity columnquantity/ /collection /resultMap select idselectOrderWithItems resultMapOrderWithItemsMap 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里套collectioncollection里再套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会算成3limit也会作用在JOIN后的临时表上最终导致每页订单数不符。我总结过三种解法按推荐程度排序方案做法适用场景先分页主表再查子表先用分页插件查orders主表ID列表再用WHERE order_id IN (...)查所有子表数据最后在内存中组装大多数业务场景推荐嵌套查询懒加载collection的select配合fetchTypelazy只对当前页的订单加载商品数据量小、可接受N1的场景JOIN后内存去重直接JOIN查询然后对结果集去重数据量小、只查单页的场景第一种是性能和可控性最均衡的。步骤是对orders表本身执行分页查询得到当前页订单。收集订单ID列表。用SELECT * FROM order_item WHERE order_id IN (...)查出所有相关商品。Service层把商品按订单ID分组set进对应订单对象。这套方案的SQL简单清晰也能配合缓存比任何高级映射技巧都稳。MyBatis的关联映射确实能省事但它不是用来包治百病的。4. 性能红线N1查询、懒加载和二级缓存之间的博弈关联映射做得多了你就会发现真正难的从来不是怎么写resultMap而是怎么控制SQL执行次数和数据一致性。4.1 N1怎么定位不要凭感觉先看SQL日志N1问题的症状非常典型页面上一个列表接口撑死查了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条相同子查询”的规律基本可以断定是嵌套查询写法没控制好触发时机。这时候优先确认是否用了fetchTypelazy代码里是否在循环里调用了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 propertyid columnxxx/不是数据库里的主键注释。5.4 顺带聊聊ResultLoader懒加载代理对象的诞生当association或collection配置了嵌套查询且fetchTypelazy时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自己的代码少踩几个坑。