提高Spring Data JPA應用程式的效能

banq發表於2019-01-10

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)

​​​​​​​點選標題看原文

相關文章