如何解决Hibernate的警告 "firstResult/maxResults指定为集合获取"

672 阅读7分钟

提高持久层性能的最常见的建议之一是使用JOIN FETCH子句或EntityGraphs来获取加载实体时所需的关联。我完全同意这些建议,我们在持久化中心的Hibernate性能调整课程中详细讨论了这个问题。但是如果你遵循这个建议,调用setFirstResultsetMaxResult 方法来限制结果集的大小,你会在日志文件中看到以下警告:

HHH000104: firstResult/maxResults是用collection fetch指定的;在内存中应用!。

如果你在使用JOIN FETCH子句或EntityGraph的查询上调用setFirstResultsetMaxResults方法,Hibernate 5会显示该警告。Hibernate 6改进了对EntityGraphs的处理,只有当你的查询包含JOIN FETCH子句时才会显示该警告。

内容

为什么Hibernate会显示HH000104警告?

当你看一下Hibernate在使用JOIN FETCH 子句或EntityGraph时必须生成的SQL语句,这个警告的原因就很明显了。这两种方法都告诉Hibernate要初始化2个实体类之间的托管关联。要做到这一点,Hibernate需要连接相关的表并选择实体类映射的所有列。这将合并两个表中的记录并增加结果集的大小。如果你想通过调用setFirstResultsetMaxResults方法来限制其大小,这就会产生问题。

让我们来看看一个例子:

我在ChessTournamentChessPlayer 实体类之间建立了一个多对多的关联。处理这个关联的最佳做法是使用默认的*FetchType.LAZYJOIN FETCH子句或EntityGraph*来初始化它,如果需要的话。

然后Hibernate使用1条SQL语句获取所有需要的信息。但如果你限制了查询结果的大小,它就会触发之前显示的警告。你可以在下面的代码片断中看到一个例子:

TypedQuery<ChessTournament> q = em.createQuery("""
                                                  SELECT t 
                                                  FROM ChessTournament t 
                                                      LEFT JOIN FETCH t.players
                                                  WHERE t.name LIKE :name""", 
                                               ChessTournament.class);
q.setParameter("name", "%Chess%");
q.setFirstResult(0);
q.setMaxResults(5);
List<ChessTournament> tournaments = q.getResultList();

正如预期的那样,Hibernate在日志文件中写下了HH000104警告。它没有添加LIMIT或OFFSET子句来限制结果集的大小,尽管我把firstResult 设为0,maxResult 设为5:

15:56:57,623 WARN  [org.hibernate.hql.internal.ast.QueryTranslatorImpl] - HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
15:56:57,626 DEBUG [org.hibernate.SQL] - 
    select
        chesstourn0_.id as id1_1_0_,
        chessplaye2_.id as id1_0_1_,
        chesstourn0_.endDate as enddate2_1_0_,
        chesstourn0_.name as name3_1_0_,
        chesstourn0_.startDate as startdat4_1_0_,
        chesstourn0_.version as version5_1_0_,
        chessplaye2_.birthDate as birthdat2_0_1_,
        chessplaye2_.firstName as firstnam3_0_1_,
        chessplaye2_.lastName as lastname4_0_1_,
        chessplaye2_.version as version5_0_1_,
        players1_.ChessTournament_id as chesstou1_2_0__,
        players1_.players_id as players_2_2_0__ 
    from
        ChessTournament chesstourn0_ 
    left outer join
        ChessTournament_ChessPlayer players1_ 
            on chesstourn0_.id=players1_.ChessTournament_id 
    left outer join
        ChessPlayer chessplaye2_ 
            on players1_.players_id=chessplaye2_.id 
    where
        chesstourn0_.name like ?

当你在SQL客户端中执行相同的语句时,其原因就显现出来了。通过加入托管关联并选择由ChessTournamentChessPlayer实体类映射的所有列,查询的结果集是ChessTournament 表中的记录和ChessPlayer 表中相关记录的乘积:

结果集中的每条记录都是一个锦标赛和其中一个棋手的唯一组合。这是关系型数据库处理这种查询的预期方式。但在JOIN FETCH子句或EntityGraph的特殊情况下,它产生了一个问题。

通常情况下,Hibernate使用firstResultmaxResult 值来应用SQL语句中的分页。这些告诉数据库只返回结果集的一部分。在前面的例子中,我在调用setFirstResult 方法时使用了0,在调用setMaxResults 方法时使用了5。如果Hibernate对生成的SQL语句应用这些参数的标准处理,数据库将只返回结果集的前5行。正如你在下面的图片中看到的,这些记录包含了2021年塔塔钢铁国际象棋锦标赛的4名选手和2022年塔塔钢铁国际象棋锦标赛的1名选手:

但是这并不是我们的JPQL查询的目的。提供的firstResultmaxResult 值应该是返回前5个ChessTournament 实体和所有相关的ChessPlayer实体。他们应该为返回的ChessTournament实体对象定义分页,而不是为SQL结果集中的产品定义分页。

这就是为什么Hibernate将警告写入日志文件并在内存中应用分页。它执行SQL语句时没有任何分页。然后数据库会返回所有的ChessTournament实体和它们相关的ChessPlayers。而Hibernate在解析结果集时限制了返回的*List*的大小。

尽管这种方法提供了正确的结果,但它使你面临着严重的性能问题的风险。根据你的数据库的大小,查询可能会选择几千条记录,并拖慢你的应用程序。

如何避免HHH000104警告

避免Hibernate警告和潜在性能问题的最好方法是执行2个查询。第一个查询选择你要检索的所有ChessTournament 实体的主键。这个查询并不获取关联,你可以使用setFirstResultsetMaxResult 方法来限制结果集的大小。第2个是获取这些实体和它们相关的ChessPlayers

TypedQuery<Long> idQuery = em.createQuery("""
											SELECT t.id 
											FROM ChessTournament t
											WHERE t.name LIKE :name""", 
										  Long.class);
idQuery.setParameter("name", "%Chess%");
idQuery.setFirstResult(0);
idQuery.setMaxResults(5);
List<Long> tournamentIds = idQuery.getResultList();

TypedQuery<ChessTournament> tournamentQuery = em.createQuery("""
																SELECT t 
																FROM ChessTournament t 
																	LEFT JOIN FETCH t.players
																WHERE t.id IN :ids""", 
															 ChessTournament.class);
tournamentQuery.setParameter("ids", tournamentIds);
List<ChessTournament> tournaments = tournamentQuery.getResultList();
tournaments.forEach(t -> log.info(t));

前面的代码片段使用了Hibernate 6。如果你使用的是Hibernate 5,你应该在第2个查询中添加DISTINCT关键字,并将hibernate.query.passDistinctThrough的提示设置为false。正如我在之前关于Hibernate性能调整的文章中所解释的,这可以防止Hibernate为每个选手返回一个ChessTournament对象的引用:

TypedQuery<Long> idQuery = em.createQuery("""
												SELECT t.id 
												FROM ChessTournament t
												WHERE t.name LIKE :name""", 
											   Long.class);
idQuery.setParameter("name", "%Chess%");
idQuery.setFirstResult(0);
idQuery.setMaxResults(5);
List<Long> tournamentIds = idQuery.getResultList();

TypedQuery<ChessTournament> tournamentQuery = em.createQuery("""
												SELECT DISTINCT t 
												FROM ChessTournament t 
													LEFT JOIN FETCH t.players
												WHERE t.id IN :ids""", 
											   ChessTournament.class);
tournamentQuery.setParameter("ids", tournamentIds);
tournamentQuery.setHint(QueryHints.PASS_DISTINCT_THROUGH, false);
List<ChessTournament> tournaments = tournamentQuery.getResultList();

这种方法可能看起来更复杂,要执行2条语句而不是1条,但它将查询结果集的分页与选手关联的初始化分开。这使得Hibernate能够将分页添加到第一条查询语句中,并防止它在内存中获取整个结果集并应用分页。这就解决了警告问题,如果你正在处理一个巨大的数据库,就可以提高你的应用程序的性能:

07:30:04,557 DEBUG [org.hibernate.SQL] - 
    select
        c1_0.id 
    from
        ChessTournament c1_0 
    where
        c1_0.name like ? escape '' offset ? rows fetch first ? rows only
07:30:04,620 DEBUG [org.hibernate.SQL] - 
    select
        c1_0.id,
        c1_0.endDate,
        c1_0.name,
        p1_0.ChessTournament_id,
        p1_1.id,
        p1_1.birthDate,
        p1_1.firstName,
        p1_1.lastName,
        p1_1.version,
        c1_0.startDate,
        c1_0.version 
    from
        ChessTournament c1_0 
    left join
        (ChessTournament_ChessPlayer p1_0 
    join
        ChessPlayer p1_1 
            on p1_1.id=p1_0.players_id) 
                on c1_0.id=p1_0.ChessTournament_id 
        where
            c1_0.id in(?,?,?)
07:30:04,666 INFO  [com.thorben.janssen.sample.TestSample] - ChessTournament [id=1, name=Tata Steel Chess Tournament 2021, startDate=2021-01-14, endDate=2021-01-30, version=0]
07:30:04,666 INFO  [com.thorben.janssen.sample.TestSample] - ChessTournament [id=2, name=Tata Steel Chess Tournament 2022, startDate=2022-01-14, endDate=2022-01-30, version=0]
07:30:04,666 INFO  [com.thorben.janssen.sample.TestSample] - ChessTournament [id=3, name=2022 Superbet Chess Classic Romania, startDate=2022-05-03, endDate=2022-05-15, version=0]

结论

你应该使用JOIN FETCH条款或EntityGraphs来初始化你在业务代码中使用的关联。这可以避免n+1的选择问题,提高你的应用程序的性能。

但是如果你想通过调用setFirstResultsetMaxResult方法来限制结果集的大小,对关联实体的获取就会产生问题。然后,结果集包含了连接表中所有匹配记录的组合。如果Hibernate限制该结果集的大小,它将限制组合的数量而不是所选实体的数量。相反,它获取了整个结果集,并在内存中应用分页。根据结果集的大小,这可能导致严重的性能问题。

你可以通过执行两个查询语句来避免这种情况。第一条语句在获取你想要检索的所有记录的主键时应用分页。在这篇文章的例子中,这些是符合WHERE子句的所有ChessTournament 实体的id值。第二个查询使用主键值的列表来获取实体对象,并初始化所需的关联。