提高Spring Data JPA应用程序的性能


Spring Data JPA为Spring应用程序提供了数据访问层的实现。这是一个非常方便的组件,因此您可以花更多时间来实现业务逻辑。使用Spring Data JPA时需要遵循一些好的做法。例如,限制不必要对象的加载以优化性能。
本文将为您提供一些减少数据库往返的技巧,而不是检索数据库的所有元素,从而不影响应用程序的整体性能。为此,我们将首先看到Spring Data JPA提供的各种工具,以改进对数据访问的控制,以及一些减少数据检索对我们的应用程序的影响的良好实践。然后,我将与您分享一个通过播放这些不同方面来提高Spring应用程序性能的具体示例,从而减少潜在问题。

实体关系的加载
加载类型EAGER和LAZY:
使用Spring Data JPA(以及一般的Hibernate)创建应用程序时,可以自动加载对象依赖项(例如本书的作者), 加载方式有两种:自动:EAGER急切加载 ;或手动: LAZY加载。
使用EAGER类型依赖项,每次加载对象时,也会加载相关对象:当您要求书籍数据时,也会检索作者的数据;对于LAZY类型依赖项,仅加载所需对象的数据:不检索作者的数据。
使用Spring Data JPA,2个领域对象之间的每个关系都拥有这些数据加载类型之一。默认情况下,加载方法将由关系类型确定。
以下是对其数据加载默认类型的所有可能关系的提醒:

@OneToOne
对于实体A的每个实例,实体B的一个(且仅一个)实例被关联。B也只与实体A的一个实例相关联。
一个典型的例子是患者和他的记录之间的关系:

@Entity
public class Patient implements Serializable {
   @OneToOne
   private PatientRecord record;
}

对于此关系类型,默认数据加载方法是EAGER:每次询问患者的数据时,也会检索患者记录的数据。

@ManyToOne
对于实体A的每个实例,实体B的一个(且仅一个)实例被关联。另一方面,B可能与A的许多实例相关联。
一个典型的例子是产品与其类别之间的关系:

@Entity
public class Product implements Serializable {
   @ManyToOne
   private ProductCategory category;
}

对于此关系类型,默认数据加载方法是EAGER:每次询问产品数据时,也会检索类别的数据。
(banq注:在DDD聚合设计下,只有聚合根是整体,整体指向其他聚合部分对象,关系只能是单向,因为聚合根总是首先加载,因此只有1:1或1:N关系,没有N:1关系,从业务设计上简化了技术处理)

@OneToMany
对于实体A的每个实例,实体B的零个,一个或多个实例相关联。另一方面,B仅链接到A的一个实例。
它与@ManyToOne关系相反,因此典型示例可能是产品类别及其相关的产品列表:

@Entity
public class ProductCategory implements Serializable {
   @OneToMany
   private Set<Product> products = new HashSet<>();
}

对于此关系类型,默认数据加载方法是LAZY:每次询问类别数据时,都不会检索产品列表。

@ManyToMany
对于实体A的每个实例,实体B的零个,一个或多个实例相关联。相反的情况也是如此,B与A的零个,一个或多个实例相关联。
一个典型的例子是博客文章与其主题列表之间的关系:

@Entity
public class Article implements Serializable {
   @ManyToMany
   private Set<Topic> topics = new HashSet<>();
}

对于此关系类型,默认数据加载方法是LAZY:每次请求文章数据时,都不会检索到顶部列表。

尽量减少EAGER关系的使用
目标是从数据库中仅加载您要求的所需数据。例如,如果要按应用程序中注册的名称显示作者列表,则不希望获取所有关系的数据:作者编写的书籍,地址等。
一个好的做法是最小化Eager自动加载的关系。实际上,你获得的EAGER关系越多,获取的对象就越多,不一定有用。这意味着数据库所需的往返次数增加,数量增加专用于数据库表与应用程序实体之间映射的时间。因此,使用LAZY关系的权限以及仅在需要时加载缺失关系的数据可能会很有趣。

具体而言,建议仅在您确定链接数据始终有用的关系中使用EAGER加载(我认为它不常见)。这意味着使用@OneToMany和@ManyToMany默认加载方法(默认是EAGER加载)。
也可强制@OneToOne和@ManyToOne延迟加载。这反映在指定fetch关系的属性中:

@Entity
public class Product implements Serializable {
   @ManyToOne(fetch = FetchType.LAZY)
   private ProductCategory category;
}

这需要为每个实体和每个关系进行额外的调整工作,因为创建新方法将是必不可少的,这将允许我们在最少的查询中加载操作所需的所有数据。实际上,如果需要显示相对于作者的所有数据(他的生物,他的书籍列表,他的地址等),在一个查询中获取对象及其关系将是有趣的,因此使用连接数据库。

(banq注:是否懒加载,不仅仅考虑性能因素,也要结合业务设计考虑,如果是一个聚合群,最好一次性全部加载,当然如果一对多的多方有几万个数据,那么这种情况下这些数据直接从仓储专门查询获得,也无所谓懒加载或急切加载)

如何控制执行哪种查询?
Spring Data JPA为我们提供数据访问。但是,您必须了解这将如何实现。要验证执行哪些查询以从数据库检索数据,必须激活Hibernate日志。

有几种选择。首先,可以在Spring配置中激活一个选项:

spring:
  jpa:
    show-sql: true

或者,可以在记录器的配置文件中配置它:
<logger name="org.hibernate.SQL" level="DEBUG"/>

注意:在这些日志中,不会显示查询的所有参数(它们被替换"?"),但它不会阻止我们查看执行哪些查询。

如何优化LAZY对象的检索
Spring Data JPA提供了指定在数据库中的选择查询期间将加载哪些关系的功能。我们将使用几种方法查看相同的示例:如何在单个查询中检索包含其主题的文章。

方法1:使用@Query抓取和加载对象 
注释@Query允许使用JPQL语言编写选择查询。因此,您可以使用位于fetch关系连接上的JPQL关键字来加载这些关系。

因此,在Article实体的存储库中,可以findOneWithTopicsById通过指定应在检索到的文章的实例中加载主题列表来创建方法:

@Repository
public interface ArticleRepository extends JpaRepository<Article,Long> {
   @Query("select article from Article article left join fetch article.topics where article.id =:id")
   Article findOneWithTopicsById(@Param(
"id") Long id);
}

方法2:使用 @EntityGraph抓取和加载对象
从Spring Data JPA的1.10版开始,您可以使用@EntityGraph注释来创建在请求时与实体一起加载的关系图。
此注释也用于JPA存储库。该定义可以直接在存储库的查询上或在实体上完成。

查询仓储的图表graph定义
我们定义将与实体一起加载的关系,这要归功于attributePaths代表关系列表的关键字(这里是一个元素的列表):

@Repository
public interface ArticleRepository extends JpaRepository<Article,Long> {
   @EntityGraph(attributePaths = "topics")
   Article findOneWithTopicsById(Long id);
}

实体上的图表graph定义
由于JPA 2.1中NamedEntityGraph的概念,我们还可以在实体上定义这些图形graph。主要优点是可以在多个查询中使用此图形定义。在这种情况下,我们指定加载关系的列表,这要归功于关键字attributeNodes,它是一个列表@NamedAttributeNode。以下是如何在Article实体中实现它。

@Entity
@NamedEntityGraph(name = "Article.topics", attributeNodes = @NamedAttributeNode("topics"))
public class Article implements Serializable {
    ...
}

然后可以按如下方式使用它:

@Repository
public interface ArticleRepository extends JpaRepository<Article,Long> {
   @EntityGraph(value = "Article.topics")
   Article findOneWithTopicsById(Long id);
}

此外,可以为属性指定非指定关系的加载类型type:LAZY加载所有非指定关系或默认加载(EAGER或LAZY根据关系中指示的类型)。
也可以创建子图,从而以分层的方式工作,尽可能地要薄。


 @EntityGraph使用限制
对于与实体图相关的这两种方法,据我所知,我不能检索包含所有具有关系的实体的列表。实际上,为此,人们希望创建一种方法,例如findAllWithTopics()用图节点定义topics。这是不可能的; 您必须使用搜索限制(与数据库where中的select查询同义)。
为了克服这个限制,一种解决方案是创建一个方法findAllWithTopicsByIdNotNull():id永远不会null,所有数据都将被检索。另一种方法是使用第一种方法@Query执行此接查询,因为@Query注释没有此限制。

如果需要,添加非可选信息
当一个@OneToOne或一个@ManyToOne关系是强制性的 - 也就是说,实体必须有它的关联关系 - 告诉Spring Data JPA这个关系不是可选的很重要。
我们可以举出以下例子:一个人必须有一个地址,这个地址本身可以由几个人共享。所以,关系的定义如下:

@Entity
public class Person implements Serializable {
   @ManyToOne(optional = false)
   @NotNull
   private Adress adress;
}

添加optional = false信息将允许Spring Data JPA更有效地创建其选择查询,因为它将知道它必须具有与人相关联的地址。因此,最好在定义强制关系时始终指定此属性。

小心后果
虽然将关系的默认加载从EAGER更改为LAZY可能在提升性能上是一个好主意,但它也可能会产生一些意想不到的后果,并且可能会出现一些回归或错误。这是两个非常常见的例子。

1.可能会丢失信息
第一个副作用可能是信息丢失,例如当通过Web服务发送实体时。
例如,当我们修改之间的关系Person,并Address从EAGER到LAZY,我们要检讨的选择查询Person实体,以增加他们的地址明确的负载(用上面的方法之一)。否则,PersonWeb服务可能只提供特定于Person该Address数据的数据,并且数据可能已经消失。

这是一个问题,因为Web服务可能不再符合其接口合同。例如,它可能会影响网页上的显示:由于需要在HTML视图中显示地址,因此需要返回地址。

为了避免这个问题,使用数据传输对象(DTO)而不是直接将实体返回给客户端是很有趣的。实际上,映射器将实体转换为DTO将通过在数据库中检索初始查询期间尚未加载的关系来加载所需的所有关系:这是延迟加载。因此,即使我们不重构实体的恢复,Web服务也将继续返回相同的数据。
(banq注:DDD超越界面和数据库之上的设计让其从本质上避免这些麻烦。可以使用DDD值对象作为DTO!)

2.潜在的事务问题
第二个副作用可能是LazyInitializationException。
尝试加载事务之外的关系时会发生此异常。无法完成延迟加载,因为该对象已分离:它不再属于Hibernate会话。在更改关系加载类型之后可能会发生这种情况,因为在这些更改之前,从数据库检索数据时会自动加载关系。
在这种情况下,可能会抛出异常,原因有两个:

  • 第一个原因可能是您没有处于Hibernate事务中。在这种情况下,您必须使进程处于事务状态(感谢@TransactionalSpring注释在方法或其类上设置)或调用可以负责加载依赖项的事务服务。
  • 第二个原因可能是您不在您的实体所附加的事务之外,并且该实体未附加到您的新事务。在这种情况下,您必须在第一个事务中加载关系或在第二个事务中重新附加对象。

分页查询的特性
当您想要创建包含来自一个或多个关系的信息的分页查询时,直接选择查询加载“属于关系”的数据是一个(非常)坏主意。例如,当我们检索文章的第一页及其主题时,最好不要直接加载所有文章+主题数据,而是先加载文章中的数据,然后加载与主题相关的数据。
实际上,如果没有这个,应用程序将被迫恢复2个表之间的连接的完整数据集,将它们存储在内存中,然后仅选择所请求页面的数据。这与数据库的工作方式直接相关:即使我们只需要数据片段(页面,最小值/最大值或结果的前x个),它们也必须选择连接的所有数据。
在加载“具有关系”的页面的情况下,日志中会显示一条显式消息,警告您:

HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!

2个表的容量越大,影响越大:对于包含数百万个条目的表,这可能导致应用程序的处理成本极高。

因此,要解决此问题,首先必须在没有关系的情况下加载实体,然后在第二步中再次加载它们。要加载所请求页面的实体数据,可以使用findAll(Pageable pageable)JPA存储库的方法(继承自PagingAndSortingRepository该类)。然后,要加载关系数据,可以通过直接调用关系的getter来检索每个实体的数据,从而使用延迟加载。(banq注:这是DDD推荐的两种加载方式,从聚合根加载子对象,大量数据直接从仓储使用分页查询)

此操作将非常昂贵,因为它会生成大量查询。实际上,对于每个实体,将存在与要加载的关系一样多的选择查询:此问题称为“Hibernate N + 1查询问题”。如果我们以加载关系的20篇文章的页面为案例加载,这将导致21个查询:页面为1,每篇文章的主题为20。

为了降低此成本,可以在两个实体之间或关系@BatchSize(size = n)上使用注释。这允许Hibernate等到有足够的(n)关系在数据库中进行select查询之前检索。该数字n与页面的大小相关联(它仍然意味着具有默认页面大小,因为n在实体上定义,因此是常量)。在前面的示例中,我们可以将最小数量指定为20:@OneToMany@ManyToMany

@Entity
public class Article implements Serializable {
   @ManyToMany
   @BatchSize(size = 20)
   private Set<Topic> topics = new HashSet<>();
}

在这种情况下,加载页面的查询数量将从页面的21减少到2:1,所有主题的查询数量减少1。
注意:如果页面包含少于20个元素(因此小于n),则仍将正确加载主题。

使用缓存
为了提高应用程序的性能,使用缓存系统可能会很有趣。首先,有Hibernate二级缓存。它使得可以将域实体保留在内存中以及它们之间的关系,从而减少对数据库的访问次数。还有查询缓存。除了二级缓存之外,它通常更有用。它可以将查询和结果保存在内存中。
但是,使用这个时我们必须注意几点:

  • 二级缓存仅在通过其id(以及正确的JPA方法)访问实体时有用;
  • 在分布式应用程序中,还必须使用分布式缓存,或仅在很少更改的只读对象上使用它;
  • 当数据库可以被其他元素更改时(也就是说当应用程序不是访问数据的中心点时),实现会更复杂。

(banq注:DDD聚合根实体强调必须有唯一标识id,这为使用缓存提供设计保证)

索引创建
索引创建是提高数据库访问性能的必要步骤。这允许更快和更便宜的选择搜索。没有索引的应用程序效率会低得多。
有关创建索引的一些细节:

  • 索引在大表上性能有最佳改进;
  • 只有当数据具有足够的基数时,索引通常才有用(50%男性和50%女性的性别指数效率不高);
  • 一个加快阅读速度的索引也会减慢写作速度;
  • 并非所有DBMS都具有相同索引策略,因此必须针对性对待。例如,根据DBMS,可以在添加外键时自动创建索引,也可以不创建索引。

事务管理
事务管理在优化方面也很重要:如果我们不希望看到性能问题,则必须正确使用事务。例如,事务创建对于应用程序来说是昂贵的:最好不要在不需要它们的情况下使用它们。
默认情况下,Hibernate将在更新数据库中的所有数据之前等待Spring事务完成(除非它检测到需要中间持久性刷新)。同时,它会将更新存储在内存中。如果事务涉及大量数据,则可能会出现性能问题。在这种情况下,最好将处理拆分为多个事务。
此外,当事务没有写入时,您可以告诉Spring它是只读的以提高性能: @Transactional(readOnly = true)

​​​​​​​点击标题看原文