Spring Data JPA为Spring应用程序提供了数据访问层的实现。这是一个非常方便的组件,因为它不会重新发明每个新应用程序的数据访问方式,因此您可以花更多时间来实现业务逻辑。使用Spring Data JPA时有一些好的做法。例如,限制不必要的对象的加载以优化性能。
本文将为您提供一些技巧,以减少请求数据库的次数,而不是检索数据库的所有元素,因此不会影响应用程序的整体性能。为此,我们首先会看到Spring Data JPA提供的各种工具来改进对数据访问的控制,以及一些良好的实践去减少数据检索对我们应用程序的影响的。然后,我将与您分享一个具体的例子,通过在这些不同的方面发挥作用来提高Spring应用程序的性能,从而减少潜在的问题。
实体关系的加载
当使用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:每次询问产品数据时,该类别的数据也将被检索。
@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设置默认的加载方法,并为两个关系@OneToOne和@ManyToOne强制加载LAZY。这反映在指定关系的fetch属性中:
@Entity
public class Product implements Serializable {
@ManyToOne(fetch = FetchType.LAZY)
private ProductCategory category;
}
这需要对每个实体和每个关系进行额外的调整工作,为此我们需要创建新的方法,这些方法将允许我们在最少的查询中加载所需的所有数据。事实上,如果需要显示与作者有关的所有数据(他的书目录,他的地址等),那么在一次查询中获取该对象及其关系将会很重要,可以使用join去解决。
如何控制会执行哪些查询
Spring Data JPA为我们提供了访问数据的途径。但是,您必须知道这些是如何实现的。要验证执行哪些查询以从数据库检索数据,必须启动Hibernate的日志。
其中一个方式是添加如下配置
spring:
jpa:
show-sql: true
如何优化LAZY对象的检索
Spring Data JPA提供了指定在数据库中的选择查询期间将加载哪些关系的功能。我们将用几种方法来看看相同的示例:如何在单个查询中检索包含其主题的文章。
方法1:使用@Query检索和加载对象
@Query注解允许使用JPQL语言编写选择查询。因此,您可以使用join中使用JPQL关键字fetch
来加载这些关系。
在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 repositories中。可以直接在repository的查询上或实体上定义。
在查询中定义
我们定义了由关键字attributePaths代表关系列表的关系(这里是一个元素的列表):
@Repository
public interface ArticleRepository extends JpaRepository<Article,Long> {
@EntityGraph(attributePaths = "topics")
Article findOneWithTopicsById(Long id);
}
在实体中定义
从JPA 2.1开始,可以使用命名实体视图在实体上定义这些图。主要优点是可以在多个查询中使用此视图。在这种情况下,我们使用关键字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);
}
此外,可以使用属性类型指定非指定关系的加载类型:对所有非指定关系进行LAZY加载或默认加载。
也可以创建子图,从而以分层方式工作以尽可能的减少不比较的加载。
@EntityGraph的使用限制
对于这两种与实体图相关的方法,我们不能检索包含所有具有关系的实体的列表。事实上,我们可能想创建一个方法,例如将其定义为findAllWithTopics(),来获取所有的文章和文章对应的主题 列表。这是不可以的,必须使用搜索限制(如使用where来筛选)。
为了克服这个限制,一个解决方案是创建一个方法findAllWithTopicsByIdNotNull():该id永远不为null,所有的数据将被检索。另一种方法是使用第一种方法@Query来执行join查询,因为@Query注解没有此限制。
如果有必要可以添加非可选信息
当@OneToOne或@ManyToOne关系是强制性的 - 也就是说,实体必须具有关联关系 - 告知Spring Data JPA这种关系不是可选的,是很重要的。
我们可以举一个例子:一个人必须有一个地址,它本身可以被几个人共享。所以,关系的定义如下:
@Entity
public class Person implements Serializable {
@ManyToOne(optional = false)
@NotNull
private Adress adress;
}
添加optional = false信息将允许Spring Data JPA在创建其查询语句时更高效,因为它知道person一定会关联一个非空的地址。因此,在定义强制关系时始终指定此属性是一种好的做法。
注意事项
尽管将关系从EAGER加载换成LAZY加载可能会提高性能,但它也会产生一些意想不到的后果,并且可能会出现一些错误。这里有两个很常见的例子。
可能的信息丢失
第一个副作用可能是信息丢失,例如通过Web服务发送实体时。
例如,当我们修改Person和Address之间从EAGER到LAZY之间的关系时,我们必须重新检查获取Person实体的查询语句,将address属性一起查询出来(使用上述方法之一)。否则,Person Web服务可能仅提供特定于该Person的数据,并且address数据可能已丢失。
这是一个问题,因为Web服务可能不再满足其接口协议。例如,它可能会影响网页上的显示:需要将地址显示在HTML中。
为了避免这个问题,使用数据传输对象(DTO)而不是直接将实体返回给客户端是很有必要的。实际上,映射器将实体转换为DTO将通过在数据库中检索初始查询期间尚未加载的关系来加载它需要的所有关系:这就是Lazy Loading。因此,即使我们不重构实体,Web服务也将继续返回相同的数据。
可能的事务问题
第二个副作用可以是LazyInitializationException。
尝试在事务外加载关系时发生此异常。无法完成懒加载,因为对象已分离:它不再属于Hibernate会话。当把EAGER改为LAZY时,可能会发生这个异常。
在这种情况下,可能出现的例外情况有两个主要原因:
- 第一个原因可能是你不在Hibernate事务中。在这种情况下,您必须使流程事务化(在该方法或其类上设置@Transactional注接)或调用可以负责加载依赖关系的事务服务。
- 第二个原因可能是在事务之外处理了懒加载对象,且该实体未附加到您的新事务中。在这种情况下,您必须在第一个事务中加载关系,或者在第二个事务中重新连接对象。
分页查询的特性
当你想创建一个包含来自一个或多个关系信息的分页查询时,直接从一个查询语句中就加载其关联的属性的数据是一个(非常)不好的主意。例如,当我们检索包含主题的文章的第一页时,最好不要直接加载所有文章+主题数据,而是首先加载文章中的数据,然后加载与主题相关的数据。
事实上,如果不这样做,应用程序将被迫建立2个表之间的的完整数据集,将它们存储在内存中,然后只选择所请求页面的数据。这与数据库的工作方式直接相关:即使我们只需要数据的一部分(一页数据,最小/最大的结果,结果的前几个),它们也必须join的所有数据。
在加载页面及其关系的情况下,日志中会显示一条明确的消息来警告您:
HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
2个表的体积越大,影响越大:对于包含数百万个条目的表,这可能导致应用程序的处理非常昂贵。
为了克服这个问题,首先加载不包含关系的实体,然后在第二步中再次加载相关联的实体。要加载请求页面的实体数据,可以使用JPA存储库的findAll(Pageable pageable)方法(从PagingAndSortingRepository类继承),然后,要加载关系数据,可以使用Lazy Loading直接调用关系的getter来检索每个实体的数据(需要在事务中)。
但是这个操作会很昂贵,因为它会产生很多查询。事实上,对于每个实体,将会有许多选择查询,因为有加载关系:这个问题被称为“Hibernate N + 1查询问题”。如果我们以加载包含关系的20篇文章的页面为例进行加载,则这将导致21个查询:页面为1,每篇文章的主题为20。
为了降低成本,可以在两个实体之间的@OneToMany或@ManyToMany关系上使用@BatchSize(size = n)注释。这允许Hibernate在数据库中进行选择查询之前等待足够的(n个)关系进行检索。这个数字n要与页面的大小相关联(但它意味着有一个默认的页面大小,因为n是在实体上定义的,因此是恒定的)。在前面的例子中,我们可以将最小数字指定为20:
@Entity
public class Article implements Serializable {
@ManyToMany
@BatchSize(size = 20)
private Set<Topic> topics = new HashSet<>();
}
在这种情况下,加载页面的查询数将从21减少到2,一次加载所有文章,另一次加载所有主题。
注意:如果页面包含少于20个元素(小于n),则主题仍将被正确加载。