選單

反應式單體:如何從 CRUD 轉向事件溯源

作者 | Jonathan David

譯者 | 張衛濱

策劃 | 蔡芳芳

本文是一個系列文章的第一部分,闡述瞭如何基於事件溯源的理念在不影響既有業務的情況下,對單體式的 CRUD 應用進行改造。

本文最初發表於 Wix Engineering 網站,經原作者 Jonathan David 授權由 InfoQ 中文站翻譯分享。

我們都聽過這樣的故事:大型的單體應用曾經給我們帶來過巨大的業務價值並且很好地為我們的客戶提供了服務,但是現在這種方式已經開始拖累我們了。產品的願景逐漸朝反應式特性演化,這意味著要在正確的背景下對多個領域事件作出實時反應。但是,問題在於我們的單體應用被設計成了一個典型的 CRUD 系統,也就是在狀態發生變化時同步執行業務邏輯。

本文是系列文章的第一篇,會講述如何將事件溯源和事件驅動架構引入到我們的客戶支援平臺(customer support platform)中,在這個過程中,我們允許逐步遷移,並且在沒有將現有功能置於風險之中的前提下,已經開始為我們提供新的商業價值。按照傳統的

CRUD 方式

進行系統設計時,我們主要關注的是狀態以及如何在一個分散式環境中由多個使用者進行狀態的建立、更新和刪除操作,而

事件溯源方式

關注的是領域事件,它們何時發生以及它們如何表達業務意圖。在事件溯源方式中,狀態是事件的具體化(materialization),這只是領域事件多種可能的使用方式之一。

客戶支援平臺是實踐反應式能力的一個很好的用例。因為客戶代理會處理來自不同渠道的案例,在這個過程中,很容易錯失對高優先順序案例的跟蹤。而事件驅動系統能夠單獨跟蹤每個支援案例,能夠幫助客戶代理保持對正確案例的關注,並在其他案例需要關注的時候發出告警。這只是眾多示例中的一個。另外一個示例是當某個種類的案例在給定的時間段內大量出現的時候,我們就需要採取一定的措施。

Wix Answers 是一個客戶支援解決方案,它將工單、幫助中心和呼叫中心等支援工具整合到了一個直觀的平臺中,具有先進的內建自動化和分析能力。

1

如果我們能重新開始的話,系統會是什麼樣子呢?

如果能夠重新開始的話,我們會選擇事件溯源架構。我不會深入介紹事件溯源架構是什麼,如果你想了解更多知識的話,我強烈推薦 Martin Fowler 的這篇較舊的文章和 Neha Narkhede 的這篇較新的文章。

我喜歡事件溯源的原因在於,它將

領域事件

放在優先的位置,並且以此為中心。如果你仔細傾聽客戶闡述他們的需求的話,你會經常聽到他們這樣說:“當發生這種情況時,我希望系統那樣做。”實際上,他們是在用領域事件的方式在說話。作為開發者,如果能夠理解我們的主要目標就是產生領域事件時,事件就開始步入正軌了,我們就會理解事件溯源的威力。

在討論我們採取了哪些行動將單體應用變得具有反應式特徵之前,我想要描述一下如果沒有任何的遺留程式碼,能夠重新開始的情況下,理想的解決方案是什麼。我認為這樣的話,你就能更好地理解我們所採取的路線以及我們必須要做出的妥協。

反應式單體:如何從 CRUD 轉向事件溯源

這是事件溯源架構中事件的一般流程:

命令(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 實體,然後返回更新後的實體給呼叫者。

反應式單體:如何從 CRUD 轉向事件溯源

這使得 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