首页 / 资讯中心 / 文章详情

MyBatis一对一关联映射:嵌套查询与嵌套结果方案详解

MyBatis一对一关联映射:嵌套查询与嵌套结果方案详解 ★ FEATURED ARTICLE
1. 先搞清楚一对一关联映射到底在解决什么问题做后端开发的朋友百分之百都写过类似的代码查订单列表要带出下单用户的名字查用户信息要带出用户的身份证详情查学生要带出学生的学号卡。这种“主表一条记录关联表恰好一条记录”的关系在数据库里就是典型的一对一关系。MyBatis对这种关系的处理提供了两套完全不同的方案嵌套查询和嵌套结果。这两套方案底层逻辑、执行效率、使用场景差异非常大很多人学的时候只记住了写法一到真正项目里选型就懵。我见过不少同事把嵌套查询用在所有关联场景里结果数据库被慢查询拖垮也见过有人明明只需要查一条用户信息非要写一堆复杂的多表join把简单问题搞复杂。这篇文章就直接把一对一映射的来龙去脉讲透两种方案各自的原理、适用场景、优缺点、避坑点外加一套完整可落地的实操案例。不管你是刚接触MyBatis的新手还是写了好几年CRUD的老手看完应该都能对“一对一”这件事形成一个立体的判断。先给个总览两种方案的核心区别用一句话就能说清嵌套查询先查主表拿到主表数据后再根据主表的外键字段额外发一条SQL去查关联表。两条SQL分步执行。嵌套结果一条SQL用join把主表和关联表所有需要的字段一次查出来再由MyBatis负责把结果集里的列映射到两个对象的属性上。听起来很简单但实际写起来细节非常多。下面一个个拆。2. 需求场景设定一个最典型的“用户-身份证”模型为了后面讲解不抽象我自定义一个模拟项目X模型就用最常见的“用户表sys_user”和“用户身份证表sys_user_idcard”业务规则是一个用户只能有一条有效的身份证记录一条身份证记录也只属于一个用户。这就是教科书般的一对一关系。两张表的建表SQL如下CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 用户ID, username VARCHAR(50) NOT NULL COMMENT 用户名, password VARCHAR(100) NOT NULL COMMENT 密码, email VARCHAR(100) COMMENT 邮箱, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间 ) COMMENT 用户表; CREATE TABLE sys_user_idcard ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 身份证记录ID, user_id BIGINT NOT NULL COMMENT 用户ID外键, idcard_no VARCHAR(18) NOT NULL COMMENT 身份证号码, real_name VARCHAR(50) NOT NULL COMMENT 真实姓名, address VARCHAR(200) COMMENT 住址, issue_date DATE COMMENT 签发日期 ) COMMENT 用户身份证表;对应的实体类我习惯在MyBatis中直接用POJO承载映射结果public class SysUser { private Long id; private String username; private String password; private String email; private Date createTime; // 一对一关联对象 private SysUserIdcard idcard; // getter/setter 省略 } public class SysUserIdcard { private Long id; private Long userId; private String idcardNo; private String realName; private String address; private Date issueDate; // getter/setter 省略 }请注意SysUser里多了一个SysUserIdcard类型的属性这个属性就是MyBatis完成关联映射后的“产物”。接下来两种方案都是围绕这个属性做文章。3. 嵌套查询方案分步查询代码直观但暗藏性能坑3.1 嵌套查询的映射写法嵌套查询的核心写法是在resultMap里通过association标签的select属性指定另外一条查询语句的id。MyBatis执行时会先执行主查询拿到每一条主表记录后再用主表记录的外键值去执行这条额外的查询语句。直接看代码用户Mapper接口和XML如下public interface SysUserMapper { SysUser selectUserWithIdcardNested(Long id); }对应的XMLresultMap idsysUserNestedMap typecom.demo.entity.SysUser id propertyid columnid / result propertyusername columnusername / result propertypassword columnpassword / result propertyemail columnemail / result propertycreateTime columncreate_time / association propertyidcard columnid javaTypecom.demo.entity.SysUserIdcard selectcom.demo.mapper.SysUserIdcardMapper.selectIdcardByUserId / /resultMap select idselectUserWithIdcardNested resultMapsysUserNestedMap SELECT id, username, password, email, create_time FROM sys_user WHERE id #{id} /select然后身份证Mapper里提供一个按userId查询的方法select idselectIdcardByUserId resultTypecom.demo.entity.SysUserIdcard SELECT id, user_id AS userId, idcard_no AS idcardNo, real_name AS realName, address, issue_date AS issueDate FROM sys_user_idcard WHERE user_id #{userId} /select这里有几个细节必须注意association中的columnid表示把主查询结果集里的id列的值作为参数传给select指向的那条查询。如果关联查询需要多个参数column可以写成column{userIdid, otherxxx}这种键值对形式。select的值必须是Mapper接口全限定名加方法idMyBatis才能定位到那条Statement。嵌套查询里association指定的子查询resultType可以直接用实体类但字段映射必须靠别名或者单独再写一个resultMap。3.2 嵌套查询的执行流程和底层逻辑我直接画个执行时间线大家感受一下它的行为模式第一条SQL执行SELECT id, username, ... FROM sys_user WHERE id ?拿到用户记录后MyBatis发现resultMap里有association且配置了select于是提取该行记录的id列值第二条SQL执行SELECT ... FROM sys_user_idcard WHERE user_id ?把第二条SQL查询结果封装成SysUserIdcard对象set到SysUser.idcard属性上如果主查询返回了N条记录那么子查询就会执行N次。这一点是嵌套查询最核心的性能特征一条主查询 N条子查询也就是经典的“N1查询问题”。3.3 嵌套查询的适用场景与注意事项嵌套查询最大的价值在于“懒加载”也叫延迟加载。什么叫懒加载就是关联对象在真正被使用的时候才去查数据库。比如你现在只查询用户的列表展示的时候只显示用户名完全不碰身份证信息那子查询完全可以不执行。MyBatis里开启懒加载需要两个配置aggressiveLazyLoadingfalse不要一查到主对象就立刻加载关联对象lazyLoadingEnabledtrue开启全局懒加载开关具体来说在MyBatis全局配置文件中加settings setting namelazyLoadingEnabled valuetrue/ setting nameaggressiveLazyLoading valuefalse/ /settings开启后调用getIdcard()方法时才真正触发子查询。这个机制对于“关联数据不总是需要展示”的场景非常友好比如订单列表接口用户点击详情时才去拿详细数据。但请注意懒加载有它自己的坑如果子查询在SQL会话关闭之后才被触发会抛LazyInitializationException。解决方案要么保证在事务范围内访问要么直接关闭懒加载。嵌套查询带来的N1问题在开启懒加载后并没有真正解决只是把N次子查询延迟到了访问时。如果业务方在大循环里挨个访问idcard属性性能照样崩。所以我的个人建议是嵌套查询适合“关联查询不频繁、主表数据量大但关联数据使用频率低”的场景。一旦确认每条主记录都必须要关联数据嵌套查询基本就是性能灾难这时候应该换成嵌套结果。注意嵌套查询在调试时务必开启MyBatis的SQL日志实际项目中我见过好多次“查询变慢但不知道慢在哪”一打开日志才发现原来一次查询悄悄变成了几十次子查询。3.4 嵌套查询的常见问题排查子查询字段为null多半是column属性写成固定值了或者主查询的结果集里没有这个列名。子查询执行了但结果没映射上检查子查询的resultType是否写对别把idcardNo写成idcardno别名对不上就是null。报错too many result如果两张表真的严格一对一但子查询返回多条说明数据有问题去查数据库里的外键重复数据。4. 嵌套结果方案一条SQL连表查高效但映射细节更讲究4.1 嵌套结果的映射写法嵌套结果方案本质上就是一条SQL把主表和关联表的字段全查出来然后通过resultMap里association标签的result子标签把结果集里的列逐列映射到关联对象的属性上。同样以上面的需求为例先写Mapper方法public interface SysUserMapper { SysUser selectUserWithIdcardResult(Long id); }XML如下resultMap idsysUserResultMap typecom.demo.entity.SysUser id propertyid columnid / result propertyusername columnusername / result propertypassword columnpassword / result propertyemail columnemail / result propertycreateTime columncreate_time / association propertyidcard javaTypecom.demo.entity.SysUserIdcard id propertyid columnidcard_id / result propertyuserId columnidcard_user_id / result propertyidcardNo columnidcard_no / result propertyrealName columnreal_name / result propertyaddress columnaddress / result propertyissueDate columnissue_date / /association /resultMap select idselectUserWithIdcardResult resultMapsysUserResultMap SELECT u.id AS id, u.username AS username, u.password AS password, u.email AS email, u.create_time AS create_time, c.id AS idcard_id, c.user_id AS idcard_user_id, c.idcard_no AS idcard_no, c.real_name AS real_name, c.address AS address, c.issue_date AS issue_date FROM sys_user u LEFT JOIN sys_user_idcard c ON u.id c.user_id WHERE u.id #{id} /select看到区别了吗嵌套结果方案里SQL的SELECT列表必须为关联对象的所有字段起别名不要跟主表的字段别名冲突。因为MyBatis的映射是根据column来定位的主表列和关联表列如果同名就会映射错乱。4.2 嵌套结果的底层原理结果集自动分片嵌套结果之所以能用一个resultMap搞定两个对象核心在于MyBatis内部有一个结果集分片机制。我用一个类比来解释一条join查询返回的结果集好比一个长表格每一行同时包含用户列和身份证列。MyBatis解析这一行时先把属于用户列的字段拿出来拼SysUser对象再把属于身份证列的字段拿出来拼SysUserIdcard对象最后把后者塞进前者的idcard属性。如果主表一条记录对应多条关联记录结果集里用户字段会重复出现MyBatis会依靠resultMap里的id标签判断是否同一个主对象。如果id重复就复用同一个主对象只往集合属性里追加关联对象。这就是为什么很多人说“嵌套结果天然支持延迟加载之外的一对多映射”本质上靠的是这个id去重机制。但在一对一场景下主表和关联表都是唯一记录所以不存在对象复用问题。这个机制反而提醒我们写一对一resultMap时关联对象的那个id字段一定别忘了配置否则可能引发奇怪的重复封装。4.3 嵌套结果的适用场景与注意事项嵌套结果的优点非常明显只有一条SQL数据库只进行一次查询性能远优于嵌套查询结果集完整没有懒加载异常风险事务边界外也能安全使用因为数据在第一次查询时就已经全部查回来了缺点是SQL复杂尤其当关联层级深、字段多时整个SQL和resultMap会变得非常啰嗦。另外join查询本身对索引的依赖很强如果关联字段没索引数据量大时性能一样会崩。适用于查询时必须完整展示关联数据比如详情页、报表、管理后台列表这些场景。注意嵌套结果方案不要和懒加载一起用懒加载只对嵌套查询生效。很多人配置了lazyLoadingEnabled后以为嵌套结果也能懒加载实际上完全无效白白增加了理解成本。4.4 嵌套结果的常见问题排查关联对象是null但SQL能查出数据大概率是参数#{}写错或者是JOIN方向有问题。如果你用LEFT JOIN主表有记录但关联表没有那关联对象本来就是null这是符合预期的。查询结果字段错乱查一下SELECT列表里有没有重复别名比如两张表都有create_time没起别名就会串。association里有属性查出来是null检查resultMap里该属性的column名是否和SQL别名严格一致。5. 两个方案对比一张表看懂怎么选写到这里估计有人要问那我到底用哪个先直接上一个对比表方便收藏维度嵌套查询嵌套结果SQL数量主查询子查询多条SQL一条join SQL执行次数1N次查询1次查询延迟加载原生支持不支持映射复杂度简单一个association带select需要为每个字段起别名映射繁琐N1问题存在需要警惕不存在适用场景关联数据不常展示数据量可控关联数据必须展示数据量大排查难度子查询多日志长SQL复杂字段映射混淆风险事务外安全懒加载可能抛异常安全再说说选型时的三个核心判断标准第一数据量。主表可能查出一千条数据且每条都需要关联信息直接弃用嵌套查询。N1在这个量级下会产生一千零一条SQL任何一个数据库都扛不住。第二关联数据是否稳定需要。如果一半场景只需要主表字段一半需要关联字段嵌套查询配合懒加载是合理的。但要注意如果调用方是在for循环里到处get关联属性懒加载反而变成紧加载一样慢。第三SQL可维护性。嵌套结果的join SQL和resultMap都比较长如果团队里都是MyBatis新手维护成本很高。这时候宁可多写几条子查询SQL也别写出一个谁都看不懂的巨型resultMap。这三点对应了大多数实际开发场景。我自己的习惯是查询列表用嵌套结果查询详情用嵌套结果只有那种“只在极少情况下才需要展示关联信息”的旁路查询才用嵌套查询配懒加载。6. 完整实操案例嵌套结果方案的完整落地方案这部分我直接给出一个完整的、可以直接抄作业的项目案例。选用嵌套结果方案因为这个方案在绝大多数业务场景下是更稳的选择。6.1 项目结构我习惯按功能模块分包这里简化为src/main/java/com/demo ├── entity │ ├── SysUser.java │ └── SysUserIdcard.java ├── mapper │ ├── SysUserMapper.java │ └── SysUserMapper.xml6.2 Mapper接口完整代码package com.demo.mapper; import com.demo.entity.SysUser; public interface SysUserMapper { SysUser selectUserWithIdcard(Long id); }6.3 Mapper XML完整代码?xml version1.0 encodingUTF-8? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.demo.mapper.SysUserMapper resultMap iduserWithIdcardMap typecom.demo.entity.SysUser id propertyid columnid / result propertyusername columnusername / result propertypassword columnpassword / result propertyemail columnemail / result propertycreateTime columncreate_time / association propertyidcard javaTypecom.demo.entity.SysUserIdcard id propertyid columnidcard_id / result propertyuserId columnidcard_user_id / result propertyidcardNo columnidcard_no / result propertyrealName columnreal_name / result propertyaddress columnaddress / result propertyissueDate columnissue_date / /association /resultMap select idselectUserWithIdcard resultMapuserWithIdcardMap SELECT u.id AS id, u.username AS username, u.password AS password, u.email AS email, u.create_time AS create_time, c.id AS idcard_id, c.user_id AS idcard_user_id, c.idcard_no AS idcard_no, c.real_name AS real_name, c.address AS address, c.issue_date AS issue_date FROM sys_user u LEFT JOIN sys_user_idcard c ON u.id c.user_id WHERE u.id #{id} /select /mapper6.4 关键细节解释resultMap里主表和关联表的id都分别配置了。主表的id标签用于唯一标识主对象关联表的id标签用于唯一标识关联对象。一对一场景可能感知不强但保持这个习惯后面写一对多时能少踩很多坑。主查询用了LEFT JOIN而不是INNER JOIN这样即使某用户没有身份证记录查询结果依然有一行只是idcard属性为null。如果你希望查不到关联数据时整个用户也不要返回那可以用INNER JOIN。每一列都显式起了别名这是嵌套结果方案最容易出错的点千万不要省。6.5 实际执行效果说明当调用selectUserWithIdcard(1L)时MyBatis执行一条SQL。假设数据库里用户id1有一条记录且关联身份证有一条记录那么返回的SysUser对象的idcard属性会被完整填充可以在业务层直接调用user.getIdcard().getIdcardNo()获取身份证号。如果数据库里用户id2没有身份证记录LEFT JOIN返回一行但身份证列的值为null最终SysUser.idcard为null。业务代码里必须做空值判断这是很多空指针异常的来源。我实际遇到过一个线上问题详情页明明查到了用户一访问user.getIdcard().getRealName()就报空指针。原因就是该用户数据在身份证表里压根没有记录join查出来是null。自那以后凡是跟关联对象打交道的代码我都先判断if(user.getIdcard() ! null)。6.6 嵌套结果方案的SQL优化思路嵌套结果方案虽然只执行一条SQL但join查询并不代表一定快。如果两张表数据量大必须确保join字段有索引。上面案例里sys_user_idcard.user_id作为外键没有索引的话查询就会被全表扫描拖垮导致哪怕只查一条用户数据也要扫完整个身份证表。一般需要加的索引ALTER TABLE sys_user_idcard ADD INDEX idx_user_id (user_id);在开发环境可以用EXPLAIN验证执行计划看type是否达到ref或eq_ref级别而不是ALL全表扫描。7. 嵌套查询在真实场景中的正确使用姿势前面说了嵌套查询一堆问题但它并非一无是处。如果场景选对了它反而比嵌套结果更优雅。这里展开讲两个典型的“正确用法”。第一个典型场景分级查询设计。比如你要查一个用户同时用户关联了收货地址、订单、优惠券等多张表但详情页默认只展示用户基本信息和最近的订单地址和优惠券分开的tab页里点击才加载。这种场景下把地址、优惠券做成嵌套查询配合懒加载用户不点tab就不触发SQL体验和性能都能兼顾。第二个典型场景多级嵌套。比如查订单-查订单项-查商品信息层级很深。嵌套查询的association可以一层层套下去SQL写起来非常简单每条子查询都很短。换成嵌套结果的话一条SQL要join三张表甚至四张表字段别名和resultMap会写得痛不欲生。所以我的建议是不要听风就是雨看到“N1”就否定嵌套查询。评判标准永远只有一个——你的业务到底会不会频繁访问关联对象。不会频繁访问嵌套查询加懒加载是好东西会频繁访问那它就是把SQL放大了N倍的定时炸弹。特别提醒公司项目的ORM层如果开启了二级缓存嵌套查询的懒加载对象默认是不能跨SqlSession访问的二级缓存一旦参与进来懒加载失效的情况非常常见。真遇到诡异问题先检查缓存配置。8. 几个容易被忽略的坑字段映射、别名与空值这部分其实是我最想写的。很多人在一对一映射上踩坑根本不是原理不懂而是细节没注意。8.1 字段名映射的大坑MyBatis默认有个驼峰转换配置mapUnderscoreToCamelCase很多项目为了省事直接打开setting namemapUnderscoreToCamelCase valuetrue/打开后数据库列名idcard_no会自动映射到Java属性idcardNo看起来非常美好。但我在一次排查中发现一旦resultMap里手动写了result映射mapUnderscoreToCamelCase就不一定生效了。尤其嵌套结果的association子标签里如果某列没写result子标签MyBatis可能不会自动做驼峰转换导致关联对象属性为null。这个问题的排查方式非常反直觉明明数据库有值、SQL查得出来但对象属性就是null。第一反应是查resultMap而不是去查SQL。如果确认resultMap没漏字段再看是不是驼峰转换的配置在resultMap场景下的兼容性问题。8.2 空值处理细节嵌套结果方案里LEFT JOIN返回的null字段MyBatis默认不调用对应属性的setter。也就是说如果idcard_no是nullSysUserIdcard对象的idcardNo属性根本不会被赋值维持默认值null。这听起来没问题但如果你的代码里对某个字段设了默认值或者用构造器初始化了非空默认值就会产生一个非常隐蔽的bug对象里的字段不是null而是默认值。我遇到过的一个案例SysUserIdcard类里给idcardNo字段赋了初始值unknown。正常运行没问题但一旦某用户没有身份证记录LEFT JOIN查出来是null关联对象应该为null结果却new出了一个SysUser对象里面的idcardNo是unknown——这个假数据直接被业务层写进了日志排查了很久才定位到。结论实体类里不要给会被ORM映射的字段设默认初始化值。这不是Java的问题是MyBatis赋值机制的坑。8.3 association的fetchType属性其实MyBatis在association标签上还提供了一个fetchType属性可以单独控制某个关联查询是否懒加载。它的优先级高于全局配置association select... fetchTypelazy/或者association select... fetchTypeeager/这个属性主要用在嵌套查询场景。有些时候你只希望某一个关联查询懒加载但不想开全局懒加载就可以用它。这个细节很多人不知道但实际排查懒加载异常时非常有用。8.4 参数传递嵌套查询的column传参前面提到association的column可以传多个参数这里补一个具体例子。比如你有个关联查询条件不只是外键还要加一个状态字段可以这样写association propertyidcard column{userIdid, userStatusstatus} selectcom.demo.mapper.SysUserIdcardMapper.selectIdcardByUserIdAndStatus/这样会把主查询结果集里的id列传给userId参数把status列传给userStatus参数。子查询的Mapper方法要用Param注解明确参数名或者XML里用#{userId}、#{userStatus}对应。这个写法在官方文档里不太显眼但实际项目中非常实用特别是关联查询还需要额外条件过滤的时候。9. 从代码评审角度聊聊为什么有人会把一对一映射写成一团乱麻最后聊一点团队协作层面的经验因为一对一映射本身不难难的是在多人协作的代码库里保持清晰。我参与过好多次代码评审看到最多的问题不是写错而是“混合使用”。同一个项目里有人用嵌套查询有人用嵌套结果同一个resultMap被改了七八轮一会儿加select一会儿改join最终整个XML变得非常混乱。我的建议是项目里最好定一个规范列表查询有100%关联数据需求的统一用嵌套结果详情查询原则上也用嵌套结果除非关联数据非常重才用嵌套查询加fetchTypelazy禁止在service层循环里调用懒加载getter另外resultMap的命名也统一一下我一般用“主表名关联表名Map”的方式来命名。比如userWithIdcardMap这样一眼就能看出这个Map是用来做哪两个表关联映射的。select方法的命名同样带后缀比如selectUserWithIdcard避免几十个select方法混在一起时根本分不清哪个是哪个。XML文件的头部、缩进、注释格式也建议统一很多团队都用代码格式化插件但XML的格式化经常被忽略导致合并冲突一堆。这虽然是工程化的事但发生在MyBatis映射文件里往往比普通Java代码更难以排查。我的经验是一对一的resultMap写好之后就不要轻易动。真要改字段宁可新建一个resultMap也别在一个旧Map上反复打补丁。否则哪天线上突然多查出一列排查半天才发现是某个同事在一个共享Map上加了一行result。10. 我的最终建议两种方案各司其职组合拳才是王道如果你问我最终推荐哪个我的回答永远是一句话两种都用但用得聪明。实际项目中一个中大型业务的Mapper文件里往往会同时存在嵌套查询和嵌套结果。我的习惯是核心列表页、详情页、导出报表全部用嵌套结果保证一次查询拿全数据冷门数据的补充查询、旁路信息展示用嵌套查询配合fetchTypelazy需要时再触发凡是嵌套结果SQL里的join字段必须确认有索引凡是嵌套查询SQL日志必须开着方便发现N1问题不要试图在技术上追求“绝对正确”要从业务数据流的角度判断哪一种方案的代价最小。嵌套查询的代价是SQL数量嵌套结果的代价是SQL复杂度和映射维护成本。大多数企业级应用的性能瓶颈不在单条SQL而在SQL数量这也是我更倾向嵌套结果的原因。最后再分享一个小技巧当你写的resultMap越来越长、字段越来越多时可以试试给实体类加上无参构造器和全参构造器并在resultMap里使用constructor标签。尤其是关联对象的字段很多时用constructor比一堆result子标签更简洁也让对象的不可变性更好。不过这个用法要小心如果数据库查出来某个字段是null构造器参数就会出现null代码里要根据实际业务语义处理。一对一映射说到底是MyBatis入门到进阶的一道分水岭。把它彻底搞明白后面的一对多、多对多、递归查询理解起来都会顺畅很多。希望这篇文章能帮你少走点弯路也欢迎有不同实战经验的朋友一起讨论各自的踩坑记录。
阅读完成 · 觉得有帮助?
咨询建站