0%

淺談 CQRS

前言

之前專案就使用過 CQRS 做為系統的架構來開發,但一直覺得自己『知其然,而不知其所以然』,剛好最近出了一本CQRS 命令查詢職責分離模式 所以就買回來把他讀完了,趁記憶還很清晰時趕快做一下筆記,以下的內容參雜了許多自己的見解,歡迎大家一起討論

什麼是 CQRS

CQRS 全名為 Command Query Responsibility Segregation ,意即命令查詢分離的設計模式,而

  • Command : 會對系統狀態或資料做出異動的行為
  • Query : 單純取得資料不會對系統狀態造成異動的行為

以傳統 CRUD 類比的話,CUD = Command,R = Query

為什麼需要 CQRS

降低複雜度

我自己接觸過的大部分系統,大多都是以維護資料做為出發點來設計的系統,例如專案內一定會有所謂的 Repository Layer : 一個提供各種 API 並能對資料做 CRUD 的封裝層。
系統本來就是一系列對資料維護的過程,這樣做有錯嗎? 只要商業情境不要太複雜的話,大部分都沒什麼問題,但如果系統規模越做越大,商業情境越來越複雜,如果只是用 CRUD 的思維來設計系統,往往會讓複雜度越疊越高,甚至到不可收拾的地步。
舉個例子,你應該看過這樣的 API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void Update(MemberDto member)
{
var memberEntity = MemberRepository.Get(member.Id);
if(memberEntity == null)
throw new NullReferenceException("Member not found.");

if(string.IsNullOrEmpty(member.Name))
throw new ArgumentNullException(nameof(member.Name));

if(DateTime.TryParse(member.Birthday, out var birthday) == false)
throw new ArgumentException(nameof(member.Birthday));

//bla bla bla ....

if(string.IsNullOrEmpty(member.Password))
throw new ArgumentNullException(nameof(member.Password));

// Set Entity ...

memberEntity.Password = member.Password;

MemberRepository.Update(memberEntity);
}

這個 API 可能包含了對 Member 資料的各種邏輯驗證,而呼叫這個 API 的你可能只是為了完成密碼變更而已,這份 Code 硬要說沒什麼大問題,但也體現了從 CRUD 角度設計出的 API 可能隨著商業邏輯的日益複雜,複雜度可能會成長到非常可怕的地步,而這也是 CQRS 中想避免的事情。

Command 會這樣做

1
2
3
4
5
6
7
8
9
10
11
public void Handle(ResetPasswordCommand command)
{
var member = MemberRepository.Get(command.Id);
if(memberEntity == null)
throw new NullReferenceException("Member not found.");

//領域模型知道 ResetPassowrd 的相關驗證與知識
member.ResetPassword(command.Password);

MemberRepository.Update(member);
}

1. 貼近現實世界的用語

PO 會對我們說需要會員註冊的功能,而不會說需要新增會員的功能,而通常更換密碼忘記密碼會是完全不一樣的流程,用 UpdatePassword 很難精準說明這裡面做了什麼,你可能需要翻程式碼看流程,才能說出這個方法內包含了哪些商業情境,而這正是 Command 想避免的。

2. 貼近商業邏輯

一個 Command 只做一件事情,而 Command 所執行的流程跟邏輯應符合真實流程與情境

3. Command 內盡量不要再串其他 Command

Command 通常會對應觸發系統 Event,當 Command 串 Command 時,後續的連鎖反應容易失控,盡量讓 Command 就是獨立完成一個商業邏輯。

也許看到這邊會覺得這好像有點 DDD 的影子,定義領域模型並封裝領域知識,外部透過 Command 觸發領域模型做事,就我目前的認識,的確 DDD 通常會採用 CQRS 的設計模式來實作,但 CQRS 卻不一定要實作 DDD,不過 DDD 我還是初學者就不獻醜了。

提升效能

系統都希望提供各種角度的搜尋來滿足分析與功能需求,而這會加重資料庫的運算資源短缺問題,通常下一步就會開始將資料庫做讀寫分離的拆解,但最終逃離不了 Table Schema 需要同一套的束縛,而這個束縛在運算或是資料到達某個量級時就很難再往上提升了。

搜尋、寫入最佳化是個兩難的問題,搜尋要快通常要對 Index 設計下一些功夫,而偏偏 Index 越多寫入越慢,又或是 Table Schema 適度的反正規化對搜尋較為友善,但偏偏這會讓寫入資料維護時變得異常麻煩;反過來看,寫入要快需要盡量只寫入該寫入或該更新的資料,而正規化反而是有利於寫入的情境。當讀寫都對同一個 DB 或是同一張 Table 時這兩邊的平衡往往會讓 RD 抓狂,而 CQRS 正是能解決的問題的很好解決方案。
/images/20210815/cqrs.png
CQRS 允許 Command 與 Query 可以是 Table 等級的隔離也可以是資料庫的實體隔離,更提供針對 Command 與 Query 各自選用最佳方案的資料庫,例如:Command 選用 RDB 而 Query 選擇 NoSQL 甚至是 Elasticsearch 這種重量級搜尋服務來滿足,兩邊資料則透過 Event Sync 的方式來同步。
通常系統寫入與查詢的量也是不成比例的,一般系統大部分都在應付各種查詢,而寫入可能只有讀取不到 10 分之 1 的量,CQRS 是有辦法單獨對 Query DB 做橫向擴充,而這在傳統系統架構上是很難做到的一點。

既然這麼好,我是否該每個系統都這樣設計

有優點就有缺點,而 CQRS 的缺點是對於開發的難度提升了不止一個檔次,等等!上面不是說為了簡化才選擇 CQRS,這邊又說開發變困難,筆者態度前後不一啦(怒噴兩萬字)

先冷靜聽我說,CQRS 是想讓你的程式碼符合商業情境,並盡量符合 Single responsibility principle,這是 Code Level 的簡化,但架構卻變得更為複雜,第一你得面對兩邊資料庫的選擇問題,就算退一步說,我系統量不大,選擇同一座資料庫切 Table 可以吧,但 Command 與 Query 就是會有同步時間差,資料只能做到最終一致性,而你準備好面對這樣的改變了嗎?你應該不會想要採用 CQRS ,但抄寫兩張 Table 卻用同步的作法,這樣用單張 Table 採用 CRUD 的方式還更快一些。

當你的開始對 Command / Query 各自選用最佳的資料庫解決方案時,Event Sync 抄寫資料的實作想好解決方案了嗎?中間是透過 Queue 實作 Pub / Sub 同步嗎? 資料同步延遲的 SLO 定好了嗎? Consumer 同步速度跟不上寫入發過來的 Event 時,橫向擴充的機制是否想好了?

當開始要面對最終一致性時,不單單只是系統層面的調整,有時甚至從需求到 RD 觀念都是需要做些改變,現實世界最終一致性例子比比皆是,但在過去的大單體時代強一致性才是顯學,如今系統需要乘載的量體已經不可同日而語,除非有革命性的物理突破,不然我們都要開始習慣這個改變

講這麼多,那到底怎樣才需要用到 CQRS 實作系統? 我的建議是如果這個系統流量資料量預期成長不大,或需求不太會隨著商業情境一直改變的,例如後台系統,這種真的套個 Template 用 CRUD 簡單搞定就好,引入 CQRS 只會增加複雜度且不會有多大效益的,反之如果這個系統預期未來會持續成長,商業需求也會一直調整,那我會建議可以考慮引入 CQRS,但在前期可以選擇單一資料庫切 Table 的方式就好,等到真的量開始起來時再開始 Migration 都還來得及。