MyBatis ResultMap 吞行问题

你写了一对多关联查询,数据库中明明有 10 行数据,MyBatis 返回的列表却只有 3 条。剩下的 7 行去哪了?这就是经典的 ResultMap “吞行”问题。

现象:数据去哪了?

假设你有一张订单表 t_order 和一张订单明细表 t_order_item,一个订单可以包含多个商品明细:

1
2
3
4
5
6
7
8
9
10
11
12
-- 订单
CREATE TABLE t_order (
id BIGINT PRIMARY KEY,
order_no VARCHAR(32)
);

-- 订单明细
CREATE TABLE t_order_item (
id BIGINT PRIMARY KEY,
order_id BIGINT,
product_name VARCHAR(64)
);

你写了一个 SQL 用 JOIN 关联查询:

1
2
3
4
5
6
7
8
9
<select id="getOrders" resultMap="OrderResultMap">
SELECT
o.id AS order_id,
o.order_no AS order_no,
oi.id AS item_id,
oi.product_name AS product_name
FROM t_order o
LEFT JOIN t_order_item oi ON o.id = oi.order_id
</select>

对应的 ResultMap:

1
2
3
4
5
6
7
8
<resultMap id="OrderResultMap" type="com.example.OrderVO">
<id column="order_id" property="id"/>
<result column="order_no" property="orderNo"/>
<collection property="items" ofType="com.example.OrderItemVO">
<id column="item_id" property="id"/>
<result column="product_name" property="productName"/>
</collection>
</resultMap>

数据库里有 3 个订单,共 10 条订单明细,JOIN 查出来是 10 行。但你调试时发现,返回的 List<OrderVO> 只有 3 条,而且每条订单的明细数量好像也不对。那 10 行数据”消失”了。

这就是 ResultMap 吞行。

根本原因:<id> 干了什么

要理解吞行,必须先理解 <resultMap><id> 的真实作用。

很多开发者以为 <id> 只是告诉 MyBatis”这一列是主键”。这是错的。 <id> 的核心职责是:告诉 MyBatis 如何判断两行结果是否属于同一个对象

MyBatis 在映射结果集时,内部维护了一个缓存机制。它逐行读取 JDBC ResultSet,对每一行:

  1. 提取所有 <id> 列的值,组成一个”身份标识”;
  2. 用这个标识去缓存中查找——看看之前是否已经创建过这个对象;
  3. 如果没找到 → 创建新对象,放入缓存;
  4. 如果找到了 → 不再创建新对象,而是把当前行数据合并到已有对象上。

一旦两行的 <id> 值相同,它们就会被当作同一个对象。 这就是吞行的根源。

模拟:吞行是如何发生的

我们把上面的 SQL 跑出来,假设结果是这样的(省略无关列):

order_id order_no item_id product_name
1 A001 101 手机
1 A001 102 耳机
1 A001 103 数据线
2 A002 104 键盘
2 A002 105 鼠标
3 A003 106 显示器

MyBatis 逐行处理:

  • 第 1 行order_id=1,缓存中没有 → 创建 OrderVO(id=1),添加第一个明细 item。
  • 第 2 行order_id=1,缓存中已有 OrderVO(id=1)不创建新对象,往已有对象追加一条明细 item。
  • 第 3 行:同上,追加第三条明细。
  • 第 4 行order_id=2,缓存中没有 → 创建新的 OrderVO(id=2),添加明细。

最终 6 行数据被归并为 3 个 OrderVO 对象,每个对象的 items 列表分别有 3 条、2 条、1 条明细。

在这个例子里,”吞行”其实是我们期望的行为——JOIN 查出来的行数本来就大于对象数。但有一种情况你会真的遭遇”异常吞行”。

真正出问题的场景:<id> 配置错误

如果 <id> 的列值不能唯一标识一个对象,就会出现异常吞行。

场景一:<id> 列值重复但代表不同对象

1
2
3
4
5
<!-- 错误示例 -->
<resultMap id="UserResultMap" type="com.example.User">
<id column="name" property="name"/> <!-- 名字可能重复 -->
<result column="age" property="age"/>
</resultMap>

如果表中有两个 name='张三' 的用户,MyBatis 会把第二行的数据合并到第一个”张三”上,第二个”张三”就丢了。

修复<id> 必须选择具有唯一性的列(通常是数据库主键)。

1
2
3
4
5
<resultMap id="UserResultMap" type="com.example.User">
<id column="id" property="id"/> <!-- 主键,唯一 -->
<result column="name" property="name"/>
<result column="age" property="age"/>
</resultMap>

场景二:忘记写 <id>

1
2
3
4
5
<resultMap id="UserResultMap" type="com.example.User">
<!-- 没有 <id> -->
<result column="id" property="id"/>
<result column="name" property="name"/>
</resultMap>

没有 <id> 时,MyBatis 会把所有 <result> 列的值组合起来作为默认的身份标识。换句话说,只有当两行完全相同时才会合并。这种行为在大多数简单场景下也能工作,但不是好习惯,而且可能导致性能问题或意外的合并行为。

修复:始终为 ResultMap 定义至少一个 <id>

场景三:<collection> / <association> 里的 <id> 配错了

嵌套映射中,内层 <id> 的配置同样重要。

1
2
3
4
5
6
7
<resultMap id="OrderResultMap" type="OrderVO">
<id column="order_id" property="id"/>
<collection property="items" ofType="OrderItemVO">
<!-- 如果这里没写 <id> 或写错了,子对象也会被"吞" -->
<result column="product_name" property="productName"/>
</collection>
</resultMap>

如果明细表 t_order_item 中某两行的 product_name 相同(完全有可能),上面这种写法会导致同名商品明细被合并为一条。应该在 <collection> 内部也定义 <id>

1
2
3
4
<collection property="items" ofType="OrderItemVO">
<id column="item_id" property="id"/> <!-- 明细表的主键 -->
<result column="product_name" property="productName"/>
</collection>

实战排查:怎么确认是不是吞行

如果你怀疑遇到了吞行问题,按以下步骤确认:

1. 先把 SQL 拿到数据库客户端直接跑。
数一数返回的总行数。如果数据库返回 20 行,MyBatis 映射后只剩 5 个对象,那就很可疑。

2. 检查 <id> 列的取值。
在数据库里跑一个聚合查询:

1
2
3
4
SELECT <id列>, COUNT(*) 
FROM (你的SQL) t
GROUP BY <id列>
HAVING COUNT(*) > 1;

如果查出有重复的 <id> 值,而且这些重复不应该被合并,那就是问题所在。

3. 开启 MyBatis DEBUG 日志。
application.yml 或日志配置中加入:

1
2
3
logging:
level:
com.yourpackage.mapper: DEBUG

MyBatis 会在 DEBUG 级别打印出映射过程中的细节,包括哪些行被合并了。

最佳实践

场景 建议
每个 <resultMap> 必须定义 <id>,用它标识”什么算同一个对象”
<id> 的列 选用数据库主键。不要用 name、status 等可能重复的列
嵌套映射(<collection>/<association> 内层也定义 <id>
无主键的查询结果 如果返回的是聚合统计结果,确实没有唯一列,那 <id> 可以省略,但要清楚这意味着所有列都相同才会合并
性能敏感场景 <id> 配置合理可以减少对象创建,提升性能,但前提是语义正确

总结

MyBatis ResultMap 吞行不是什么神秘 bug,它是 <id> 机制的正常行为——你用 <id> 告诉 MyBatis “什么算同一个对象”,MyBatis 忠实地执行了这个逻辑。

问题的本质只有一句话:**<id> 列的值不唯一,但你认为它们是不同的对象。** 解决方式也很简单:<id> 指向真正能唯一标识一行数据的列。

记住这个心法:ResultMap 的 <id> 定义的不是”主键”,而是”身份”——你凭什么说这两行数据是同一个人?