ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

MyBatis结果集映射全解析:从自动映射到嵌套映射的避坑指南

MyBatis结果集映射全解析:从自动映射到嵌套映射的避坑指南 我接触MyBatis这些年见得最多的翻车现场十有八九都出在结果集映射这一步。SQL写得很顺跑了也没报错返回的数据却一个字段都拿不到或者查出来一个List里面全是null。之前带团队做过一个报表统计模块单表查询的性能刚刚的结果一对多映射一上数据直接少了一半后来排查到凌晨才发现是resultMap嵌套映射里缺少id列导致结果合并出错。这次就把MyBatis结果集映射从小到大的知识点做一次系统性的梳理从自动映射到手工映射从一对一、一对多再到各种让人头秃的边界情况结合我实际踩过的坑争取一次讲透。1. 先搞清楚结果集映射的本质从数据库行到Java对象的翻译官结果集映射在MyBatis里的位置一句话概括就是它负责把JDBC查询出来的ResultSet转换成你要的Java对象。这个转换不是简单的列名赋值它涵盖了类型转换、属性匹配、嵌套结构组装、集合填充等一系列逻辑。如果不用MyBatis原生JDBC你怎么写查出ResultSet之后一行一行地取数据调用rs.getString(user_name)塞进User对象里再处理Date、BigDecimal这些类型转换。这么写本身没什么问题但每张表都要写一遍每个查询方法几乎都是套模板。MyBatis出现之后把这层样板代码收编进了框架内部开发者只需要在XML映射文件里告诉框架怎么转剩下的活框架全包了。不过这里有一个容易误解的点很多人以为映射只是字段赋值这种小事其实结果集映射的复杂程度取决于两个因素——目标对象的结构复杂度和查询结果的形态。目标对象的结构复杂度说的是你的Java类到底有多深。只映射一个User实体字段名跟列名对得上那确实是小事一桩但如果User里面嵌套了DepartmentDepartment里面又有一组Role映射要处理的维度就完全不同了。查询结果的形态说的是SQL返回的行和列的组织方式是单行单列、单行多列、多行多列还是通过JOIN把关联数据塞进了一行里。组合起来的复杂度才是结果集映射真正考验人的地方。理解这一点你才能明白后面为什么会有resultType和resultMap两套方案为什么有人会用Map接收为什么有人踩了懒加载的坑为什么明明SQL查出了十行数据映射完只剩五行。这些问题的根源全部都能追溯到结果集形态和目标结构之间的匹配关系上。2. resultType自动映射真正常用的方案以及它的隐形边界老实说日常业务里我用得最多的还是resultType不是resultMap。大部分单表查询、简单关联查询resultType完全够用而且代码量最少。2.1 自动映射的两个隐含规则resultType的工作原理是自动映射autoMapping框架拿到查询结果集之后自动把列名和JavaBean的属性名做匹配匹配成功就赋值。但这里有两个隐含规则新手很容易忽略。第一个规则是列名与属性名的匹配默认不区分大小写。你SQL里写了SELECT user_name FROM user映射到User类的getUserName()如果开启了驼峰映射就能自动对得上如果没有开启驼峰映射user_name这个列就无法自动映射到userName属性上。这一点后面单独展开讲。第二个规则是自动映射并不保证覆盖所有场景。MyBatis对自动映射的处理机制是能匹配上的就映射匹配不上的一律不管。这就导致了一个非常典型的问题——查出了多余的列不会报错该映射的没映射上也不报错。它就这样静默处理等你从对象里取字段的时候发现是null才知道出了问题。具体代码来看最常用的三种resultType场景返回POJO实体select idgetUserById resultTypecom.example.entity.User返回Map适合动态列、汇总统计select idcountGroupByDept resultTypejava.util.HashMap返回基础类型计数、取单值select idgetUserName resultTypejava.lang.String第一种场景没什么好说的字段对得上就行。重点说说Map场景的标准行为当resultType是Map时MyBatis默认把列名作为Key把列值作为Value每行数据是一个Map对象。有点反直觉的是它默认不会做驼峰转换所以SQL里查出来是user_nameMap的Key就是user_name而不是userName。这一点在写动态报表、通用查询时特别容易踩。2.2 开启驼峰命名映射的正确姿势数据库规范基本上都是下划线命名Java实体类则是驼峰命名所以mapUnderscoreToCamelCase这个全局配置基本属于必开项。配置方式分两种。XML配置settings setting namemapUnderscoreToCamelCase valuetrue/ /settingsSpring Boot环境则在application.yml里配mybatis: configuration: map-underscore-to-camel-case: true开启之后select id, user_name, user_phone from user查询出来的user_name列就能自动映射到userName属性。但注意这个转换只对resultType的自动映射生效对resultMap里的column和property的映射关系不生效除非resultMap里没写property而依赖自动映射部分。这句话怎么理解就是你在resultMap中如果写了result columnuser_name propertyuserName/那么无论驼峰映射开不开这条映射都有效因为你手动指定了。但如果resultMap里只写了部分字段剩下的字段想靠自动映射补全那么驼峰映射开关就依然有用。2.3 判断什么时候该换用resultMapresultType好用但不是万能。下面这三种场景必须切换到resultMap列名与属性名对不上且无法用驼峰规则解决。比如SQL里写了select count(*) from user返回的列名是count(*)你想映射到totalCount就怎么也自动映射不上。这种必须用result columncount(*) propertytotalCount/或者给SQL列写别名。目标对象有嵌套结构。比如查询订单时要同事带出用户信息用resultType只能映射出Order自身的字段order.user拿不到。如果查询列是两个实体的字段拼在一行里就得用resultMap的association/collection来做嵌套映射。目标对象里有复杂类型处理器。比如枚举、JSON字符串转对象需要指定typeHandlerresultType无法完成这种自定义转换。一句话总结单表、列名规整、无嵌套用resultType最省事一旦走到多表、列名脏、结构嵌套的地带别硬撑直接上resultMap。3. resultMap手工映射从字段对齐到构造器与继承resultMap是MyBatis结果集映射最核心、功能最强的部分也是日常调试中最容易出问题的地方。展开来说一个完整的resultMap由几个子元素组成每个都有各自的用途。3.1 基本字段映射id和result的职责先看一个最简单的resultMapresultMap iduserMap typecom.example.entity.User id columnid propertyid jdbcTypeBIGINT/ result columnuser_name propertyuserName/ result columnuser_phone propertyuserPhone/ /resultMapid用来标记主键列result用来映射普通列。在基础场景中id和result的行为几乎一样都是列到属性的映射但id有一个深层作用它参与了MyBatis对结果对象去重的判定。后面讲一对多嵌套时会专门说到这里先记住——有主键就一定要用id标记不要嫌麻烦。jdbcType这个属性很多人不写因为MyBatis在大多数情况下能自己推断列类型。但有一些数据库驱动比较矫情比如Oracle的null值在某些类型下返回时会报无效的列类型这时候手动指定jdbcType能规避问题。还有插入操作里字段值为null时不指定jdbcType也可能导致SQL执行失败。经验是遇到奇怪的报错先检查一下jdbcType是不是漏了。3.2 构造器映射constructor不可变对象的映射方案Java里很多对象根本不会给你默认构造器尤其是一些领域对象、值对象字段全部通过构造器初始化没有无参构造器。这种对象用前面的做法就映射不了因为MyBatis默认通过无参构造器创建实例再反射赋属性。这时就要用constructor了resultMap iduserMap typecom.example.entity.User constructor idArg columnid javaTypelong/ arg columnuser_name javaTypeString/ /constructor /resultMapconstructor里的idArg和arg可以简单理解为带构造器版本的id和result。它们通过javaType指定构造参数类型MyBatis会根据参数类型去匹配构造器。有个小技巧当对象有多个构造器重载时arg的顺序必须和构造器参数的顺序一致否则会报找不到合适的构造器或者更隐蔽地匹配到错误的构造器。我自己写过一次顺序错位的映射编译不报错、启动不报错跑起来才发现字段全错位了那个排查过程是真的酸爽。3.3 resultMap继承extends消除重复映射的样板代码多表关联时你可能会给一个基础实体写一个基础resultMap然后给它的关联查询写一个扩展resultMap。比如订单表要关联用户那么可以在订单的基础resultMap上扩展出订单加用户的版本resultMap idorderMap typecom.example.entity.Order id columnid propertyid/ result columnorder_no propertyorderNo/ result columnamount propertyamount/ /resultMap resultMap idorderWithUserMap typecom.example.entity.Order extendsorderMap association propertyuser javaTypecom.example.entity.User id columnuser_id propertyid/ result columnuser_name propertyuserName/ /association /resultMapextends继承的是映射配置不是Java对象的继承关系。子resultMap里只需要写新增的映射规则就行基础字段自动从父resultMap继承。这个特性在Order、User、Role这类复杂关系里能少写大量重复代码。不过有一点要留意extends继承的id如果子类想覆盖直接写同名的id或result子类的配置会覆盖父类。用这个特性在特殊场景下可以灵活调整列映射但别滥用否则复杂的继承链会变成新的维护噩梦。4. 嵌套结果映射多表关联查询的核心玩法多表查询是resultMap展示真正实力的地方。它有两种实现路径嵌套结果映射联合查询和嵌套查询多次查询。这两条路径在结果处理机制上完全是两码事很多性能问题、数据缺失问题都源于混淆了这两种方式。4.1 一对一关联的association联合查询和嵌套查询的区别先看一对一场景比如Order关联UserSQL上可以简单用JOIN一次查出所有需要的列select idselectOrderWithUser resultMaporderWithUserMap select o.id, o.order_no, o.amount, u.id as user_id, u.user_name, u.user_phone from t_order o left join t_user u on o.user_id u.id where o.id #{id} /select对应的resultMapresultMap idorderWithUserMap typecom.example.entity.Order id columnid propertyid/ result columnorder_no propertyorderNo/ result columnamount propertyamount/ association propertyuser javaTypecom.example.entity.User id columnuser_id propertyid/ result columnuser_name propertyuserName/ result columnuser_phone propertyuserPhone/ /association /resultMap这种方式叫嵌套结果映射也就是说MyBatis拿到的是一次SQL查询出来的扁平结果集然后在内存中按照resultMap的配置把同一行的列拆到Order和User两个对象里。它的关键在于列别名要和resultMap里的column对应上。另一种路径是嵌套查询。association里直接写select让MyBatis去执行第二条SQLresultMap idorderWithUserMap typecom.example.entity.Order id columnid propertyid/ result columnorder_no propertyorderNo/ result columnamount propertyamount/ association propertyuser columnuser_id selectcom.example.mapper.UserMapper.selectUserById/ /resultMap这里MyBatis拿到user_id这个列的值交给selectUserById这个查询去查User。联合查询适合数据量大、关联层级深的场景一条SQL跑到底但SQL复杂度会线性上升嵌套查询写起来清爽但会引发N1查询问题——查一条Order要额外查一次User查100条Order就要执行101次SQL。4.2 一对多集合的collection为什么少了数据、多了null再来看一对多。一个User有多个Order这就是collection登场的时候resultMap iduserWithOrdersMap typecom.example.entity.User id columnid propertyid/ result columnuser_name propertyuserName/ collection propertyorderList ofTypecom.example.entity.Order id columnorder_id propertyid/ result columnorder_no propertyorderNo/ result columnamount propertyamount/ /collection /resultMap对应的SQL一般是这样select u.id, u.user_name, o.id as order_id, o.order_no, o.amount from t_user u left join t_order o on u.user_id o.user_id where u.id #{id}这里最关键、也最容易出问题的就是id的使用。我踩过一次印象非常深的坑上面的resultMap里User有id columnid propertyid/但Order内部我没有写id只写了result。结果查询出来一个用户明明有三条订单映射完成后orderList里只剩一条。排查到最后发现MyBatis在嵌套结果映射时会基于id判断结果对象是否相同——如果Order子映射里没有用id标记主键MyBatis就没法区分不同Order行导致相同结构的行被合并成了同一个对象。解决办法就是给子实体也补上id映射——id columnorder_id propertyid/三条订单占比正确返回。4.3 嵌套结果映射的三个避坑细节嵌套结果映射虽然功能强但坑确实不少。除了上面说的id缺失问题还有三个高频问题值得单独拿出经验说。第一列名冲突。联合查询如果两个表都有id列且没有起别名那么MyBatis根据列名id无法区分哪一个是User的、哪一个是Order的导致映射错乱。后果往往是User拿到了Order的id或者反过来。这种问题怎么排查都找不到逻辑错误最后发现是SQL的锅。解决方式就是对重复列名一律起别名哪怕只查单个实体只要是多表联查就必须注意。第二collection的ofType和association的javaType不要写混。association因为目标是单个对象所以用javaType指定目标类型collection因为目标是集合实际指定的是集合元素的类型所以用ofType。写错MyBatis会报类型转换异常但有时候它不会第一时间报而是在后续取数据时才暴露出来。第三嵌套查询的懒加载配置。asssociation和collection里写了select后默认是立即执行第二条SQL的。如果想让子查询在真正访问属性时才触发需要开启全局设置lazyLoadingEnabled和aggressiveLazyLoading。不过懒加载是一把双刃剑用好了能显著减少SQL执行次数用不好会在序列化JSON、跨事务访问时爆出一堆LazyInitializationException。我的建议是少用懒加载能JION就JION实在没办法再用嵌套查询加懒加载并且只在单次事务会话里访问避免拿到Service层之外还在访问未加载的属性。5. 自动映射级别的全局配置与缓存对映射结果的影响结果集映射不光是单个resultMap的事MyBatis的几个全局配置会直接影响所有映射行为。很多人遇到SQL没问题Mapper接口方法没问题就是查出来一半字段是null这种问题时往往不会往全局配置上想。5.1 autoMappingBehavior的三个等级MyBatis的自动映射行为由autoMappingBehavior控制它有NONE、PARTIAL、FULL三个值。默认是PARTIAL含义是自动映射对没有定义嵌套映射的结果集生效。简单说默认情况下如果你使用resultMap但只定义了部分字段剩下的字段还是会尝试自动映射。NONE表示关闭自动映射只有显式写的result才会映射。PARTIAL表示不包含嵌套结果时自动映射。FULL表示包含嵌套结果时也执行自动映射。有一种场景比较微妙如果一个resultMap里写了association其中User实体的某些字段没有显式在association里配置那么PARTIAL模式下这些字段不会被自动映射。这是因为PARTIAL模式下MyBatis认为association已经是嵌套结果了自动映射的优先级让位给了手动配置。但是把这个全局配置改成FULL后没显式配置的字段也会自动匹配。我遇到过一次就是这样的同事在association里只写了id和userName两个映射别的字段全丢了。他把autoMappingBehavior设成FULL之后字段回来了。但这属于一种能用但不够明确的解法我更推荐直接在association里补齐字段映射或者开一个继承基础resultMap的扩展而不是依赖FULL这种全局行为。全局配置的副作用是不可控的管得住这个项目管不住下一个。5.2 二级缓存对映射的影响查出来的对象不再是你以为的对象缓存本来不是结果集映射的话题但它和映射结果有直接关联。当你查询一个用户时第一次查询会走完整的结果集映射流程然后结果对象被放进二级缓存。第二次同样的查询MyBatis直接把缓存对象返回不会再执行一次结果集映射。这带来的问题就是如果你的实体类没有重写equals和hashCode或者缓存的对象被某处代码修改了后续所有拿到这个对象的代码都会看到被修改后的状态。此外二级缓存默认是跨Mapper共享的而不同Mapper的resultMap配置可能不同同一个实体类在不同Mapper里映射的字段范围不同的话缓存里存的到底是哪个版本的字段列表就成了玄学问题。我的实操经验是实体对象的缓存尽量只服务读多写少、字段稳定的场景并且实体类要实现序列化接口涉及字段多变的动态查询不要开二级缓存。还有配合Redis这种外部缓存时最好做一层DTO转换不要在DAO层直接暴露可变的实体对象。5.3 从MyBatis到MyBatis-Plus的映射差异提到MyBatis结果集映射有个绕不开的相关话题是MyBatis-Plus在映射层面的差异。MyBatis-Plus是建立在MyBatis之上的增强框架内置了基础CRUD的SQL这些内置SQL的结果映射走的是TableName、TableField注解或者全局驼峰配置。MyBatis-Plus默认会把实体类的驼峰属性转换成下划线列名去查询。一旦你使用了TableField(exist false)标注的非表字段这个字段不会参与SQL查询但因为继承自MyBatis的自动映射机制当查询结果里恰好有同名列时它还是有可能被映射上。更直接的差异是MyBatis-Plus的selectMaps返回的MapKey默认是列名下划线风格而它内部查询用的实体映射又是驼峰属性。这种两层皮的情况经常让人困惑。搞懂了MyBatis原生映射机制后再去看MyBatis-Plus的行为就会清晰很多——它本质上还是在用MyBatis那套映射逻辑只是封装了更多自动化规则。6. 映射翻车现场几个典型排查案例与修复最后这部分我把这些年做MyBatis结果集映射排查时积累的典型问题和链路完整复盘一遍。这些问题不是手册上能查到的标准答案而是从实际项目里摔出来的经验。6.1 所有字段都查出来了返回对象里全是null这个问题是出现频率最高的而且通常发生在刚开启驼峰映射、或者刚联调一个新模块时。我处理这个问题的排查链路是这样的第一步先确认SQL在数据库客户端里能查出来数据排除SQL本身的问题。第二步看到控制台打印的SQL里字段名和user_name一致但实体类属性是userName脑内快速判断一下——如果全局配置了驼峰映射映射应该成功如果没配那么user_name肯定映射不到userName。随后打开日志翻出MyBatis的映射结果日志在log4j里配置org.apache.ibatis为TRACE级别可以打出参数和结果。你会发现MyBatis在映射时打的日志会明确显示它尝试映射的列和属性。问题往往在这一步就能定位。解决的方案要么是开启驼峰映射全局配置要么是给SQL的列名加别名比如select user_name as userName要么干脆手工写resultMap。三个方案都能解决问题但优先级我会这样排单表场景优先开全局驼峰关联查询场景优先给重复列或不规整列起别名实体字段名比较特殊时用resultMap。6.2 一对多映射后集合少数据或数据错乱这个案例前面提过一部分这里把排查思路完整串一遍。现象是一个部门下面有10个员工查出来变成3个而且每个人的列表互相混了。通常第一反应是SQL的JOIN出问题了但SQL直接执行出来的结果是10行数据完全正常。那问题就出在resultMap的嵌套映射上。检查collection内部的子实体映射发现子实体的id没有配置。MyBatis在嵌套结果映射时如果没有明确的id来唯一标识子对象它会用所有字段的值来综合判断两个结果对象是否相同。在这个例子里由于join出来的结果行里除了主键其他列比如name、phone有重复MyBatis就误判成了相同对象进行了合并去重。修复就是给子实体映射补上id。还遇到过一种变体id配置了但SQL里的order_id因为JOIN之后重名写成了o.id列的别名没有起对导致MyBatis拿到的列名是id而不是order_id同样匹配不上。这类问题的排查核心就是仔细对照SQL的列别名和resultMap的column拼写一个字符都不能差。6.3 Map接收结果时Key的大小写和驼峰问题用resultTypemap接收查询结果在写统计类报表时特别常见但也是最容易踩坑的地方。我给一个实际场景用select dept_id, count(*) as count_val from user group by dept_id进行统计映射到Map后打印Key控制台输出的是dept_id和count_val。如果代码里用map.get(deptId)去取值结果一定是null。这个和驼峰配置开关没有关系因为Map的Key映射不会自动把下划线转驼峰。处理方案我一般是两条路要么在SQL里给列起别名写成select dept_id as deptId要么在代码里取的时候直接使用下划线风格的Key。两条路我推荐第一条因为别名能让你在代码层面保持驼峰一致性少一层心智负担。另外要留意resultTypemap返回的是ListMapString, Object如果你在Map里嵌套复杂结构MyBatis不会帮你自动转换Value为对象它只会是数据库基础类型的值。这个限制决定了Map适合做展示型、统计型查询不适合做复杂领域模型组装。6.40被映射成null以及时间字段的时区陷阱这里有一个特别隐蔽的问题当数据库字段类型是int但值为0时MyBatis在某些驱动组合下可能映射成null。这个问题的根源不在MyBatis是因为字段类型和Java类型不匹配或驱动返回的类型是BIGINT但Java属性是Integer中间的类型转换环节在某些边界值上出现了异常。处理方案是保证Java实体类字段的包装类型与数据库字段类型严格对应。数据库是int就用Integer不要用Long数据库是bigint就用Long不要用Integer。很多人图省事从另一个实体里复制字段过来忘了改类型就会引发这类问题。特别是0和这种假空值最容易让人在排错时怀疑人生。时间字段的时区问题也经常和映射绑定在一起出现。DATETIME类型在数据库中存储的是本地时区的时间但JDBC驱动会基于JVM时区进行转换。如果你的服务器时区是UTC而数据库是东八区查出来就会差8个小时。这个问题体现在映射上就是你拿到的Date对象打印出来小时数总是少8。解决方案一般是在JDBC连接串上显式配置时区例如MySQL的serverTimezoneAsia/Shanghai保证驱动和数据库所用时区一致。如果项目统一使用UTC也行但展示层必须自行做一次本地化转换别双重转换。6.5 脱敏、加密字段在映射层的处理思路有些项目里数据库里的手机号、身份证号是加密存储的查询出来之后在Service层做解密。这种方案在数据量小时问题不大但涉及分页、批量查询时对每条数据做解密会带来性能损耗而且解密逻辑散落在多个Service方法里。更优雅的做法是利用MyBatis的typeHandler在结果集映射阶段自动完成解密。自定义一个TypeHandler在getNullableResult方法里拿到的已经是驱动查出来的密文字符串在此处执行解密逻辑后返回明文给实体MappedTypes(String.class) MappedJdbcTypes(JdbcType.VARCHAR) public class EncryptTypeHandler extends BaseTypeHandlerString { Override public String getNullableResult(ResultSet rs, String columnName) throws SQLException { String encryptValue rs.getString(columnName); return decrypt(encryptValue); } // 其他方法省略 }然后在resultMap中给对应字段指定typeHandlerresult columnphone propertyphone typeHandlercom.example.handler.EncryptTypeHandler/这样整个项目里凡是查询结果涉及手机号的地方都能统一解密不用改Service逻辑。同理枚举类型字段可以写通用的枚举TypeHandler来做值和展示文本的转换避免在每个实体里写getStatusDesc()这种半手工映射。从这个角度看MyBatis结果集映射的强大之处不只是字段匹配它给了你在Java类型和数据库类型这两个世界之间的翻译枢纽。读数据时走TypeHandler写数据时也走TypeHandler双向转换规则统一。这是很多ORM框架都比不上的灵活度。最后补一句个人经验结果集映射这个东西看起来零散实际上有一套完整自洽的逻辑。我的建议是把自动映射的规则、resultMap的各个子元素、嵌套映射的判定机制、全局配置的开关作用这几个点真正吃透而不是遇到问题就上网搜一段代码贴进去。排查映射问题的时候按照SQL列名 → resultMap column → Java属性 → 类型匹配这条链路一步步对绝大多数问题都能在十分钟内定位。像我前面说到的那些坑——缺id导致集合少数据、列名冲突导致映射错乱、Map取不到驼峰Key、时区差8小时——本质上都不是什么高深的技术问题只是对映射机制的理解缺了一角。写这篇小结的时候我把这些碎片化的知识点重新串了一遍也算是对自己过去经验的一次梳理。希望你下次遇到映射问题打开这篇文章对照排查链路能比当年的我少加几次班。
返回列表