MyBatis ResultMap 吞行问题
MyBatis ResultMap 吞行问题
你写了一对多关联查询,数据库中明明有 10 行数据,MyBatis 返回的列表却只有 3 条。剩下的 7 行去哪了?这就是经典的 ResultMap “吞行”问题。
现象:数据去哪了?
假设你有一张订单表 t_order 和一张订单明细表 t_order_item,一个订单可以包含多个商品明细:
1 | -- 订单 |
你写了一个 SQL 用 JOIN 关联查询:
1 | <select id="getOrders" resultMap="OrderResultMap"> |
对应的 ResultMap:
1 | <resultMap id="OrderResultMap" type="com.example.OrderVO"> |
数据库里有 3 个订单,共 10 条订单明细,JOIN 查出来是 10 行。但你调试时发现,返回的 List<OrderVO> 只有 3 条,而且每条订单的明细数量好像也不对。那 10 行数据”消失”了。
这就是 ResultMap 吞行。
根本原因:<id> 干了什么
要理解吞行,必须先理解 <resultMap> 中 <id> 的真实作用。
很多开发者以为 <id> 只是告诉 MyBatis”这一列是主键”。这是错的。 <id> 的核心职责是:告诉 MyBatis 如何判断两行结果是否属于同一个对象。
MyBatis 在映射结果集时,内部维护了一个缓存机制。它逐行读取 JDBC ResultSet,对每一行:
- 提取所有
<id>列的值,组成一个”身份标识”; - 用这个标识去缓存中查找——看看之前是否已经创建过这个对象;
- 如果没找到 → 创建新对象,放入缓存;
- 如果找到了 → 不再创建新对象,而是把当前行数据合并到已有对象上。
一旦两行的 <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 | <!-- 错误示例 --> |
如果表中有两个 name='张三' 的用户,MyBatis 会把第二行的数据合并到第一个”张三”上,第二个”张三”就丢了。
修复:<id> 必须选择具有唯一性的列(通常是数据库主键)。
1 | <resultMap id="UserResultMap" type="com.example.User"> |
场景二:忘记写 <id>
1 | <resultMap id="UserResultMap" type="com.example.User"> |
没有 <id> 时,MyBatis 会把所有 <result> 列的值组合起来作为默认的身份标识。换句话说,只有当两行完全相同时才会合并。这种行为在大多数简单场景下也能工作,但不是好习惯,而且可能导致性能问题或意外的合并行为。
修复:始终为 ResultMap 定义至少一个 <id>。
场景三:<collection> / <association> 里的 <id> 配错了
嵌套映射中,内层 <id> 的配置同样重要。
1 | <resultMap id="OrderResultMap" type="OrderVO"> |
如果明细表 t_order_item 中某两行的 product_name 相同(完全有可能),上面这种写法会导致同名商品明细被合并为一条。应该在 <collection> 内部也定义 <id>:
1 | <collection property="items" ofType="OrderItemVO"> |
实战排查:怎么确认是不是吞行
如果你怀疑遇到了吞行问题,按以下步骤确认:
1. 先把 SQL 拿到数据库客户端直接跑。
数一数返回的总行数。如果数据库返回 20 行,MyBatis 映射后只剩 5 个对象,那就很可疑。
2. 检查 <id> 列的取值。
在数据库里跑一个聚合查询:
1 | SELECT <id列>, COUNT(*) |
如果查出有重复的 <id> 值,而且这些重复不应该被合并,那就是问题所在。
3. 开启 MyBatis DEBUG 日志。
在 application.yml 或日志配置中加入:
1 | logging: |
MyBatis 会在 DEBUG 级别打印出映射过程中的细节,包括哪些行被合并了。
最佳实践
| 场景 | 建议 |
|---|---|
每个 <resultMap> |
必须定义 <id>,用它标识”什么算同一个对象” |
<id> 的列 |
选用数据库主键。不要用 name、status 等可能重复的列 |
嵌套映射(<collection>/<association>) |
内层也定义 <id> |
| 无主键的查询结果 | 如果返回的是聚合统计结果,确实没有唯一列,那 <id> 可以省略,但要清楚这意味着所有列都相同才会合并 |
| 性能敏感场景 | <id> 配置合理可以减少对象创建,提升性能,但前提是语义正确 |
总结
MyBatis ResultMap 吞行不是什么神秘 bug,它是 <id> 机制的正常行为——你用 <id> 告诉 MyBatis “什么算同一个对象”,MyBatis 忠实地执行了这个逻辑。
问题的本质只有一句话:**<id> 列的值不唯一,但你认为它们是不同的对象。** 解决方式也很简单:让 <id> 指向真正能唯一标识一行数据的列。
记住这个心法:ResultMap 的 <id> 定义的不是”主键”,而是”身份”——你凭什么说这两行数据是同一个人?
