這是 N+1 問題終結的開始:引入單查詢載入。

工程 | Jens Schauder | 2023 年 8 月 31 日 | ...

太長不看

從 Spring Data JDBC 3.2.0-M2 開始,Spring Data JDBC 支援單查詢載入。單查詢載入使用單個 select 語句載入任意聚合。

要啟用單查詢載入,您需要在您的 RelationalMappingContext 上呼叫 setSingleQueryLoadingEnabled(true)

在 3.2.0-M2 中,這僅適用於簡單的聚合,由一個聚合根和單個實體集合組成。它也僅限於 CrudRepository 中的 findAllfindByIdfindAllByIds 方法。未來版本將對此進行改進。最後一個限制是您使用的資料庫必須支援分析函式(又名視窗函式)。除了記憶體資料庫(H2 和 Hsql DB)之外,所有官方支援的資料庫都支援。

您可以將單查詢載入縮寫為 SQL,但是請不要這樣做。

如果您想了解它是如何工作的,以及我們是如何想出它的,請繼續閱讀。

問題

從概念上講,Spring Data JDBC 一次性載入完整的聚合。然而,到目前為止,如果您檢視實際執行的 SQL,您會發現對於非平凡的聚合,會執行多個 SQL 語句。例如,考慮引用 Hobby 集合和 Toy 實體集合的 Minion 型別。當 Spring Data JDBC 載入一堆這樣的 Minion 時,它會

  1. 執行 SELECT ... FROM MINION
  2. 對於該查詢中的每個結果,它會
  3. 執行 SELECT ... FROM HOBBY
  4. 執行 SELECT ... FROM TOY

這是低效的,被稱為 N+1 問題,因為對於一個具有單個集合的聚合,要載入 N 個聚合,需要執行 N+1 個查詢(一個用於根,N 個用於子實體)。如果只有一個單個集合,您可以進行連線,但當有多個集合時,這就會失效。

這個問題絕非 Spring Data JDBC 所特有。其他 ORM 使用不同的策略來最小化這個問題。例如,它們可能會將一個子實體連線到聚合根。或者,它們可能會對相關實體使用批次載入。所有這些方法都限制了問題的影響,但它們只是治標不治本。此外,大多數人實際上會告訴您,您不能真正地在一個查詢中完成此操作,因為您將獲得所有子表的笛卡爾積,這可能會非常糟糕。想象一下 5 個子表,每個 Minion 有 10 個條目。這些表的笛卡爾積將是 1010101010 = 10000 行!

構想

很久以前,我記得我的前同事 Frank Gerberding 說過:“關係資料庫的問題在於它們總是返回表,有時你需要一棵樹。” 他是用德語說的,我不記得他的確切措辭,但大意是這樣的。這讓我思考:確實,SQL 查詢總是基本上返回一個表。但是,我該如何在其中表示一棵樹呢?換句話說:您如何在 Excel 中表示聚合的資料?如果您忽略 Excel 基本上是一個擁有超能力的關係資料庫的事實,而只是將其視為一個單一的電子表格呢?

讓我們從一個相當簡單的例子開始。

class Minion {
    @Id
    Long id;
    String name;
    List<Toy> toys;
    // the skills you need to excel at this hobby.
    List<Hobby> hobbies;
}

ToyHobby 目前只有 name 屬性。

如果我想在 Excel 中表示它,我可能會這樣做

Minion id Minion name toys name hobbies name
1 Bob Teddy Hold Teddy
Blue Light Look Cute
Follow Kevin
2 Kevin ... ...

從查詢中獲得這樣的結果會非常棒。透過對 ResultSet 進行一次遍歷,從中構建 Java 例項並不困難。

此時我記得 SQL 實際上是圖靈完備的。因此,我可以在 SQL 中表達這一點。這只是一個如何表達的問題!知道一個問題有解決方案總是有幫助的。當你能夠壓制腦海中試圖說服你沒有解決方案,你只是在浪費時間的那個聲音時,找到解決方案就會容易得多。

行號

集合的元素透過 Minion 中行的索引“連線”。但該索引在資料庫中不存在。幸運的是,您可以使用 row_number() 視窗函式相當容易地建立此類索引。

如果您不瞭解視窗函式(又名分析函式),它們與聚合函式類似,但 group by 不會將所有匹配的行摺疊成一行。相反,分析函式應用於 group by 定義的視窗,並且結果在每行中都可用。而且它不必總是同一組中所有行都具有相同的結果。這些函式可以做更多的事情。您應該閱讀更多相關資訊。但對於我們目前的問題,我們只需要

  • row_number(),它為組中的所有行分配一個唯一的、連續遞增的數字。
  • count(*),它計算組中的行數。我知道,令人驚訝。

我們首先為每個子表建立一個子查詢。每個子查詢從底層表中選擇所有列,一個 row_number()count(*),每個都按 minion_id 分組。

( 
  select *,
    row_number() over (partition by minion_id) h_rn,
    count(*) over (partition by minion_id) h_cnt
  from hobby
) h

我們實際上對聚合根也做了同樣的事情。但是,我們不需要 row_number,因為我們知道每行只有一個 Minion。因此,我們可以將其固定為 1。

( 
  select *,
    1 m_rn
  from minion
) m

按 ID 連線

接下來,我們將所有這些子查詢與標準左連線連線起來

select *
from ( ... ) m
left join 
  ( ... ) h
  on m.id = h.minion_id
left join 
  ( ... ) t
  on m.id = t.minion_id

這正是上面我宣告不可接受的笛卡爾積。

Minion id m_rn Minion name toys name t_rn hobbies name h_rn
1 1 Bob Teddy 1 Hold Teddy 1
1 1 Bob Blue Light 2 Hold Teddy 1
1 1 Bob Teddy 1 Look Cute 2
1 1 Bob Blue Light 2 Look Cute 2
1 1 Bob Teddy 1 Follow Kevin 3
1 1 Bob Blue Light 2 Follow Kevin 3
2 1 Kevin ... ... ... ...

我們想要的是類似於不同行號上的full outer join。不幸的是,您不能在 SQL 中在一個列上進行 left join,在另一個列上進行 full outer join。但是我們可以透過 where 子句解決這個問題。

行號上的偽全外連線

where 子句的樸素版本是

where m_rn = h_rn
and   m_rn = t_rn

這忽略了我們需要外連線語義的事實。為了解決這個問題,添加了大量的 is null 檢查和與 cnt 列的比較,使得 where 子句相當難以閱讀。它也足夠複雜,以至於我無法在不犯大量錯誤的情況下寫下來。因此,我將省略細節。如果您真的想知道,請啟用 SQL 日誌記錄。

這樣,我們就將行數減少到了正確的數量。太棒了!但我們仍然在重複部分資料。

Minion id m_rn Minion name toys name t_rn hobbies name h_rn
1 1 Bob Teddy 1 Hold Teddy 1
1 1 Bob Blue Light 2 Look Cute 2
1 1 Bob Teddy 1 Follow Kevin 3
2 1 Kevin ... ... ... ...

例如,對於沒有匹配玩具的愛好,一個玩具的資料會一遍又一遍地重複。我們真正希望將其減少為 null 值。在這個玩具示例中,這並沒有太大的區別,但這些值可能是部落格文章上的長篇評論,並且需要大量時間透過網路傳輸。為此,我們用以下表達式替換了幾乎所有列

case when x_rn = rn then name end

這裡的 x_rn 是作為相關列源的子查詢的行號。rn總行號——也就是說,所有子查詢都連線到的行號。此條件基本上表示:如果子查詢有此行的資料,則使用它;否則,只使用 null。我們在所有普通列上使用此模式。只有在下文所述的進一步連線中使用的列才不會這樣處理。

現在我們的結果看起來正是我們想要的。

Minion id m_rn Minion name toys name t_rn hobbies name h_rn
1 1 Bob Teddy 1 Hold Teddy 1
1 1 Blue Light 2 Look Cute 2
1 1 Follow Kevin 3
2 1 Kevin ... ... ... ...

我們返回最少的行數,並且沒有重複資料!但我們只針對一個巢狀實體級別這樣做!這透過簡單的遞迴解決:我們得到的結果看起來就像一個簡單的表。因此,它可以像這樣使用。確切地說,它可以替代向選擇新增行號的子查詢,因為它已經有一個行號。

條件

到目前為止,我們基本上查看了 findAll 操作的查詢。大約半年前,我已經有一個適用於 findAll 的解決方案,但對於 findByIdfindByAddressName 等操作,它並沒有產生高效的解決方案。這對於上面提出的解決方案來說不是問題。任何 where 子句都應用於聚合根的最內層選擇,並且由於連線,它會限制所有資料。這得到了您為外部索引鍵和 ID 建立的索引的良好支援,因此我們相信這種查詢方式可以高效執行。

展望

如本文開頭所述,此方法目前僅適用於 Spring Data JDBC、簡單的聚合和非常特定的查詢方法。我們希望將其提供給所有聚合、所有 Spring Data JDBC 查詢方法,甚至 Spring Data R2DBC。後者將使 Spring Data R2DBC 能夠讀取完整的聚合!它肯定會對您未來指定 Spring Data Relational 查詢的方式產生影響。當然,使用 Spring Data Relational 的下游專案也將受益於此。Spring 的 REST 和 GraphQL 支援就是很好的例子。

關注此 Github 問題以瞭解有關此主題的更多進展。

結論

我們找到了一種透過單個查詢從任意表樹載入資料的方法。這非常適合 Spring Data JDBC,因為它處理的聚合就是這樣的樹。生成的查詢稍微複雜一些,但 RDBMS 應該能夠高效地執行它們。

當然,我們現在正在尋找真實的經驗和反饋:您遇到問題了嗎?它對您的效能有影響嗎?請透過 GithubStackoverflow 告訴我們。

獲取 Spring 新聞通訊

透過 Spring 新聞通訊保持聯絡

訂閱

領先一步

VMware 提供培訓和認證,助您加速進步。

瞭解更多

獲得支援

Tanzu Spring 提供 OpenJDK™、Spring 和 Apache Tomcat® 的支援和二進位制檔案,只需一份簡單的訂閱。

瞭解更多

即將舉行的活動

檢視 Spring 社群所有即將舉行的活動。

檢視所有