前言
从一个Mybatis列表查询的Bug入手,看一个或许被很多人忽略的Mybatis使用中的大坑。
中间是排查思路。如果不想看排查过程,可以跳过【排查】这一节。
Bug描述
JavaWeb项目中,使用Mybatis查询pg数据库。
在查询一个列表数据的时候,发现该列表的倒数第二页,缺了一条数据(pageSize=100,结果那一页只有99条)
把控制台打印的sql和参数日志拿出来,去数据库查,发现是可以查出100条的。甚至控制台上显示的sql日志也显示查出了100条。
看了一下sql,发现用了row_number()分页查询,类似于:
<select id="list_data", resultMap = "test_resultmap">
select * from (
select row_number() row_num, t.*
from (select a.*, b.* from A a left join B b on a.id=b.aid order by a.sort_field desc) as t
) as t1
where row_num >= #{start} and row_number <= #{end}
</select>
通过对比Mybatis查询的数据
和数据库执行相同sql得到的数据
。发现表B中有部分数据出现了重复。
实际业务中,B表不应该有数据重复,而是应该和A表一一对应才对。
通过删除B表中的脏数据,再次验证,这个问题就算解决了。
站在业务角度上讲,这个问题就算解决了。
可站在技术角度讲,只是这个业务场景中B表不允许重复。毕竟两表之间一对多的关系是很常见的设计。所以这个现象很不正常,需要排查一下根本原因。
排查
分页Total查询条件和List数据查询条件不一致?
排查后发现Total的统计没问题。【排除】
row_number分页语句的问题?
参考文章:使用 ROW_NUMBER () 排序后分页查询的坑
发现换成Limit查询,会出现同样的问题。
这个答案似乎很靠谱,或许逻辑是这样的:
因为B表重复,导致整个查询结果重复,排序字段又是A表的字段,所以那些重复的数据的排序字段值也是重复的------>排序不稳定 ------>同一个数据出现在第2也,也出现在第3页。当出现在第3页的时候,因为Mybatis的某种机制,这条奇怪的数据就被发现了,然后就被Mybatis过滤掉了。
试试网上给出的方案,给排序条件增加了一个排序字段。倒数第二页真的变成100条了。
问题解决了?
答 : 属于瞎猫碰见死耗子。其实根本就不是同一个问题。而且这个所谓的"解决方案",还引起了排序混乱。
或者说是"正是因为排序被打乱了,所以之前那个错误的页跑到其他地方去了,只是倒数第二页变正常了而已"。然后就被误解为正确解释和方案了。
[该方案虽然不是针对这个问题,但也是列表查询的常见问题之一,后面会说]
多字段排序
前面说:用了一个错误的方案居然"解决"了问题,实际上只是因为排序打乱了而已。为什么排序会被打乱? 是因为理解错了两个字段排序的写法:
order by a, b desc
等效于 order by a asc, b desc
而非等效于 order by a desc, b desc
也就是每个字段都要紧跟自己的排序方式,如果不写,则默认为asc。
看Mybatis源码
这部分放到另一篇博客里。
真正的解决方案
问题的真正根源其实在Mapper.xml的 ResultMapper中。也就是前面那个select语句中的resultMap = "test_resultmap"
,类似如下所示(业务代码中实际很复杂,这里就抽象出主要特征)
<resultMap type="com.test.Abc" id="test_resultmap">
<id property="aId" column="id"/>
<result property="aName" column="aname"/>
<result property="aOrderField" column="aorderfield"/>
<result property="bid" column="bid"/>
<result property="bName" column="bname"/>
<result property="bOrderField" column="borderfield"/>
<collection property="otherInfo" javaType="ArrayList" ofType="com.test.C">
<result property="cid" column="id"/>
<result property="cName" column="cname"/>
</collection>
</resultMap>
这里的问题点就在于,多了一个嵌套类otherInfo
,可select语句又没有真正的用上。
解决方案就是把这个多余的嵌套类配置删掉(实际我是另外重写了一个ResultMap,因为原来那个有其他select在引用),问题就解决了。
解释
Mybatis执行完sql拿到数据后,需要对数据进行封装。在上面这个例子中,就是把数据封装进
com.test.Abc
对象中。
封装数据的时候,分两种情况:简单ResultMap
和嵌套ResultMap
- 简单ResultMap:数据库查出几条就封装几个
- 嵌套ResultMap:对查出的数据进行去重,然后封装返回
上面的示例Bug,一开始就是把ResultMap写成了嵌套类型,但实际上自己的返回值里根本没有嵌套类型的数据。
可Mybatis没那么智能,它发现ResultMap是嵌套类,就不管三七二十一的对数据进行了去重操作。可写这个方法人并没有这种去重意图。Bug因此就产生了。
(大概是写这个查询方法的人看到前面这个ResultMap里的字段包含了自己业务所需要的所有字段,所以就想着这个ResultMap可以通用,就直接拿来引用在自己的select方法里了。
这是需要重点注意的地方!!)
另一个坑
现在,假设查询sql返回的有嵌套类型,并且确实希望它嵌套在Abc里
<select id="list_data", resultMap = "test_resultmap">
select * from (
select row_number() row_num, t.*
from (
select a.*, b.*, c.*
from A a
left join B b on a.id=b.aid
left join C c on a.id=c.aid
order by a.sort_field desc) as t
) as t1
where row_num >= #{startIndex} and row_number <= #{endIndex}
</select>
com.test.Abc如下
public class Abc {
private int aId;
private String aName;
private int bId;
private String bName;
private C otherInfo;//嵌套类型
...
}
此时就符合业务逻辑了吗?
答 :并没有。查询结果依然是根据A表去重,而我们实际希望的是根据A和B共同判断唯一条件。
问题出在<id property="aId" column="id"/>
当使用嵌套类型ResultMap时,Mybatis就会对结果集进行去重。
去重的标准:
- 有
id
元素标签,就以此标签指定的字段进行去重 - 没有
id
元素标签,就以整个ResultMap中所有字段拼起来作为去重标准(因此Mybatis官网和很多博客中,都会说这么一句话:id和result的唯一区别是id将结果标记为标识符属性,以便在比较对象实例时使用,这有助于提升性能)
其实这个id标签的作用很重要,是真的可以提升性能(通过一个字段去重和通过一堆字段去重,效率是完全不同的)。
如果业务中真的需要Mybatis根据多张表的数据进行去重操作。尽量给这个实体类增加一个"虚拟id"(见这篇博客)
所查即所得?
mybatis在很多人的潜意识里,就是所查即所得 (相比hibernate的高度封装,我们要的就是mybatis轻量级的自由)。
所以在这个问题的排查中,当我发现所有的线索都指向了:Mybatis对数据进行了去重操作 。
我就陷入了深深的自我怀疑(写了这么多年crud,竟然不知道Mybatis还有这种操作,Mybatis怎么会做这么"自作主张"的事呢)
实际上,Mybatis还真有一个合情合理的去重场景:
当我们要查询"一对多"的场景时,可以使用嵌套类型。可以直接获得一个类似于
// A和B是一对多关系 class A { private int aId; private String aName; private List<B> bList;//嵌套B集合 }
这样的封装好的结果集。这样的结果是怎么来的呢。
简单想想就会明白:从数据库查出来的数据,肯定是类似于这样的结果:
aId1, aName1, bId1, bName1
aId1, aName1, bId2, bName2
aId2, aName2, bId3, bName3
aId2, aName2, bId4, bName4
要把这样的结果封装到A对象集合中,必然要对返回值中A表的数据进行去重。然后再把B表的字段值分别封装到A对象中的bList中。
之所以一时联想不到"一对多"的场景。除了"去重"和"一对多"看起来确实相关性不强,更重要的我觉得还是这种场景应用的比较少的缘故。
工作过程中,虽然sql越写越复杂,可我们总是倾向于用复杂的sql,简单的结果集。 在潜意识里总觉得,用复杂的数据结构的 都是初学者的demo。因为结果类型越复杂,通用性越差,引起不必要问题的可能性就越大(事实上也确实如此)。可尽量不用,却不能不懂其原理,否则遇到像这样的bug就可能不知所措。
引申
根据上面所说的,我们知道是Mybatis处理"一对多"时,自动对结果去重导致的问题。
那么再问一个问题:如果Mapper.xml里ResultMap用了嵌套类型,但结果类型却是这么写的
resultType = "java.util.Map"
Mybatis还会对结果进行去重吗?
答:不会。
因为Mybatis需要实体类和Mapper.xml里ResultMap同时是嵌套类型,才会进行去重。
Mybatis分页查询数据常见问题
问题描述 | 原因 | 解决方案 |
---|---|---|
Mybatis查询列表数据量和sql执行的数据量不一致【本文的主题】 | 执行了嵌套类的ResultMap【Mybatis结果集处理机制问题】 | 把乱套用的嵌套类ResultMap处理一下 |
Mybatis查询列表数据的嵌套类型集合中只有一条数据(比如,订单表和商品表一对多,结果查出来商品集合里永远只有一条数据) | 主表的id名称和嵌套类的id名称重复了【Mybatis结果集处理机制问题】 | 用别名区分一下 |
同一条数据出现在多个分页中 | 列表查询排序字段重复了【sql问题,和Mybatis无关】 | 增加一个排序字段 |
使用PageHelper引起的分页问题 | 一般都是Mybatis框架直接简单粗暴套分页语句引起的。比如直接在你的sql后面增加limit语句(这一般很容易通过sql日志发现问题)【Mybatis处理sql语句问题】 | 自己写分页,不要用PageHelper(有些博客说使用Mybatis的懒加载,也可以。但不建议,复杂的sql还是自己控制比较好) |
参考
使用 ROW_NUMBER () 排序后分页查询的坑
resultmap中点id resultmap的id属性
Mybatis官网文档
MyBatis查询List返回数据只有少部分,因为结果去重了
Mybatis中用法(主要用于一对多去重)