
没用过ResultMap的Java后端算不上真正碰过MyBatis。我之前在排查一个实时任务里的配置加载问题时把Mapper XML从上到下翻了个遍最后定位到ResultMap映射上花了大半天才反应过来问题出在哪。那次之后我意识到ResultMap这种东西平时写CRUD用不太上一旦碰上多表关联、嵌套结构、动态字段它就是你绕不开的那道坎。这篇文章就把我实际用ResultMap的经验拆开讲透从基础标签到association、collection、discriminator再到延迟加载和自动映射配合的坑一次性聊明白。1. 从resultType到ResultMap什么时候必须换打法1.1 一个让我重新审视映射的线上问题先说我遇到的那个场景。项目里有个实时数据处理模块里面有个DAO层叫ConfigDataMapper对应着一张任务节点配置表查出来的数据要映射成一个嵌套很深的Java对象外层是节点信息里面要带出该节点下所有阶段instance stage的实时状态列表。需求文档写得很清楚单表查询搞定不了必须走JOIN。一开始我图省事直接用resultType映射以为只要SQL查出来的字段和对象属性对得上就没问题。结果一跑问题全出来了同名字段互相覆盖、嵌套的List永远只有第一条数据、某些字段莫名其妙是null。排查到最后所有的锅都在映射方式上——resultType处理不了结构化数据。从那次之后我给自己定了个规矩凡是查询结果需要组装成嵌套对象、多表JOIN字段有重名、或者想精细控制某些字段怎么塞进对象的一律上resultMap不纠结。1.2 ResultMap到底解决什么问题理解ResultMap之前先要理解MyBatis里三个角色的关系表结构、Java对象、查询方式。resultType强调的是“字段名对属性名”它希望你数据库字段和Java属性天然对应或者靠开驼峰转换来凑合。可一旦字段对不上、类型对不上、结构对不上它就没辙了。resultMap做的事情本质上是“定向翻译”它脱离SQL语句独立存在通过id、result、association、collection、discriminator这些标签明确告诉MyBatis“这一列去哪个属性”“这个子对象由哪几条记录拼出来”。它不关心你的SQL长什么样只关心查出来的列名怎么落到对象图里。这就是为什么resultMap往往和复杂的resultType场景区分开你需要的是“结果集的形状控制权”。另外一个关键点resultMap通过id属性全局唯一标识可以被多个查询语句反复引用。同一个映射结果可能在selectById、selectWithCondition、selectJoinXXX里都能复用。这种复用性在大项目里很值钱——你只需要维护一份字段映射规则而不是每个SQL各写一份。2. ResultMap的基础构成id、result与构造器映射2.1 一条完整映射的写法不要一上来就想着association和collection先把基础标签写利索。一个最简单的resultMap长这样resultMap idconfigDataMap typecom.az00.tmc.realtime.dao.entity.ConfigData id columnid propertyid jdbcTypeBIGINT/ result columntask_name propertytaskName jdbcTypeVARCHAR/ result columnnode_code propertynodeCode jdbcTypeVARCHAR/ result columnconfig_json propertyconfigJson jdbcTypeVARCHAR/ result columncreate_time propertycreateTime jdbcTypeTIMESTAMP/ /resultMap注意id和result的区别。id是用来标记主键或唯一键的它的作用不只是映射字段更重要的是MyBatis用它来做缓存键和唯一性判断。在嵌套映射场景里id直接决定了MyBatis怎么区分“两条记录是不是同一个对象”。有一个细节很多人忽略如果你不写id只写result在某些场景下MyBatis会退化成用整行数据做唯一性判断导致嵌套映射的折叠逻辑异常。尤其在collection折叠多条记录成List时id缺失会造成子列表里出现重复元素或者覆盖异常。jdbcType要不要写我建议对数据库里容易出类型歧义的字段比如CHAR、TIMESTAMP、TINYINT显式标注。虽然MyBatis大部分情况能自己推断出来但有些数据库驱动在特殊类型上返回的JDBCType并不准确显式指定能减少很多“明明查出来了却映射失败”的诡异问题。2.2 constructor构造器映射不可变对象也能优雅赋值大多数实体类都是POJO风格有默认构造函数加一堆setter。但如果你面对的是不可变对象比如领域模型里的值对象类上没有setter只有带参构造函数这时候result就没法走setter注入了得用constructor。resultMap idtaskInfoMap typecom.az00.tmc.realtime.dao.entity.TaskInfo constructor idArg columntask_id nameid javaTypejava.lang.Long/ arg columntask_name nametaskName javaTypejava.lang.String/ arg columntask_type nametaskType javaTypejava.lang.String/ /constructor /resultMapconstructor里面的idArg对应构造函数的id参数arg对应普通参数。注意name属性是从Java 8的反射参数名里取的如果编译时没加-parameters参数反射拿不到参数名这时候你就得靠参数顺序来匹配了。我在Maven里开了parameterstrue/parameters所以大多数情况下都能用name直接指定代码更可读。另外一个建议constructor里的参数顺序必须和构造函数定义一致否则会静默失败——不是报错而是值错位。我踩过一次排查半天发现是构造器映射的arg顺序和构造函数参数列表不一致值串位了。3. association关联映射一对一场景的两种执行策略3.1 嵌套结果映射一条SQL搞定关联对象association处理的是“一个对象里面套另一个对象”的关系典型如“每个配置项对应一个执行器信息”。这对关系在关系型数据库里就是一张表带外键JOIN另一张表。嵌套结果映射的写法是把JOIN查出来的多列数据通过association里的子映射组装成子对象resultMap idjunctionInstageMap typecom.az00.tmc.realtime.dao.entity.JunctionInstage id columnjunction_id propertyid/ result columnjunction_name propertyname/ result columnstage_status propertystageStatus/ association propertyexecutorInfo javaTypecom.az00.tmc.realtime.dao.entity.ExecutorInfo id columnexecutor_id propertyid/ result columnexecutor_name propertyname/ result columnexecutor_type propertytype/ /association /resultMap这时候SQL就是常规JOINSELECT j.id AS junction_id, j.name AS junction_name, j.stage_status, e.id AS executor_id, e.name AS executor_name, e.type AS executor_type FROM junction_instage j LEFT JOIN executor_info e ON j.executor_id e.id WHERE j.id #{id}核心逻辑在于ResultMap的column命名和SQL里的alias别名必须严格对应。千万别依赖数据库返回的原始列名建议在每个字段上显式加上前缀别名尤其是多表JOIN有同名字段时否则后面查错都不知道错在哪。这种方式的优点是只需要执行一条SQL多表数据一次性查出来性能可控。缺点是SQL会变得很长而且嵌套层级越深SQL的组织复杂度越高。3.2 嵌套查询拆开查询但小心N1association还有另一种写法通过select属性指向另一条查询语句让MyBatis自动去执行第二次查询来填充对象resultMap idjunctionInstageMap typecom.az00.tmc.realtime.dao.entity.JunctionInstage id columnid propertyid/ result columnname propertyname/ association propertyexecutorInfo columnexecutor_id selectcom.az00.tmc.realtime.dao.ConfigDataMapper.selectExecutorById/ /resultMap这种情况下MyBatis会先执行主查询拿到executor_id之后再调用selectExecutorById去把ExecutorInfo查出来填进去。嵌套查询的优点非常明显主查询SQL变得清爽而且每个子查询独立成statement复用性极高。缺点是那个臭名昭著的N1问题——如果你一次查出100条主记录那么MyBatis最多会执行100次子查询数据库连接和查询开销直接爆炸。所以我的经验是嵌套查询只适合主结果集数据量很小或者子查询本身走主键索引且极快的场景。一旦数据量上来立刻切回嵌套结果映射。3.3 多表联查的同名字段处理columnPrefix是救命稻草在实际项目里JOIN两张表非常容易出现同名字段。比如junction_instage表有个id字段executor_info表也有个id字段。如果你在resultMap里都写columnidMyBatis压根分不清哪个id是哪张表的。我在早期项目里踩过这个坑最原始的做法是给每张表的字段起不同的别名比如把两个字段重命名为junction_id和executor_id。这样结果集是区分开了但整个SQL别名写得特别啰嗦一旦表多起来alias命名的规矩全靠自觉容易乱。后来发现MyBatis的columnPrefix属性完美解决这个问题。它在嵌套结果映射的场景里非常香resultMap idjunctionInstageMap typecom.az00.tmc.realtime.dao.entity.JunctionInstage id columnjunction_id propertyid/ association propertyexecutorInfo javaTypecom.az00.tmc.realtime.dao.entity.ExecutorInfo columnPrefixexec_ id columnid propertyid/ result columnname propertyname/ /association /resultMap这条resultMap对应SQLSELECT j.id AS junction_id, e.id AS exec_id, e.name AS exec_name FROM junction_instage j LEFT JOIN executor_info e ON j.executor_id e.id注意这里的精髓association上的columnPrefixexec_表示子映射里所有column都会被自动拼上这个前缀去结果集里取值。也就是说子映射里的columnid实际读取的是结果集里的exec_id列。这样一来你不需要给子查询字段起完全不重名的alias只需要保证前缀是一致的。这个特性在多级嵌套时尤其强。比如第二层子对象里还有第三层子对象每一层各自指定一个前缀各层互不干扰。提示columnPrefix只对嵌套结果映射association/collection内部有子映射生效对嵌套查询用select属性不生效。因为嵌套查询是单独拿到一个新结果集不走父结果集的列名匹配逻辑。4. collection集合映射一对多场景的嵌套思路4.1 ofType的语义与多行记录折叠collection处理的是“一个对象里面有List的另一类对象”比如“一个任务节点下挂了多个执行阶段”。这是数据库里典型的一对多关系。一个最基本的一对多collection长这样resultMap idjunctionInstageMap typecom.az00.tmc.realtime.dao.entity.JunctionInstage id columnid propertyid/ result columnname propertyname/ collection propertystageList ofTypecom.az00.tmc.realtime.dao.entity.Instage id columnstage_id propertyid/ result columnstage_name propertyname/ result columnstage_order propertyorder/ /collection /resultMap执行SQL时JOIN出来的结果是多行重复的——主对象字段在每一行里都一样只有子对象字段在变化。MyBatis会根据id标签来判断哪些行属于同一个主对象然后把他们折叠组装成一个List。这里有一个很多人踩过的坑主对象层面的id必须存在且其值必须是主对象的真实主键。如果漏掉或写错MyBatis可能把同一个主对象的多次出现当成不同对象导致出现多个重复的主对象每个里面只有一个子元素——你明明JOIN出了三行得到的却是三个重复主对象各自带一个子对象而不是一个主对象带三个子对象。4.2 嵌套select传多个条件collection同样支持用select属性做二次查询方法上和association类似但经常遇到的一个需求是子查询需要传多个参数。比如前面那个实时任务的场景需要根据junction_id和task_type两个条件查出该节点下的阶段列表resultMap idjunctionInstageMap typecom.az00.tmc.realtime.dao.entity.JunctionInstage id columnid propertyid/ result columnname propertyname/ collection propertystageList ofTypecom.az00.tmc.realtime.dao.entity.Instage selectcom.az00.tmc.realtime.dao.ConfigDataMapper.selectStageListByJunction columnid, task_type result columnstage_id propertyid/ result columnstage_name propertyname/ /collection /resultMap注意这里columnid, task_type这种写法。MyBatis会把当前行里id和task_type两个列的值取出来作为参数列表传给子查询。子查询的Mapper方法需要定义成接受Param(v1) Param(v2)或者一个MapMyBatis会按顺序把列值放进去。如果只传一个参数直接把列名写在column属性里就行。多参数时用逗号分隔但要注意参数顺序必须和子查询Mapper方法签名对应上。我实测中发现一个容易忽略的细节多参数传值时column写列名必须在当前resultMap对应的结果集里能查到该列。如果该列没有在id或result里映射它照样可以传——因为MyBatis在找列值时是直接去结果集元数据里拿的不要求必须显示映射。不过为了可读性和稳定性我还是建议把要传的列显式映射出来。4.3 结果集排序与内存折叠的取舍collection的结果集折叠是MyBatis在内存中完成的过程。如果你希望主对象下的子对象List是有序的光靠resultMap不行必须保证SQL里对子表排序字段ORDER BY。比如下面的SQLJOIN出来后阶段列表会按stage_order排序MyBatis折叠进List时保持了这个顺序SELECT j.id AS id, j.name AS name, s.id AS stage_id, s.name AS stage_name, s.stage_order FROM junction_instage j LEFT JOIN instage s ON j.id s.junction_id WHERE j.id #{id} ORDER BY s.stage_order这点看似简单但真有人忽略。有次我排查“为什么List里顺序总是乱”的问题结果发现SQL没有ORDER BYMyBatis只是按照数据库驱动返回的自然顺序折叠数据库不保证顺序结果自然不稳定。补上排序后问题立刻消失。另外一个取舍是当数据集很大比如上万条主记录带子记录一次JOIN查出来做内存折叠可能把应用内存打爆改用嵌套查询配合延迟加载反而更好。反之如果主记录数少但子记录多嵌套结果映射更合适。没有绝对的对错只有适不适合当前数据规模。5. 鉴别器与自动映射多态和宽松机制配合的两种重要玩法5.1 discriminator按字段值动态切换映射规则discriminator鉴别器是ResultMap里被使用频率最低但价值很高的一个标签。它的作用是根据某列的值动态选择继承哪个resultMap。非常像Java里的多态——同一个父类引用根据类型字段的值映射成不同的子类。举一个我实际用过的例子配置表中有一个config_type字段如果值是1我们需要映射成HttpConfig对象里面有url、timeout等字段如果值是2需要映射成KafkaConfig对象里面有brokers、topic等字段。两者都继承同一个父类BaseConfig。用discriminator解决再合适不过resultMap idbaseConfigMap typecom.az00.tmc.realtime.dao.entity.BaseConfig id columnid propertyid/ discriminator javaTypeint columnconfig_type case value1 resultMaphttpConfigMap/ case value2 resultMapkafkaConfigMap/ /discriminator /resultMap resultMap idhttpConfigMap typecom.az00.tmc.realtime.dao.entity.HttpConfig extendsbaseConfigMap result columnurl propertyurl/ result columntimeout propertytimeout/ /resultMap resultMap idkafkaConfigMap typecom.az00.tmc.realtime.dao.entity.KafkaConfig extendsbaseConfigMap result columnbrokers propertybrokers/ result columntopic propertytopic/ /resultMap这里有意思的地方在于extends的使用——子resultMap可以继承父resultMap的映射配置再补充自己的特殊字段。鉴别的流程是MyBatis先执行baseConfigMap遇到discriminator后拿config_type列的整型值去匹配case匹配到哪个就用哪个resultMap继续完成剩余字段的映射。我记得当时用这个特性时一个同事还问我“为什么不直接在Service里用if-else手动判类型组装”。如果只有一两个类型if-else确实没问题。但当类型扩展到十几种且不同类型的字段差异很大时把映射职责留在XML里比在Java代码里堆条件判断要直观得多后续加类型也只需要加新的resultMap和case手不痒。5.2 自动映射级别让resultMap只写关键差异MyBatis有个默认开启的自动映射机制意思是即使你没在resultMap里显式配置每一个字段只要查询结果的列名能通过驼峰转换匹配上Java属性它也会自动映射进去。这个机制配合resultMap特别香——你只需要在resultMap里处理那些有特殊映射需求的字段关联对象、集合、构造器等普通字段完全可以让自动映射兜底。这个特性默认级别是PARTIAL也就是自动映射只处理没有嵌套映射的字段。如果你设置了FULL级别autoMappingBehaviorFULL它还会自动映射嵌套结果里的字段。我在实际项目中的配置是mybatis: configuration: auto-mapping-behavior: partial map-underscore-to-camel-case: true也就是全局开驼峰转换resultMap里只写嵌套结构和特殊字段。这样的好处是数据库加了个普通新字段后Java对象加个同名属性就能查出来不用频繁改resultMap。但注意自动映射也不是万能的。有几种情况必须显式写映射字段名跟属性名差异过大驼峰转换搞不定。字段需要typeHandler做自定义类型转换比如JSON字符串和Java对象互转。字段需要从不同列组装比如把数据库里的create_time映射到父对象的一个属性同时它又是某个子对象排序的依据。5.3 resultMap与自动映射的优先级很多初学者搞不清一个问题resultMap里只写部分字段剩下的没写的字段怎么办答案是由自动映射处理。也就是说resultMap里的显式映射优先级高于自动映射。同一个字段如果你在resultMap里配置了以你的配置为准如果没配置MyBatis会尝试按驼峰规则自动映射。这个特性有时候会带来隐性问题你开了驼峰转换但某张表的列名复杂到转换后跟属性对不上你忘了在resultMap里补显式映射结果字段就是null而且不报错。这正是自动映射“宽松”带来的隐患——它不会告诉你“这个字段没映射上”只是默默给你一个null值。所以我有一个习惯复杂查询的SQL每查一个字段都起清晰的别名这样自动映射的命中率更高减少null字段的排查成本。6. 延迟加载的取舍与常见坑6.1 延迟加载的参数配置延迟加载Lazy Loading是嵌套查询玩法的一个配套特性。默认情况下你用association或collection的select属性做嵌套查询MyBatis会在主查询执行后立即执行子查询。但如果你配置了延迟加载子查询会等到真正访问对应属性时才执行。配置如下mybatis: configuration: lazy-loading-enabled: true aggressive-lazy-loading: falselazy-loading-enabledtrue开启延迟加载aggressive-lazy-loadingfalse表示“延迟到真正访问属性时才触发子查询”而不是一访问主对象的任意属性就触发。这个配置对性能优化的意义很大。比如我那个实时任务的列表页用户进来默认只展示节点名称和状态根本没打算展示阶段列表。如果不用延迟加载MyBatis会无条件把所有节点的阶段列表全部查一遍大量无意义SQL执行。开了延迟加载后只有当代码里真正调用junctionInstage.getStageList()时才会去查子查询。6.2 延迟加载的调试经验和序列化坑延迟加载虽好坑也多。第一个坑是会话关闭后访问懒加载属性报错。MyBatis的懒加载依赖SqlSession仍然存活。如果你在Service层返回实体对象搞了个事务注解控制sqlSession生命周期等Controller层访问getStageList()时会话已经关了直接抛LazyInitializationException。解决方式主要有三种在Web层用OpenSessionInView模式保持会话开启把事务边界拉长。在Service层内手动触发访问懒加载属性把整个对象图填充完整后再返回。关掉懒加载回归立即加载简单粗暴但性能受影响。这三种方案各有利弊我个人的偏好是第二种。在Service层里显式调用或遍历一次关联属性既能利用延迟加载减少不必要的查询又能避免会话关闭引发的异常。缺点是如果某些分支确实用不到子对象也算浪费了一次查询——不过比起异常来说可接受。第二个坑是懒加载对象在序列化时会出问题。MyBatis懒加载返回的List或对象可能是一个代理对象比如Javassist或CGLIB代理直接序列化给前端时要么报错、要么序列化出来的结构与预期不一致。解决办法要么在序列化前触发加载要么用Jackson的JsonIgnoreProperties(handler)等注解忽略代理相关属性。第三个坑是嵌套层级太多时如果每层都懒加载链式触发访问会产生连串SQL这时候性能反而比一次性JOIN更差。我一般建议嵌套层级超过两层就别用懒加载了直接用嵌套结果映射最稳。7. 一个综合分析从Mapper方法到ResultMap的落地实战前面理论讲了很多最后用一个贴近真实的综合案例把整个流程串一遍。背景是我们有个实时数据模块需要根据一个节点ID查出节点完整配置节点内部包含它下挂的所有阶段同时每个阶段关联一个执行器信息。对应Mapper就是前面出现过的ConfigDataMapper里的方法。这个方法内部就是靠一个复杂的ResultMap支撑起来的。Mapper接口定义package com.az00.tmc.realtime.dao; public interface ConfigDataMapper { JunctionInstage selectJunctionWithStages(Param(junctionId) Long junctionId, Param(taskType) String taskType); }XML实现mapper namespacecom.az00.tmc.realtime.dao.ConfigDataMapper resultMap idjunctionInstageMap typecom.az00.tmc.realtime.dao.entity.JunctionInstage id columnjunction_id propertyid/ result columnjunction_name propertyname/ result columnjunction_status propertystatus/ collection propertystageList ofTypecom.az00.tmc.realtime.dao.entity.Instage columnPrefixstage_ id columnid propertyid/ result columnname propertyname/ result columnorder propertyorder/ association propertyexecutorInfo javaTypecom.az00.tmc.realtime.dao.entity.ExecutorInfo columnPrefixexec_ id columnid propertyid/ result columnname propertyname/ result columntype propertytype/ /association /collection /resultMap select idselectJunctionWithStages resultMapjunctionInstageMap SELECT j.id AS junction_id, j.name AS junction_name, j.status AS junction_status, s.id AS stage_id, s.name AS stage_name, s.order AS stage_order, e.id AS exec_id, e.name AS exec_name, e.type AS exec_type FROM junction_instage j LEFT JOIN instage s ON j.id s.junction_id LEFT JOIN executor_info e ON s.executor_id e.id WHERE j.id #{junctionId} AND j.task_type #{taskType} ORDER BY s.order /select /mapper这里有几个细节值得说三级嵌套通过columnPrefix把每层字段隔离开。子映射里columnid实际上取的是stage_id和exec_id列避免同名字段冲突。只做一次JOIN查询没有任何N1问题。ORDER BY s.order保证阶段列表有序。这个案例如果换成用resultType要么得建一个冗余的扁平DTO要么在Service里手动组装代码量不是一个量级。而用resultMap所有映射逻辑收拢在XML里实体类保持干净的领域结构各层各司其职排查问题也只需要盯XML和SQL。在实际使用中我还有几个每回都要自查的点结果集列名是否与resultMap期望的列名完全一致包括前缀拼接后的名字。主对象的id是否唯一保证折叠逻辑正确。嵌套子查询的column传参与Mapper方法签名是否对应。多表JOIN的表关联顺序是否合理LEFT JOIN还是INNER JOIN有没有想清楚——需要的数据一条都不能少但多余的关联也会白白增加查询开销。最后一个心得ResultMap这种配置型的东西最大的成本不是写出来的那几十行XML而是调试时的那几个小时。所以一定要刻在脑子里——先查SQL结果的列名再查resultMap的column拼写最后查Java属性名按这个顺序排查90%的问题都能定位。顺序反了容易在SQL和Java属性上来回怀疑浪费时间。