上篇我们把 resultMap 的基础模型、association 一对一、collection 一对多都捋了一遍。如果你已经消化得差不多,那么多表关系映射的下半场就好办多了——这一篇要啃的是真正每天会在业务里遇到的硬骨头:多对多映射、同名列冲突、N+1 查询、动态 SQL 的条件陷阱、二级缓存脏读,还有三个我打包票你迟早会碰到的报错。
我尽量不写教科书式的东西,所有内容都从实际项目里来。代码能直接抄,坑能提前避,这是这篇文章的核心目标。案例统一用商城后台的商品库模型:商品表 product、品牌表 brand、分类表 category、供应商表 supplier,品牌和供应商之间通过一张中间表 brand_supplier 维持多对多关系。后面所有章节都会反复用到这套表,所以先花三十秒把关系看明白。
1. 多对多映射:用两段一对多拼出关系模型
1.1 建表与SQL:一张中间表解决品牌和供应商的关系
先说清楚一个很多人刚接触时的疑问:MyBatis 里到底有没有专门的多对多标签?答案是没有,也不需要。多对多关系在数据库层面就是通过中间表拆成两个一对多,在 MyBatis 里同样如此。你永远不会在 resultMap 里写一个 <many-to-many>,你写的是两个 collection,或者一个 association 套一个 collection。
以品牌和供应商为例,表结构大概是这样的:
sql复制CREATE TABLE brand (
id BIGINT PRIMARY KEY,
brand_name VARCHAR(100),
logo_url VARCHAR(255),
deleted TINYINT DEFAULT 0
);
CREATE TABLE supplier (
id BIGINT PRIMARY KEY,
supplier_name VARCHAR(100),
contact_name VARCHAR(50),
contact_phone VARCHAR(20)
);
CREATE TABLE brand_supplier (
id BIGINT PRIMARY KEY,
brand_id BIGINT,
supplier_id BIGINT
);
查询某个品牌下的所有供应商,SQL 就是三张表 left join:
sql复制SELECT
b.id,
b.brand_name,
b.logo_url,
s.id AS supplier_id,
s.supplier_name,
s.contact_name,
s.contact_phone
FROM brand b
LEFT JOIN brand_supplier bs ON b.id = bs.brand_id
LEFT JOIN supplier s ON bs.supplier_id = s.id
WHERE b.id = #{brandId}
注意供应商表的所有字段我都加了别名,supplier_id、supplier_name。这不是多此一举,是多表映射的第一条生命线。后面第 2 章会专门讲为什么。
对应的 resultMap 长这样:
xml复制<resultMap id="brandMap" type="cn.demo.entity.Brand">
<id property="id" column="id"/>
<result property="brandName" column="brand_name"/>
<result property="logoUrl" column="logo_url"/>
<collection property="supplierList" ofType="cn.demo.entity.Supplier">
<id property="id" column="supplier_id"/>
<result property="supplierName" column="supplier_name"/>
<result property="contactName" column="contact_name"/>
<result property="contactPhone" column="contact_phone"/>
</collection>
</resultMap>
这个写法其实就是一对多,区别只在于数据来源多了一张中间表。很多刚接触的人看到多对多就紧张,其实把它拆开来看,一个 collection 解决"品牌有哪些供应商",反过来再写一个 collection 解决"供应商供应哪些品牌",两边都是标准的一对多。
1.2 resultMap里为什么没写“多对多标签”?中间结果集说了算
为什么不需要多对多标签?因为 MyBatis 的映射机制根本不关心你的表之间是什么关系,它只看两样东西:SQL 返回的列,以及你配置的 resultMap。resultMap 负责把结果集里的每一行,按照 <id> 和 <result> 的配置,组装成一个个 Java 对象;<collection> 只是告诉 MyBatis"这一行里带了一个子对象列表的元素"。
理解这一点特别重要。比如上面查品牌和供应商的 SQL,如果 id 为 1 的品牌有 3 个供应商,那么 SQL 返回 3 行,品牌字段在这 3 行里重复出现。MyBatis 在遍历结果集时,第一次碰到 id=1 时创建 Brand 对象,之后每碰到一个 supplier_id,就把供应商封装好塞进同一个 Brand 对象的 supplierList 里。不去重、不额外查数据库,纯粹靠内存里的对象引用。
所以你在配置 resultMap 时,脑子里应该想的是"结果集长什么样、我按什么列分组合并对象",而不是"表关系是几对几"。这也是为什么 MyBatis 能用一个 resultMap 同时撑起一对一、一对多、多对多的原因——映射规则是通用的,关系只是表设计的定语。
1.3 什么情况下才需要第三层嵌套,怎么避免性能爆炸
实际项目里常出现"商品的品牌有多个供应商"这种三层结构:一个商品关联一个品牌,一个品牌关联多个供应商。此时 resultMap 可以写成 association 套 collection:
xml复制<resultMap id="productDetailMap" type="cn.demo.entity.Product">
<id property="id" column="id"/>
<result property="productName" column="product_name"/>
<result property="price" column="price"/>
<association property="brand" javaType="cn.demo.entity.Brand">
<id property="id" column="brand_id"/>
<result property="brandName" column="brand_name"/>
<result property="logoUrl" column="logo_url"/>
<collection property="supplierList" ofType="cn.demo.entity.Supplier">
<id property="id" column="supplier_id"/>
<result property="supplierName" column="supplier_name"/>
<result property="contactName" column="contact_name"/>
<result property="contactPhone" column="contact_phone"/>
</collection>
</association>
</resultMap>
但这里要泼一盆冷水:三层嵌套尽量别用一次 join 查出来。原因很现实,如果商品 A 的品牌有 20 个供应商,这一条商品就会在结果集里膨胀成 20 行,商品本身的信息(价格、状态)重复 20 次。查询 100 个商品时结果集可能变成几千行,传输和对象构建的开销都会变大。
我实践中的做法是:详情页需要展示三层数据时,先查商品单条记录,再按 brand_id 查品牌和供应商列表,也就是最多两次查询加上一次集合查询。只有列表页需要带出一层关联信息(比如商品列表带品牌名)时,才用一条 join 解决。三层嵌套留给需求真的非常看重一次性返回的场景,而且必须做数据量评估。这个取舍在第 3 章讲 N+1 问题时还会再提到。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 同名列覆盖问题:columnPrefix如何把联查结果收拾干净
2.1 一个看上去正常却返回null的联查结果
多表联查最常见的诡异现象是:单表查询一切正常,一旦 join 了别的表,主表或者关联对象的某些字段变成 null,甚至主键都被改掉了。原因往往只有一个——结果集里出现了同名列。
举个我见过的真实配置:
xml复制<resultMap id="productMap" type="cn.demo.entity.Product">
<id property="id" column="id"/>
<result property="productName" column="product_name"/>
<association property="brand" javaType="cn.demo.entity.Brand">
<id property="id" column="id"/> <!-- 问题在这 -->
<result property="brandName" column="brand_name"/>
</association>
<association property="category" javaType="cn.demo.entity.Category">
<id property="id" column="id"/> <!-- 又冲突 -->
<result property="categoryName" column="category_name"/>
</association>
</resultMap>
SQL 里 SELECT 出来的是 p.id, b.id, c.id,三个表都有 id 列,没有起别名。JDBC 的 ResultSet 通过列名取值时,遇到重复列名通常返回第一个匹配项,于是 Product.id、Brand.id、Category.id 拿到的全是同一个值。你以为能拿到商品的品牌、分类,结果它们的主键全变成了商品 id,后续再用这些 id 做任何操作都会出错。更隐蔽的是,如果属性名恰好一样,比如商品和品牌都有 status 字段,还会出现"商品的上下架状态被品牌状态覆盖"这种更难察觉的问题。
所以再次强调第 1 章那句话:多表查询的所有列,只要有可能重名,就必须起别名。这不是规范不规范的问题,是活命的问题。
2.2 一个resultMap复用于多张表的写法
解决同名列覆盖有两个路径。第一个是土办法,在 SQL 里给每个字段起别名,然后 resultMap 的 column 指向别名。这个办法有效,但有个很烦的问题:如果一个 brandMap 想复用到不同的查询场景,每个查询都要记得给 brand 字段加同样的前缀,一旦漏一个就出问题。
第二个是官方解法,从 MyBatis 3.4.2 开始支持的 columnPrefix 属性。它的作用是把 resultMap 里的 column 自动加一个前缀去结果集里找列,这样你的 brandMap 不用为每个场景单独调整,SQL 侧只需要统一给 brand 表字段加 brand_ 前缀即可。
看个例子。先定义基础的 brandMap:
xml复制<resultMap id="brandMap" type="cn.demo.entity.Brand">
<id property="id" column="id"/>
<result property="brandName" column="brand_name"/>
<result property="logoUrl" column="logo_url"/>
</resultMap>
然后商品联查品牌时,这样引用:
xml复制<resultMap id="productWithBrandMap" type="cn.demo.entity.Product">
<id property="id" column="product_id"/>
<result property="productName" column="product_name"/>
<result property="price" column="price"/>
<association property="brand"
resultMap="brandMap"
columnPrefix="brand_"/>
</resultMap>
对应的 SQL:
sql复制SELECT
p.id AS product_id,
p.product_name,
p.price,
b.id AS brand_id,
b.brand_name,
b.logo_url
FROM product p
LEFT JOIN brand b ON p.brand_id = b.id
此时 brandMap 里的 <id property="id" column="id"/> 实际会去结果集里找 brand_id 这一列,brand_name 列找 brand_name。只要 SQL 侧永远遵循"品牌字段一律加 brand_ 前缀"这个约定,brandMap 就能在任何查询里复用,不会出现把 product.id 误当成 brand.id 的情况。
2.3 开启下划线自动映射后,为什么别名反而不能乱加
很多项目会在 application.yml 里配置:
yaml复制mybatis:
configuration:
map-underscore-to-camel-case: true
这个配置能把 product_name 自动映射到 productName,非常方便。但它只对 resultType 自动映射生效,对 resultMap 里显式配置的 column 不生效——resultMap 里写了什么 column 就用什么列名去找,不会帮你把下划线转驼峰。
所以你会遇到这样的情况:resultType 查询时一切正常,改成 resultMap 后好多字段变成 null,检查了半天,原来是 resultMap 里写了 <result property="productName" column="product_name"/>,但 SQL 返回的列名是 product_name,没问题对吧?那为什么是 null?很大概率是这一列在结果集里确实叫 product_name,但你的 SQL 给别的字段起别名时不小心把它也改了名,或者两个关联表的列名疑似相同导致 MyBatis 实际上取到了别的列。
实践经验:开启 map-underscore-to-camel-case 后,resultType 的场景可以偷懒,但 resultMap 的场景一定要显式把每一个 column 写清楚,并且在写 SQL 时把列的来源表确认一遍。尤其是多个表都存在 create_time、update_time、deleted 这类通用字段时,必须给它们加表别名前缀或自定义别名,否则自动映射的列会互相踩踏。
3. N+1问题排查全程:从慢页面到SQL执行次数统计
3.1 页面卡顿,第一件事先开日志
N+1 问题是多表关系映射里最经典、也是上线后最容易被用户感知的问题。症状非常统一:列表页打开特别慢,100 条数据可能要 2 到 3 秒;单看 SQL 每一条都不慢,但执行次数多到离谱。
我排查这种问题的第一步永远是打开 MyBatis SQL 日志,先看执行了多少条 SQL、长什么样。Spring Boot 项目里最简单的方式:
yaml复制mybatis:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
或者在 application.yml 里针对 Mapper 包开 debug 日志:
yaml复制logging:
level:
cn.demo.mapper: debug
控制台会打印每条 SQL 的参数和查询结果条数。如果发现商品列表查询只执行了 1 次,但品牌查询执行了 100 次,N+1 就坐实了。
顺便提一句,日志里打印的 SQL 默认是带 ? 占位符的,没法直接复制到数据库执行。想快速看到可执行 SQL,可以用 p6spy 这类 JDBC 代理工具,它会拦截真实参数并拼出完整 SQL。团队里如果管理严格,也可以在 IDEA 里装 MyBatis Log Plugin 这种插件,看日志时直接点格式化。不过我自己的习惯是先看占位符和参数列表,大多数问题不需要完整 SQL 就已经能定位了。
3.2 根因在 resultMap 的 select 属性:嵌套查询有多可怕
N+1 出现的根因,绝大多数是 resultMap 里用了嵌套查询。比如商品查询配成这样:
xml复制<resultMap id="productMap" type="cn.demo.entity.Product">
<id property="id" column="id"/>
<result property="productName" column="product_name"/>
<association property="brand"
javaType="cn.demo.entity.Brand"
column="brand_id"
select="cn.demo.mapper.BrandMapper.selectById"/>
</resultMap>
这条配置的意思是:查完商品列表后,每拿到一行结果,就用这一行的 brand_id 再去执行一次 BrandMapper.selectById。查 100 个商品执行 100 次品牌查询;如果品牌查询里再嵌套查供应商列表,那就会变成 100 乘以每个品牌的供应商数量,SQL 爆炸式增长。
嵌套查询并不是一无是处,它写起来简单,resultMap 看起来非常清晰,而且配合延迟加载可以在某些场景下减少不必要的查询。但它对列表页是致命的。列表页的特点就是主表数据量大,每一行都需要关联数据,嵌套查询必然导致"1 + N"次数据库往返。
3.3 改成 join 查询之后,为什么结果又乱了
修复 N+1 的主流方案是改成 join 查询,一条 SQL 把需要的关联数据全部查出来。还是商品带品牌的例子:
xml复制<resultMap id="productWithBrandMap" type="cn.demo.entity.Product">
<id property="id" column="product_id"/>
<result property="productName" column="product_name"/>
<result property="price" column="price"/>
<association property="brand" resultMap="brandMap" columnPrefix="brand_"/>
</resultMap>
改成 join 后,一条 SQL 执行 100 行查询,只访问一次数据库,性能立竿见影。但这里有个常见的后续问题:如果关联对象本身是一对多,比如商品列表要带"这个商品分类下的所有子分类",join 出来的结果集会因为子表数据多而产生行膨胀,每行商品信息重复多次。此时如果不注意 <id> 的正确配置,MyBatis 可能为同一个商品创建多个对象,分页结果和去重逻辑全部错乱。
应对行膨胀的成熟做法是:分页先只查主表,拿到当前页的主表 id 列表,再用 WHERE id IN (...) 去查关联表,最后由程序组装。这个方法在数据量可控、关联层级多的情况下,比一次大 join 更稳。一句话总结:列表页能一层 join 就一层 join,层级多了就拆查询,别硬撑。
4. 多表动态SQL的or优先级陷阱与分页适配
4.1 or条件直接把整张表的条件“放飞”了:一个事故复盘
多表查询的动态 SQL 里,or 和 and 的优先级问题是个高频事故。SQL 里 AND 的优先级高于 OR,这大家都知道,但写动态 SQL 时因为 <if> 拼接,很容易把括号丢掉,导致条件失效。
一个很典型的场景:商品列表页支持按商品名或品牌名搜索,同时必须过滤上架状态。新手写出来的 XML 往往是这样的:
xml复制<select id="searchProduct" resultMap="productWithBrandMap">
SELECT ...
FROM product p
LEFT JOIN brand b ON p.brand_id = b.id
WHERE
p.status = 1
AND p.product_name LIKE CONCAT('%', #{keyword}, '%')
OR b.brand_name LIKE CONCAT('%', #{keyword}, '%')
</select>
当用户输入了一个品牌关键词时,这条 SQL 会把所有满足"品牌名匹配"的商品全部捞出来,哪怕它们是下架状态。因为 OR 把整个条件劈成了两块:(p.status = 1 AND p.product_name LIKE ?) 和 (b.brand_name LIKE ?),后者完全没有状态限制。这种问题在测试环境很难发现,因为测试数据往往少,下架商品没几条;上线后数据量一大,用户立刻就会来反馈"我搜到一堆已下架的商品"。
正确写法是给 OR 加括号:
sql复制WHERE p.status = 1
AND (
p.product_name LIKE CONCAT('%', #{keyword}, '%')
OR b.brand_name LIKE CONCAT('%', #{keyword}, '%')
)
MyBatis 的 <where> 标签会自动去掉开头多余的 AND 或 OR,但它不会帮你给括号内的条件做任何魔法处理。你在 <if> 里拼出来的每一段 SQL,最终都是原样拼接的,括号必须自己在片段里写清楚。我建议所有多表查询的条件都用 <where> 标签包裹,并且每一组 OR 都用显式括号括起来,别偷懒。
4.2 多表join分页的count优化与collection兼容
多表 join 之后用 PageHelper 或 MyBatis-Plus 分页插件,最常见的坑是 count 语句。PageHelper 会自动生成 count SQL,但遇到 group by、distinct、多表 join 时,它生成的 count 往往把 join 表也统计进来,导致总数偏大。比如统计商品总数,如果 join 了品牌表,PageHelper 生成的 count 会带着 join,可能因为品牌表的一对多关系把同一商品统计了多遍。
解决办法有两个方向。第一个是给 PageHelper 配置手写 count SQL,在 <select> 里写一个专门的 count 查询,用 pagehelper.count-sql 或者直接在 select 标签上指定 count 子查询。第二个更通用的思路:分页查询的 SQL 只查主表,不 join 任何可能产生行膨胀的表,拿到主表主键列表后再用 IN 查关联数据。这样 count 和 page 都是简单单表查询,分页精准,关联数据再单独组装。代价是多写几个查询方法,但性能和正确性都有保障。
4.3 collection分页的正确姿势:先分页主表,再组装子列表
如果你已经在 resultMap 里用了 collection,又想对这个带 collection 的查询直接分页,麻烦就来了。分页插件是在 SQL 执行前拦截并改写 SQL 的,分页计算的"行"对应的是 SQL 返回的物理行,而不是 MyBatis 组装后的对象个数。一个商品带 5 个供应商,SQL 返回 5 行,分页插件认为这是 5 条记录,但业务上它只是一个商品。这会导致每页条数虚高、最后一页数据错乱。
我的习惯是:带 collection 的查询永远不直接分页。先写一个只查主表的分页查询,拿到 List<Product> 和 total,再用主键集合查出关联对象,在 Service 层把关联数据按主键分组后 set 进去。代码多写一点,但结果完全可控,也避免了 MyBatis 对象组装和分页插件之间的语义冲突。
MySQL 8 里要给多表查询增加行号也很简单,直接放到 SELECT 列表:
sql复制SELECT
ROW_NUMBER() OVER (ORDER BY p.id DESC) AS row_no,
p.product_name
FROM product p
LEFT JOIN brand b ON p.brand_id = b.id
老版本 MySQL 可以用 @rownum := @rownum + 1 模拟,但要注意这个变量在分页场景下必须在子查询里先赋好值,否则行号会乱。
5. 统计类多表查询:用DTO接住聚合结果
5.1 为什么聚合结果不要试图塞进原来的实体类
多表关系映射不只包括"查列表、查详情",统计报表也很常见。最典型的例子:统计每个品牌下的商品数量。有人喜欢在 Product 实体里加一个 brandName 字段、一个 productCount 字段,把统计结果直接塞进实体类里,图省事不用新建类。
这个做法短期方便,长期很坑。实体类的职责是描述一张表的记录结构,一旦塞进聚合字段,后续所有查询这个实体的方法都不得不面对这些"偶尔才有值、平时为 null"的字段。序列化到前端时,多出来的字段还可能污染接口文档。更麻烦的是,如果统计口径变化,你得在实体类里不断增加新字段,最后实体类变成一个大杂烩。
正确的做法是新建一个 DTO 类,只包含本次统计查询需要的字段:
java复制public class BrandProductCountDTO {
private Long brandId;
private String brandName;
private Integer productCount;
// getter、setter
}
5.2 一个实际的分组统计查询示例
统计每个品牌下的有效商品数量,并按数量倒序:
sql复制SELECT
b.id AS brand_id,
b.brand_name,
COUNT(p.id) AS product_count
FROM brand b
LEFT JOIN product p
ON b.id = p.brand_id
AND p.deleted = 0
GROUP BY b.id, b.brand_name
ORDER BY product_count DESC
注意这里用的是 COUNT(p.id) 而不是 COUNT(*)。因为 LEFT JOIN 后,没有商品的品牌会返回一行 p 字段全为 null 的记录,COUNT(p.id) 会忽略 null,统计结果为 0;COUNT(*) 会把这一行也算进去,结果变成 1。这种细节直接决定报表数据对不对。
MyBatis 的 Mapper 接口方法:
java复制List<BrandProductCountDTO> countProductByBrand();
SQL 的 resultType 直接写成 DTO 类:
xml复制<select id="countProductByBrand" resultType="cn.demo.dto.BrandProductCountDTO">
SELECT
b.id AS brand_id,
b.brand_name,
COUNT(p.id) AS product_count
FROM brand b
LEFT JOIN product p
ON b.id = p.brand_id
AND p.deleted = 0
GROUP BY b.id, b.brand_name
ORDER BY product_count DESC
</select>
只要开启了 map-underscore-to-camel-case,brand_id 会自动映射到 brandId,product_count 会自动映射到 productCount。聚合统计用 resultType 加 DTO 就够了,一般不需要再写 resultMap,除非你要把统计结果再一次嵌套成对象结构。
此外,聚合统计里 SUM(IFNULL(x, 0)) 这种写法也常见。多表 LEFT JOIN 后,右表没有匹配记录时 SUM 结果是 null 而不是 0,直接返回给前端会出现很奇怪的 null,所以在 Service 层或 SQL 里要把 null 兜底成 0。这类"统计查询的 null 处理"往往要跟产品确认清楚口径,是"没有数据算 0"还是"没有数据不统计",不同业务不一样。
