扼杀性能的 10 个常见 Hibernate 错误

分类: 工作日志 0人评论 Konrad-Garus 8月前发布

你有没有想过如果你能解决Hibernate问题,那么你的应用程序可以更快?

那么请阅读这篇文章!

我在很多应用程序中修复过性能问题,其中大部分都是由同样的错误引起的。修复之后,性能变得更溜,而且其中的大部分问题都很简单。所以,如果你想改进应用程序,那么可能也是小菜一碟。

这里列出了导致Hibernate性能问题的10个最常见的错误,以及如何修复它们。

JPA-Hibernate-10-Common-Mistakes-That-Cripple-Your-Performance

错误1:使用Eager Fetching

FetchType.EAGER的启示已经讨论了好几年了,而且有很多文章对它进行了详细的解释。我自己也写了一篇。但不幸的是,它仍然是性能问题最常见的两个原因之一。

FetchType定义了Hibernate何时初始化关联。你可以使用@OneToMany,@ManyToOne,@ManyToMany和@OneToOneannotation注释的fetch属性进行指定。

@Entity
public class Author{
    @ManyToMany(mappedBy="authors", fetch=FetchType.LAZY)
    private List<Book> books = new ArrayList<Book>();
    ...
}

当Hibernate加载一个实体的时候,它也会即时加载获取的关联。例如,当Hibernate加载Author实体时,它也提取相关的Book实体。这需要对每个Author进行额外的查询,因此经常需要几十甚至数百个额外的查询。

这种方法是非常低效的,因为Hibernate不管你是不是要使用关联都会这样做。最好改用FetchType.LAZY代替。它会延迟关系的初始化,直到在业务代码中使用它。这可以避免大量不必要的查询,并提高应用程序的性能。

幸运的是,JPA规范将FetchType.LAZY定义为所有对多关联的默认值。所以,你只需要确保你不改变这个默认值即可。但不幸的是,一对一关系并非如此。

错误2:忽略一对一关联的默认FetchType

接下来,为了防止立即抓取(eager fetching),你需要做的是对所有的一对一关联更改默认的FetchType。不幸的是,这些关系在默认情况下会被即时抓取。在一些用例中,那并非一个大问题,因为你只是加载了一个额外的数据库记录。但是,如果你加载多个实体,并且每个实体都指定了几个这样的关联,那么很快就会积少成多,水滴石穿。

所以,最好确保所有的一对一关联设置FetchType为LAZY。

@Entity
public class Review {
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "fk_book")
    private Book book;
    ...
}

错误3:不要初始化所需的关联

当你对所有关联使用FetchType.LAZY以避免错误1和错误2时,你会在代码中发现若干n+1选择问题。当Hibernate执行1个查询来选择n个实体,然后必须为每个实体执行一个额外的查询来初始化一个延迟的获取关联时,就会发生这个问题。

Hibernate透明地获取惰性关系,因此在代码中很难找到这种问题。你只要调用关联的getter方法,我想我们大家都不希望Hibernate执行任何额外的查询吧。

List<Author> authors = em.createQuery("SELECT a FROM Author a", Author.class).getResultList();
for (Author a : authors) {
    log.info(a.getFirstName() + " " + a.getLastName() + " wrote "
            + a.getBooks().size() + " books.");
}

如果你使用开发配置激活Hibernate的统计组件并监视已执行的SQL语句的数量,n+1选择问题就会更容易被发现。

15:06:48,362 INFO [org.hibernate.engine.internal.StatisticalLoggingSessionEventListener] - Session Metrics {
  28925 nanoseconds spent acquiring 1 JDBC connections;
  24726 nanoseconds spent releasing 1 JDBC connections;
  1115946 nanoseconds spent preparing 13 JDBC statements;
  8974211 nanoseconds spent executing 13 JDBC statements;
  0 nanoseconds spent executing 0 JDBC batches;
  0 nanoseconds spent performing 0 L2C puts;
  0 nanoseconds spent performing 0 L2C hits;
  0 nanoseconds spent performing 0 L2C misses;
  20715894 nanoseconds spent executing 1 flushes (flushing a total of 13 entities and 13 collections);
  88175 nanoseconds spent executing 1 partial-flushes (flushing a total of 0 entities and 0 collections)
}

正如你所看到的JPQL查询和对12个选定的Author实体的每一个调用getBooks方法,导致了13个查询。这比大多数开发人员所以为的还要多,在他们看到如此简单的代码片段的时候。

如果你让Hibernate初始化所需的关联,那么你可以很容易地避免这种情况。有若干不同的方式可以做到这一点。最简单的方法是添加JOIN FETCH语句到FROM子句中。

Author a = em.createQuery(
                "SELECT a FROM Author a JOIN FETCH a.books WHERE a.id = 1",
                Author.class).getSingleResult();

错误4:选择比所需的更多记录

当我告诉你选择太多的记录会减慢应用程序的速度时,我敢保证你一定不会感到惊讶。但是我仍然经常会发现这个问题,当我在咨询电话中分析应用程序的时候。

其中一个原因可能是JPQL不支持你在SQL查询中使用OFFSET和LIMIT关键字。这看起来似乎不能限制查询中检索到的记录数量。但是,你可以做到这一点。你只需要在Query接口上,而不是在JPQL语句中设置此信息。

我在下面的代码片段中做到这一点。我首先通过id排序选定的Author实体,然后告诉Hibernate检索前5个实体。

List<Author> authors = em.createQuery("SELECT a FROM Author a ORDER BY a.id ASC", Author.class)
                                    .setMaxResults(5)
                                    .setFirstResult(0)
                                    .getResultList();

错误5:不使用绑定参数

绑定参数是查询中的简单占位符,并提供了许多与性能无关的好处:

  • 它们非常易于使用。
  • Hibernate自动执行所需的转换。
  • Hibernate会自动转义Strings,防止SQL注入漏洞。

而且也可以帮助你实现一个高性能的应用程序。

大多数应用程序执行大量相同的查询,只在WHERE子句中使用了一组不同的参数值。绑定参数允许Hibernate和数据库识别与优化这些查询。

你可以在JPQL语句中使用命名的绑定参数。每个命名参数都以“:”开头,后面跟它的名字。在查询中定义了绑定参数后,你需要调用Query接口上的setParameter方法来设置绑定参数值。

TypedQuery<Author> q = em.createQuery(
                "SELECT a FROM Author a WHERE a.id = :id", Author.class);
q.setParameter("id", 1L);
Author a = q.getSingleResult();

错误6:执行业务代码中的所有逻辑

对于Java开发人员来说,在业务层实现所有的逻辑是自然而然的。我们可以使用我们最熟悉的语言、库和工具。

但有时候,在数据库中实现操作大量数据的逻辑会更好。你可以通过在JPQL或SQL查询中调用函数或者使用存储过程来完成。

让我们快速看看如何在JPQL查询中调用函数。如果你想深入探讨这个话题,你可以阅读我关于存储过程的文章。

你可以在JPQL查询中使用标准函数,就像在SQL查询中调用它们一样。你只需引用该函数的名称,后跟一个左括号,一个可选的参数列表和一个右括号。

Query q = em.createQuery("SELECT a, size(a.books) FROM Author a GROUP BY a.id");
List<Object[]> results = q.getResultList();

并且,通过JPA的函数function,你也可以调用数据库特定的或自定义的数据库函数。

TypedQuery<Book> q = em.createQuery(
             "SELECT b FROM Book b WHERE b.id = function('calculate', 1, 2)",
             Book.class);
Book b = q.getSingleResult();

错误7:无理由地调用flush方法

这是另一个比较普遍的错误。开发人员在持久化一个新实体或更新现有实体后,调用EntityManager的flush方法时经常会出现这个错误。这迫使Hibernate对所有被管理的实体执行脏检查,并为所有未决的插入、更新或删除操作创建和执行SQL语句。这会减慢应用程序,因为它阻止了Hibernate使用一些内部优化。

Hibernate将所有被管理的实体存储在持久性上下文中,并试图尽可能延迟写操作的执行。这允许Hibernate将同一实体上的多个更新操作合并为一个SQL UPDATE语句,通过JDBC批处理绑定多个相同的SQL语句,并避免执行重复的SQL语句,这些SQL语句返回你已在当前Session中使用的实体。

作为一个经验法则,你应该避免任何对flush方法的调用。JPQL批量操作是罕见的例外之一,对此我将在错误9中解释。

错误8:使用Hibernate应付一切

Hibernate的对象关系映射和各种性能优化使大多数CRUD用例的实现非常简单和高效。这使得Hibernate成为许多项目的一个很好的选择。但这并不意味着Hibernate对于所有的项目都是一个很好的解决方案。

我在我之前的一个帖子视频中详细讨论过这个问题。JPA和Hibernate为大多数创建、读取或更新一些数据库记录的标准CRUD用例提供了很好的支持。对于这些用例,对象关系映射可以大大提升生产力,Hibernate的内部优化提供了一个很优越的性能。

但是,当你需要执行非常复杂的查询、实施分析或报告用例或对大量记录执行写操作时,结果就不同了。所有这些情况都不适合JPA和Hibernate的查询能力以及基于实体管理的生命周期。

如果这些用例只占应用程序的一小部分,那么你仍然可以使用Hibernate。但总的来说,你应该看看其他的框架,比如jOOQ或者Querydsl,它们更接近于SQL,并且可以避免任何对象关系映射。

错误9:逐个更新或删除巨大的实体列表

在你看着你的Java代码时,感觉逐个地更新或删除实体也可以接受。这就是我们对待对象的方式,对吧?

这可能是处理Java对象的标准方法,但如果你需要更新大量的数据库记录,那么,这就不是一个好方法了。在SQL中,你只需一次定义一个影响多个记录的UPDATE或DELETE语句。数据库将会非常高效地处理这些操作。

不幸的是,用JPA和Hibernate操作起来则没有那么容易。每个实体都有自己的生命周期,而你如果要更新或删除多个实体的话,则首先需要从数据库加载它们。然后在每个实体上执行操作,Hibernate将为每个实体生成所需的SQL UPDATE或DELETE语句。因此,Hibernate不会只用1条语句来更新1000条数据库记录,而是至少会执行1001条语句。

很显然,执行1001条语句比仅仅执行1条语句需要花费更多的时间。幸运的是,你可以使用JPQL、原生SQL或Criteria查询对JPA和Hibernate执行相同的操作。

但是它有一些你应该知道的副作用。在数据库中执行更新或删除操作时,将不使用实体。这提供了更佳的性能,但它同时忽略了实体生命周期,并且Hibernate不能更新任何缓存。

在《How to use native queries to perform bulk updates》一文中对此我有一个详细的解释。

简而言之,在执行批量更新之前,你不应使用任何生命周期侦听器以及在EntityManager上调用flush和clear方法。flush方法将强制Hibernate在clear方法从当前持久化上下文中分离所有实体之前,将所有待处理的更改写入数据库。

em.flush();
em.clear();
Query query = em.createQuery("UPDATE Book b SET b.price = b.price*1.1");
query.executeUpdate();

错误10:使用实体进行只读操作

JPA和Hibernate支持一些不同的projections。如果你想优化你的应用程序的性能,那么你应该使用projections。最明显的原因是你应该只选择用例中需要的数据。

但这不是唯一的原因。正如我在最近的测试中显示的那样,即使你读取了相同的数据库列,DTO projections也比实体快得多。

在SELECT子句中使用构造函数表达式而不是实体只是一个小小的改变。但在我的测试中,DTO projections比实体快40%。当然,两者比较的数值取决于你的用例,而且你也不应该通过这样一个简单而有效的方式来提高性能。

了解如何查找和修复Hibernate性能问题

正如你所看到的,一些小小的问题都可能会减慢你的应用程序。但幸运的是,我们可以轻松避免这些问题并构建高性能持久层。

上面这些仅是我在“Hibernate Performance Tuning Online Training”中展示的一部分内容。在Hibernate性能调优在线培训中你还将学习如何查找性能问题,以及学习大量久经考验的Hibernate性能调优技术。