反應式單體:如何從 CRUD 轉向事件溯源
作者 | Jonathan David
譯者 | 張衛濱
策劃 | 蔡芳芳
本文是一個系列文章的第一部分,闡述瞭如何基於事件溯源的理念在不影響既有業務的情況下,對單體式的 CRUD 應用進行改造。
本文最初發表於 Wix Engineering 網站,經原作者 Jonathan David 授權由 InfoQ 中文站翻譯分享。
我們都聽過這樣的故事:大型的單體應用曾經給我們帶來過巨大的業務價值並且很好地爲我們的客戶提供了服務,但是現在這種方式已經開始拖累我們了。產品的願景逐漸朝反應式特性演化,這意味着要在正確的背景下對多個領域事件作出實時反應。但是,問題在於我們的單體應用被設計成了一個典型的 CRUD 系統,也就是在狀態發生變化時同步運行業務邏輯。
本文是系列文章的第一篇,會講述如何將事件溯源和事件驅動架構引入到我們的客戶支持平臺(customer support platform)中,在這個過程中,我們允許逐步遷移,並且在沒有將現有功能置於風險之中的前提下,已經開始爲我們提供新的商業價值。按照傳統的 CRUD 方式進行系統設計時,我們主要關注的是狀態以及如何在一個分佈式環境中由多個用戶進行狀態的創建、更新和刪除操作,而事件溯源方式關注的是領域事件,它們何時發生以及它們如何表達業務意圖。在事件溯源方式中,狀態是事件的具體化(materialization),這只是領域事件多種可能的使用方式之一。
客戶支持平臺是實踐反應式能力的一個很好的用例。因爲客戶代理會處理來自不同渠道的案例,在這個過程中,很容易錯失對高優先級案例的跟蹤。而事件驅動系統能夠單獨跟蹤每個支持案例,能夠幫助客戶代理保持對正確案例的關注,並在其他案例需要關注的時候發出告警。這只是衆多示例中的一個。另外一個示例是當某個種類的案例在給定的時間段內大量出現的時候,我們就需要採取一定的措施。
Wix Answers 是一個客戶支持解決方案,它將工單、幫助中心和呼叫中心等支持工具集成到了一個直觀的平臺中,具有先進的內置自動化和分析能力。
1 如果我們能重新開始的話,系統會是什麼樣子呢?
如果能夠重新開始的話,我們會選擇事件溯源架構。我不會深入介紹事件溯源架構是什麼,如果你想了解更多知識的話,我強烈推薦 Martin Fowler 的這篇較舊的文章和 Neha Narkhede 的這篇較新的文章。
我喜歡事件溯源的原因在於,它將領域事件放在優先的位置,並且以此爲中心。如果你仔細傾聽客戶闡述他們的需求的話,你會經常聽到他們這樣說:“當發生這種情況時,我希望系統那樣做。” 實際上,他們是在用領域事件的方式在說話。作爲開發者,如果能夠理解我們的主要目標就是產生領域事件時,事件就開始步入正軌了,我們就會理解事件溯源的威力。
在討論我們採取了哪些行動將單體應用變得具有反應式特徵之前,我想要描述一下如果沒有任何的遺留代碼,能夠重新開始的情況下,理想的解決方案是什麼。我認爲這樣的話,你就能更好地理解我們所採取的路線以及我們必須要做出的妥協。
這是事件溯源架構中事件的一般流程:**命令(command)是由客戶發起的,旨在改變某個實體(通過 entity-id 進行唯一標識)的狀態。命令則是由聚合(aggregate)**處理的,聚合要根據當前的實體狀態決定接受或拒絕命令。如果一條命令被接受的話,聚合要發佈一個或多個領域事件同時要更新當前實體的狀態。我們必須要假定聚合能夠訪問到最新的實體狀態,並且沒有其他的進程正在並行地對特定的實體 id 進行決策,否則的話,我們就會面臨狀態一致性的問題,這是分佈式系統所固有的問題。由此可見,實體當前狀態(entity-current-state)的存儲是實體真實情況的來源(source of truth)。實體其他形式的表述最終都將是一致的,這是基於事件的具體化實現的。
2 使用 Kafka Streams 作爲事件溯源框架
有很多相關的文章討論如何在 Kafka 之上使用 Kafka Streams 實現事件溯源。我認爲關於這個話題還有很多需要討論的,但是我會在一篇單獨的文章中進行講解。現在我只想說,Kafka Streams 使得編寫從命令主題到事件主題的狀態轉換變得很簡單,它會使用內部狀態存儲作爲當前實體的狀態。內部狀態存儲是一個由 Kafka 主題作爲備份的 rocks-db 數據庫。Kafka Streams 保證能夠提供所有數據庫的特性:你的數據會以事務化的方式被持久化、創建副本並保存,換句話說,只有當狀態被成功保存在內部狀態存儲並備份到內部 Kafka 主題時,你的轉換纔會將事件發佈到下游主題中。如果採用 exactly-once 語義的話,這一點是能夠得到保證的。通過依靠 Kafka 的分區,我們能夠保證某個特定的實體 id 總是由一個進程來處理,並且它在狀態存儲中總是擁有最新的實體狀態。
3 在我們的單體 CRUD 系統中,是如何引入領域事件的?
我們首先要問的是,真實情況的來源是什麼。我們的單體系統通過 REST API 接收變更命令,更新 MySQL 實體,然後返回更新後的實體給調用者。
這使得 MySQL 成爲了我們的事實來源。如果不對我們的單體和它與客戶端的通信方式作出重大變更的話,我們就無法改變這一點,通信必須要變成異步的。這勢必導致客戶端的重大變化。
4 變更數據捕獲(Change Data Capture,CDC)
將數據庫的 binlog 以流的方式傳向 Kafka 是一個衆所周知的實踐,這樣做的目的是複製數據庫。表中數據行的每一個變化都會被保存在 binlog 中,這樣的記錄包含之前和當前的行狀態,這種方式能夠有效地將每個錶轉換爲一個流,從而能夠以一致的方式具體化爲實體狀態。我們使用 Debezium 源連接器將 binlog 流向 Kafka。
藉助 Kafka Streams 進行無狀態轉換,我們能夠將 CDC 記錄轉換爲命令,發佈到聚合命令主題。我們這樣做有幾個原因:
-
在很多情況下,我們有多個表使用實體 id 作爲二級索引。我們希望聚合能夠處理與同一 id 相關的所有命令。例如,我們可能有一個主鍵爲 orderId 的 “Order”表,以及一個帶有 orderId 列的 “OrderLine” 表。通過將 Order CDC 記錄轉換爲 UpdateOrderCdc 命令,將 OrderLine CDC 記錄轉換爲 UpdateOrderLineCdc 命令,我們能夠確保同一個聚合將會處理這些命令,並能訪問最新的實體狀態。
-
我們想爲所有的聚合命令定義一個模式。這個模式可以從 CDC 的更新命令開始,但也可以演變成更細粒度的命令,這些命令也可以由同一個聚合來處理,這樣就可以逐步演變成一個真正的事件溯源架構。
隨着聚合不斷處理命令,它會逐漸更新 Kafka 中的實體狀態。我們可以重新創建源連接器,並實現相同表的再次流化處理,然而,我們的聚合會根據 CDC 數據和從 Kafka 檢索的當前實體狀態之間的差異來生成事件。在某種程度上來講,Kafka 成爲了我們的流平臺的事實情況來源,該平臺是與單體應用並存的。
5 CDC 記錄代表了已提交的變化,爲什麼它們不是事件呢?
CDC feed 的目的是以最終一致的方式複製數據庫,而不是生成領域事件。CDC 記錄包含了變更前後的元素,通過變更前後的差異將其轉換成領域事件是一種很有誘惑力的方案。但是,僅僅依靠 CDC 記錄有一些嚴重的缺陷。
當執行無狀態轉換時,我們無法對來自不同表的 CDC 記錄做出正確的反應,因爲不同的表之間無法保證順序。最終,我們可能會在獲得 Order 記錄之前就處理了 OrderLine 記錄。一個好的領域事件將提供一些關於 Order 的上下文,將其作爲 OrderLine 事件的一部分。採用有狀態的轉換允許我們使用聚合狀態作爲 OrderLine 的存儲,並且只有在 Order 數據到達之後才發佈 OrderLine 事件。這是聚合作爲實體事件源的責任的一部分。記住,我們現在無法實現純粹的架構,而是一種並行的模式。
6 引入 Snapshot 階段
binlog 永遠不會包含所有表的全部變更歷史,爲此,當爲一個新的表配置新的 CDC 連接器時都會從 Snapshot 階段開始。連接器將標記 binlog 中當前所在的位置,然後執行一次全表掃描,並將當前所有數據行的當前狀態以一個特殊的 CDC 記錄進行流式處理,也就是會帶有一個 snapshot 標記。這本質上意味着在每次快照中,我們都會丟失領域事件信息。如果訂單狀態隨着時間的推移發生了多次變化,快照將只給我們提供最新的狀態。這是因爲 binlog 的目標是複製狀態,而不是成爲事件溯源的支撐。這就是聚合狀態存儲和聚合命令主題之所以重要的關鍵所在。我們想把我們的解決方案設計成每個表只進行一次快照的方式。
事件溯源的強大功能之一就是能夠通過回放歷史事件或命令來重建狀態或重建領域事件。但在這裏再次執行快照並不是正確的解決方案,因爲快照將導致事件信息的丟失。
如果想重新創建我們的領域事件,那麼我們需要重置命令主題的消費者所採取的行爲。命令主題將 CDC 記錄打包成命令,並且已經將來自不同表的命令以正確的順序(或聚合知道如何處理的順序)存儲起來了。
在本文中,我們只涉及了使單體應用具備反應性特徵的基本步驟。我們討論瞭如何使用 CDC 來建立一個命令主題,以及爲什麼不能使用 CDC 記錄作爲命令。我們有了命令主題之後,就可以使用有狀態的轉換來創建事件,進而能夠開始享受事件溯源的好處:重放命令以重新創建事件,重新處理事件以具體化狀態。
在接下來的文章中,我們將討論更高級的話題,將會涉及到:
-
如何使用 Kafka Streams 來表達聚合的事件溯源概念。
-
如何支持一對多的關係。
-
如何通過重新劃分事件來驅動反應式應用。
-
如何重新處理命令的歷史,確保在響應事件的反應式服務不停機的情況下重建事件。
-
最後,如何在多中心的 Kafka 中運行有狀態的轉換(提示:鏡像主題真的不足以實現這一點)。
參考資料:
1.Martin Fowler,2005,https://martinfowler.com/eaaDev/EventSourcing.html
2.Neha Narkhede, 2016,https://www.confluent.io/blog/event-sourcing-cqrs-stream-processing-apache-kafka-whats-connection
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/h7isDTidCic1QtIPAuPOQQ