ABP Framework 5.0 實現了單體應用場景下,收件箱和發件箱的事件嚴格順序性。但在微服務或多數據庫場景下,由于網絡時延和設施效率的限制, 分布式事件將不再是 Linearizability [1]?的,因此必然會存在物理時間上的收件亂序。
借用 Daniel Wu 的文章《消息可靠性和順序(中文)》[2]?中的插圖為您展示問題:
如果一個處理中的事件,與任何其他的事件之間有因果關系,則有可能因兩者的亂序而產生問題。
本文在這個事實下,討論我們在訂閱方可能遇到的情況和解決方案。
案例
我們做以下假設。
我們關注的是一個用戶積分服務,它是一些分布式事件的訂閱方。
m1
?和?m2
?是?先后發生?的兩個事件。t1
?和?t2
?分別為訂閱方服務收到并處理事件 m1 和 m2 的時間。t1 < t2
?代表 t1 早于 t2,稱為正序;t1 > t2
?代表 t1 晚于 t2,稱為亂序。C 代表訂閱方服務的狀態(Configuration)。
C0
?為初始狀態,CF
?為預期的最終狀態,CW
?為錯誤的最終狀態。
案例 1
事件 m1:用戶 A 創建事件
事件 m2:用戶 B 創建事件
Handler 的工作:根據 m1 和 m2,分別在本地創建 LocalUser 實體
分析:m1 和 m2 沒有因果關系,順序不敏感
t1 < t2 (正序):
t1 > t2 (亂序):
無需處理。
案例 2
事件 m1:用戶 A 創建事件
事件 m2:訂單 1 支付事件
Handler 的工作:根據 m1,在本地創建
LocalUser
實體;根據 m2,給LocalUser.Score
增加積分分析:m1 和 m2 有因果關系。m1 和 m2 順序敏感,但“實體不存在”的異常攔截了亂序,handler 是冪等的,不存在一致性問題
t1 < t2 (正序):
t1 > t2 (亂序):
無需處理。待 m1 被處理后,m2 延遲重試處理,實質上達到正序。
案例 3
事件 m1:訂單 1 支付事件
事件 m2:訂單 1 取消事件
Handler 的工作:根據 m1,給
LocalUser.Score
增加積分;根據 m2,給LocalUser.Score
扣減積分;積分最低扣到 0,不會為負數分析:m1 和 m2 有因果關系。m1 和 m2 順序敏感,handler 不是冪等的,存在一致性問題
t1 < t2 (正序):
t1 > t2 (亂序):
積分服務在本地創建LocalOrder
實體記錄訂單處理狀態。
public class LocalOrder : AggregateRoot<Guid>
{public bool HasPaidEventHandled { get; set; } // set to true after handling m1
}
當 m2 handler 發現OrderCanceledEto.OrderPaidTime != null
而LocalOrder.HasPaidEventHandled == false
,則拋出錯誤。待 m1 被處理后,m2 延遲重試處理,實質上達到正序。
我們實質上把本案例 3 轉化成了案例 2 的情況,從而實現了冪等。
處理后
t1 < t2 (正序):
t1 > t2 (亂序):
案例 4
事件 m1:用戶 A 變更事件 (變更了可用區?
Region
)事件 m2:訂單 1 支付事件
Handler 的工作:根據 m1,由于
UserEto.Region != LocalUser.Region
,清零LocalUser.Score
。根據 m2,給LocalUser.Score
增加積分分析:m1 和 m2 有因果關系。m1 和 m2 順序敏感,handler 不是冪等的,存在一致性問題
t1 < t2 (正序):
t1 > t2 (亂序):
我們可以通過這些改動解決問題:
給
User
實體擴展 int 類型屬性RegionVersion
,默認值為 0,每次 Region 變更時,RegionVersion
遞增 1。積分服務使用
LocalUserRegion.Score
記錄用戶的積分,而非使用LocalUser.Score
。public class LocalUserRegion : AggregateRoot<Guid> {public Guid UserId { get; set; }public string Region { get; set; }public int RegionVersion { get; set; }public int Score { get; set; } }
處理 m1 時,若
UserEto.RegionVersion
更新,則創建新的LocalUserRegion
實體,初始的積分為 0,相當于變更 Region 即清零積分。在用戶支付時,本地服務調用 Identity 遠程服務,將查得的
UserDto.RegionVersion
寫入事件 m2 的OrderPaidEto.UserRegionVersion
。處理 m2 時,根據
OrderPaidEto.UserRegionVersion
,增加對應的LocalUserRegion.Score
。
我們解除了 m1 和 m2 的因果關系,從而實現了冪等。
處理后
t1 < t2 (正序):
t1 > t2 (亂序) :
案例 5:ABP 實體同步器
在 ABP 的 DDD 實踐中,不同模塊之間會通過實體同步器冗余實體數據。一個典型的案例是 Blogging 模塊的 BlogUserSynchronizer [3]。本案例的特別之處在于,如果不是有極嚴的要求,過期的事件可以被跳過處理。
事件 m1:用戶 A 變更事件
事件 m2:用戶 A 變更事件
Handler 的工作:根據 m1/m2,更新
LocalUser
實體中的用戶資料分析:一旦 m2 早于 m1 被處理,則舊資料會覆蓋新資料,存在一致性問題
t1 < t2 (正序):
t1 > t2 (亂序):
我們給實體增加 int 類型的?EntityVersion
?屬性,此屬性的值從 0 開始,并在每次更新實體時,自動遞增 1。在實體同步器處理?EntityUpdatedEto<UserEto>
?事件時,若?UserEto.EntityVersion <= LocalUser.EntityVersion
,則跳過處理。就這樣,我們解決了問題。我嘗試了在 ABP 框架實現以上能力,見 PR #14197 [4]。
處理后
t1 < t2 (正序):
t1 > t2 (亂序):
思路整理
筆者認為,解決事件亂序問題有以下思路。
盡可能保持 DistributedEventHandler 的業務邏輯簡單,以便發現潛在的亂序問題。
某些情況下,我們可以通過在本地記錄實體的狀態,將 handler 轉化為冪等,就如上面案例 3 演示的那樣。
某些情況下,我們可以通過調整業務設計,解除因果關系,就如上面案例 4 演示的那樣。
實體同步器應采用 EntityVersion 的設計,以避免同步到過期的數據。
結論
即使你的應用當前只是單體,也應關心收件亂序問題,為今后可能到來的架構變化做儲備。另外,請放棄實現 Linearizability,因為在微服務或多數據庫場景下這是不可能的。
本文提到的幾個案例,開發者似乎不難找出一致性問題的隱患。但在實際生產中,業務往往更復雜,事件數量也會更多,我們很難顧及周全。即便我們在開發時把所有可能的因果關系都找了出來,并且處理了它們,將來業務變更時,我們還能確保萬無一失嗎?答案恐怕是否定的。
分布式一致性問題是沒有銀彈的,它永遠都在那里,開發者能做的是降低復雜度,通過設計解除因果關系,或手動實現冪等。
參考
Herlihy, Maurice P.; Wing, Jeannette M. (1987). "Axioms for Concurrent Objects". Proceedings of the 14th ACM SIGACT-SIGPLAN Symposium on Principles of Programming Languages, POPL '87. p. 13
Daniel Wu. (2021). 《消息可靠性和順序(中文)》.?https://danielw.cn/messaging-reliability-and-order-cn
GitHub abpframework/abp repository. BlogUserSynchronizer.cs.?https://github.com/abpframework/abp/blob/1275f2207fc39d850d23472294e456c8504f20d2/modules/blogging/src/Volo.Blogging.Domain/Volo/Blogging/Users/BlogUserSynchronizer.cs
GitHub abpframework/abp repository. PR #14197.?abpframework/abp#14197