DDD:資源庫 Repository 的性能優化
在 DDD 中,聚合根需通過資源庫(Repository)持久化,資源庫將聚合根的存儲與存儲中間件(Mysql、ElasticSearch、MonogoDB 等)解耦,我們可以根據聚合的業務特性決定選擇關係型數據庫還是非關係型數據庫存儲聚合根。
很多讀者可能還存在疑問,爲什麼資源庫只提供一個 save 方法持久化聚合根。原因是在 DDD 中,資源庫是聚合根的容器,但並不限制容器是什麼做的,也就是前面說的與底層解耦。如果容器是 Key-value 數據庫做的,是不支持 update 某個字段的,並且 inset 和 update 是不區分的。資源庫與 DAO 不同,資源庫只是向領域模型提供聚合根以及持久化聚合根。
如果我們選擇關係型數據庫作爲聚合根的容器,那麼在存儲聚合根時可能就需要將聚合根以及聚合根下的實體拆分到多個表存儲,這就可能導致每次 save 聚合根都需要執行多條 update 語句,即便聚合根下的實體並沒有發生任何的改變,即便只是聚合根修改了一個字段(值對象),因此會嚴重影響到應用的性能。
爲解決選擇關係數據庫作爲聚合根容器導致的性能問題,我們需要付出額外的努力,如用內存快照去判斷每次 save 聚合根只需要更新哪些表。
基於每個業務用例都需要通過資源庫獲取聚合根最後也通過資源庫持久化聚合根的特性,我們可以在獲取聚合根時創建快照,並且在持久化聚合根時對比(diff)快照,獲取差異信息,只執行需要更新的差異信息。
本篇分享的是筆者實現的一種方案,雖然每個團隊定義的 DDD 代碼規範不同,但資源庫的實現上差異也並不大,因此也具有參考價值。
首先,抽象聚合根快照存儲器 AggregateRootSnapshot,提供緩存聚合根快照、根據聚合根 ID 獲取快照、移除快照的方法。
提示:我們約定聚合根必須繼承一個抽象類 BaseAggregate,該抽象類定義獲取聚合根 ID 的方法,在緩存快照時可以用聚合根 id 作爲 key 緩存,這樣在拿的時候才能根據聚合根 id 拿到。
我們可以使用 redis 實現聚合根的緩存,但不建議使用性能低的存儲中間件存儲,因爲那樣不僅資源庫的性能沒能得到優化,反正還更影響性能。當然,最好的方式是存儲在內存中,雖然犧牲點內存,這便是以空間換時間。
我們使用 ThreadLocal 存儲聚合根快照,因此編寫的 AggregateRootSnapshot 實現類如下。
如果聚合根 id 不是依賴數據庫生成的(我們也不推薦聚合根 id 依賴數據庫生成,原因在之前的文章已經介紹過了)。爲了避免在聚合根爲新創建的情況下獲取到錯誤的快照,如線程在執行上一次業務用例(一次接口請求)時,只調用獲取聚合根的方法,之後沒有調用聚合根的存儲方法移除快照(如獲取聚合根詳情),而這次是創建新的聚合根,當然也沒有調用一次資源庫獲取聚合根的方法更新快照,那麼這次獲取的就將是前一次的快照,因此我們還需要對比聚合根 id 是否相同。
只對比聚合根 id 當然不能確保獲取的就是新的聚合根,能確保聚合根唯一還有這個條件:“基於每個業務用例都需要先通過資源庫獲取到聚合根,最後也需要通過資源庫持久化聚合根的特性”,這句話纔是最重要的。
提示:ThreadLocal類型字段非靜態,不會導致內存泄露嗎?答案是不會,後面會講到。
接着,我們爲使用關係型數據庫存儲聚合根的資源庫寫一個抽象類,需要使用快照優化性能的資源庫可繼承此抽象類。
RepositorySnapshotSupper 實現 Repositor 接口的 findById、save、deleteById 方法,另外提供抽象方法由子類實現。因爲我們需要在 findById 獲取到聚合根時創建一份聚合根快照並緩存,在真正 save 聚合根之前獲取快照完成 diff 判斷,然後將 diff 結果交給子類,這樣子類在實現 save 時就可以根據 diff 結果減少不必要的 sql。
提示:RepositorySnapshotSupper的快照存儲器並非靜態的,而快照存儲器的ThreadLocal類型字段也非靜態,因此我們需要確保一個資源庫只存在一個實例(單``例),纔不會導致ThreadLocal內存泄露,只是每個聚合根強引用一個ThreadLocal。
以上幾步都不是難點,難點在於如何實現快照的創建,以及 diff 實現。
快照工具類(SnnapshotUtils)實現思路:
提前條件:要求實體與聚合根提供一個私有的無參構造函數,用於通過反射創建實例。
-
- 通過反射實現字段值拷貝,當聚合根的字段類型爲非實體類型,那麼就是值對象類型,對於值對象類型我們只需要拷貝引用即可;
-
- 如果是實體類型集合,則創建一個新的集合,並將原集合中每個實體元素都拷貝一份添加到新集合,將新集合賦給快照,實體的拷貝規則同聚合根,可使用遞歸實現。
Diff 工具類實現思路:
先定義 diff 結果類型:未修改、新增、更新、刪除。
-
- 對於聚合根,如果不存在快照即認爲 Insert 類型,聚合根下的實體也全部爲 Insert 類型;
-
- 對於聚合根,如果存在快照,那麼除實體類型或實體類型集合字段外,只要其它的任意一個值對象不同,就認爲聚合根 diff 結果爲 Update 類型,否則爲 Non 類型;
-
- 只要聚合根不是新增,不管聚合根有沒有更新,都不會影響聚合根下的實體的 diff;
-
- 如果實體與聚合根一對一,即不是集合類型字段,那麼:如果對應實體快照不存在,則認 diff 結果爲 Insert,否則如果實體快照存在但新的爲 null 則認爲是 Delete,否則對比實體的各個值對象,未修改則爲 Non,修改則爲 Update;
-
- 如果實體與聚合根是多對一,即實體集合,如訂單有多個訂單 item,那麼需要一個個對比:新的 item 在快照中找不到,則爲 Insert,快照中的 item 已經不存在新的實體集合,則爲 Delete,否則對比 item,未修改則爲 Non,修改則爲 Update。
定義存儲 diff 結果的類:
由於 BaseAggregate 聚合根實現了實體接口(聚合根也是實體),因此我們在 EntityDiff 中使用 Entity 引用聚合根 / 實體,方便後續直接從 diff 中獲取 entity 執行插入、更新,或是獲取 entitySnapshot 執行刪除。(對於實體集合,也可存實體在集合中的索引。)
如果聚合根下的實體字段是集合類型,那麼 diff 結果也使用集合存儲:
diff 工具類的實現:
由於項目代碼不便貼出來,在此我簡單寫了一個測試用例,分享下成果。
訂單聚合根:
提示:使用lombok有個坑,如果使用@Builder註解,需要提供一個無參構建方法(建議是私有的構建方法),然後在構建方法上添加@Tolerate註解。
訂單 item 實體:
訂單資源庫實現:
-
當聚合根的 diff 結果類型爲 Insert 時,全量存儲聚合根、聚合根下的實體;
-
當聚合根的 diff 結果類型爲 Non 時,不需要更新聚合根,但聚合根下的實體是否需要更新還需要根據聚合根實體的 diff 結果確定;
-
當聚合根的 diff 結果類型爲 Update 時,需要更新聚合根;
-
獲取實體的 diff 結果,根據 diff 結果決定是插入、更新、刪除、還是什麼也不做。
單元測試:
單元測試結果如下:
總結
本篇介紹如何通過快照 + diff 的方式優化資源庫的性能,之所有能這樣做是因爲每個業務用例都需要先通過資源庫獲取到聚合根,最後也需要通過資源庫持久化聚合根。出於性能考慮,我們決定以空間換時間,使用 ThrealLocal + 反射實現創建和緩存聚合根快照,最後也使用反射完成 diff 邏輯。當然 diff 類還存在優化空間。
本篇介紹的快照是基於聚合根 (DO) 的,當然我們還可以基於 (PO) 去實現,也會更簡單。
- 注意:本篇圖片中的代碼可能有 bug,未更新到優化後的代碼,懶得重新截圖,僅供參考!
參考文獻:
感謝淘系技術殷浩大佬提供的思路!
[Java 藝術] 微信號:javaskill
深耕後端架構、探索底層實現原理,關注 Java 藝術,我們在架構師道路上一起成長!
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/DS3BtuIDFXr6Y0A-upE3SA