從單體架構轉向CQRS - Wu

banq發表於2022-06-21

軟體設計是一個不斷髮展的過程。每一個大系統都是從一個小系統開始的。當現有架構遇到問題但無法解決時,系統將開始演進。每一次進化都伴隨著一些技術選擇。應該解決哪些問題?它會付出怎樣的代價?作為架構師或高階工程師,必須找到合理的演進方式,無論開發進度、技術堆疊、團隊水平如何,都必須能夠滿足這些標準,然後才能做出可行的解決方案。

本文將介紹CQRS(Command Query Responsibility Segmentation)的精神和要解決的問題。我們將從一個小的單體開始,像每個軟體系統的演進一樣進行演進,本文將介紹每個演進背後的原因和方法。

傳統的單體架構

從單體架構轉向CQRS - Wu
這是最常見的系統設計。有一個 API 伺服器,通常是一個 RESTful API 和一個資料庫。客戶端提前與後端協商傳輸格式。讀取和寫入都是通過資料傳輸物件 DTO 完成的。而後端在處理業務邏輯時,會將DTO轉化為具有領域知識的領域物件,並以領域物件作為資料庫的儲存單元。

為了實現Read/Write Splitting,在左邊的寫路徑中,客戶端向上傳送DTO到後端對資料庫進行CUD(create/update/delete)操作,後端用Ack響應客戶端success 和Nak為處理後的失敗。在restful API中,通常2xx代表成功,4xx代表失敗。右邊的讀路徑簡單的通過讀請求獲得對應的DTO。

我進一步為客戶解釋了DTO的含義。客戶端上的 DTO 通常包含要在螢幕上呈現的所有資料。例如,當您在社交媒體上檢視您的個人資料時,它將包括您的姓名、帳戶和其他個人資訊,以及您自己最近的活動,甚至是您關注的活動。DTO 包含需要在此頁面上顯示的所有資訊。

為什麼我們需要強調讀/寫拆分?我們不能在讀寫路徑上使用相同的過程嗎?因為我們希望在未來更好地優化我們的系統。寫路徑有特定的優化方法,讀路徑也有。例如,要製作快取,可以在讀取路徑上使用只讀快取來減少響應時間。並且,可以通過快取寫入來改進寫入路徑。其次,寫入也可以非同步執行。所有的 DTO 都寫入訊息佇列,由 Worker 處理,以處理大量寫入的資料。此外,每個適當的資料庫都可以用於寫入和讀取。

因此,讀/寫拆分是必不可少的。並且在系統設計的早期階段就應該考慮到這一點。寫入路徑是專注於資料持久化;而讀取路徑則專注於資料查詢。

然而,這種系統設計模型存在兩個主要問題。

  1. 貧血模型。它也被稱為 CRUD 模型。當後端專注於資料轉換時,很難有處理業務邏輯的空間,這會導致業務邏輯四處分散。領域知識也會消失,例如對於電子商務網站,我們會說“購買”而不是“插入訂單記錄”。
  2. 擴充套件性不足。從系統架構來看,資料庫很容易成為整個系統的瓶頸。閱讀和寫作都必須在上面。由於沒有水平擴充套件,RDBMS 的問題更加嚴重。


基於任務的單體
為了解決上述傳統單體所遇到的問題,這裡我們嘗試引入域的概念。

從單體架構轉向CQRS - Wu
此圖與上圖基本相同。唯一的區別是將 DTO 替換為寫入路徑上的訊息。訊息包含操作和資料,而不僅僅是像 DTO 這樣的資料本身。因此,我們可以在訊息中攜帶特定領域的動作,使後端更容易識別每個動作,並有相應的領域實現。

在這個階段,C inCQRS已經出現,message 是一種命令。但是,可擴充套件性的問題仍然沒有解決。

另外,雖然我們簡化了DTO,改為使用訊息進行通訊,但在讀取路徑上仍然需要DTO。讓我們再次以社交媒體為例。修改暱稱時,訊息的格式可能是{"rename": "LazyDr"}. 但是在渲染配置檔案時,我們仍然需要額外的資訊,例如活動。這種資訊差距使得有必要在讀取路徑上進行大量處理以檢索 DTO。

CQS(命令查詢分段)
CQS的出現就是為了解決上述Read/Write Splitting的痛點。
讀取時,客戶端需要DTO,所以後端可以在讀取路徑上做一些專門用於讀取的優化,比如從原始域物件預先生成DTO,將DTO儲存在專用資料庫中進行讀取。

從單體架構轉向CQRS - Wu
這樣,在讀路徑上,應用服務的實現就變得更簡單了。應用服務可以變成一個瘦讀層,只需要負責分頁、排序等工作,客戶端請求之後,就可以很方便的從資料庫中取回DTO。

所以問題是,誰來生成這些預先構建的 DTO?這是寫路徑的責任。

從單體架構轉向CQRS - Wu
雖然圖和之前看到的例子差不多,但其實應用服務除了要持久化領域物件外,還必須要持久化DTO。也就是說,大部分業務邏輯都會壓在寫路徑上,需要準備各種讀檢視。

在這個階段,我們已經解決了領域遇到的大部分問題,但是縮放仍然沒有解決方案。現在,我們進一步定義縮放。縮放有兩個不同的方面。

  1. 流量:寫入量增加。
  2. 擴充套件:功能需求增加,比如需要各種不同的讀取檢視。繼續以社交媒體為例,個人資料上有一個簡報,但時間軸上可能有另一個簡報。


CQRS
為什麼寫路徑負責準備讀檢視?寫應該關注持久化,那些各種讀檢視不應該在寫路徑上處理。但是讀取路徑上只有讀取,誰應該準備那些讀取檢視?
因此,整體解決方案如下。

從單體架構轉向CQRS - Wu
左邊的寫路徑和右邊的讀路徑已經在CQS部分介紹過了。唯一的區別是新增了一個finally塊,負責將寫路徑上的資料庫轉換成讀路徑上使用的資料庫。一旦涉及到資料同步,就有可能遇到資料一致性問題,所以這裡列出了幾種實現最終一致性的方法,按照耗時從短到長排序:

  1. 後臺執行緒:典型代表是Redis。資料寫入主節點後,Redis 會立即將資料傳送到後臺的副本。
  2. 訊息佇列加工作者:這是非同步資料複製的常見做法。寫入資料庫時​​,會在訊息佇列中啟動一個事件並由工作人員處理。
  3. Extract-Transform-Load:這個時間間隔最長,從幾分鐘到幾小時不等。使用 map-reduce 或其他方法將結果寫入另一端。


無論採用哪種方法,唯一的事實來源都是強制性的。也就是說,如果轉換髮生任何故障,系統必須能夠恢復未完成的工作。因此,資料必須是唯一且可靠的。

資料通常分為兩種型別,
  1. state:state 是指你此刻看到的,比如銀行存摺上寫的餘額。
  2. 事件:事件是修改每個狀態的動作,例如銀行存摺上的每筆交易記錄


實際上,我們已經有了可以儲存為事件的訊息。對於寫路徑,按順序儲存訊息是非常有效的。通過每條不同的訊息,您可以根據需要輕鬆構建不同的閱讀檢視。這種方法也稱為事件溯源

但是隻有事件很難有效地使用。為了獲得最終結果,每次轉換都必須從頭到尾執行以重建讀取檢視。因此,混合方法將是理想的。在寫路徑上,狀態和事件都保留,轉換過程可以根據實際情況選擇資料來源。
總結一下CQRS中資料的整個生命週期。

從單體架構轉向CQRS - Wu
資料從客戶端開始,然後以命令格式進入後端。根據業務邏輯,將其轉換為領域物件並儲存在資料庫中。這些領域物件被轉換成各種讀取檢視,並根據需要儲存在不同的讀取專用資料庫中。最後,客戶端將這些讀取檢視以 DTO 的形式取回。

結論
有許多書籍和文章描述了具有多種模式的 DDD 和 CQRS。在我看來,這些模式限制了實體、值物件、聚合等 DDD 的想象力。這導致大多數開發人員覺得 DDD 離自己很遠,很難實現和實現。實際上,DDD 的概念並沒有那麼複雜;相反,DDD被提出來封裝業務邏輯,然後方便擴充套件功能需求。

CQRS 更簡單。在這篇文章中,我們從系統演化的過程入手,瞭解整個系統設計過程和要解決的問題,最後自然得出CQRS的結論。
系統設計中沒有靈丹妙藥。每次進化都是為了解決一些特定的問題,然而,它可能會出現一個新的問題。以本文的設計過程為例,CQRS 似乎解決了所有提到的問題,模型貧乏,可擴充套件性不足,但實際上 CQRS 也帶來了新的問題,比如資料一致性。每個技術選擇都有它的取捨,只要瞭解每個選項背後的所有威脅,就可以選擇相對可接受的方法。

即使你選擇了 CQRS,在實踐中,實現最終一致性仍然有三種選擇。系統設計是不斷選擇的結果。
這篇文章的目的是告訴你,DDD 沒有那麼可怕,CQRS 也沒有那麼複雜,它只是一個決定。

相關文章