2023年11月14日
CQRS 實戰:把讀取交給 Elasticsearch,寫入留給關聯式資料庫
讀和寫,其實是兩種完全不同的需求
以房仲網站的「物件」為例:
| 寫入(Command) | 讀取(Query) | |
|---|---|---|
| 誰在做 | 房仲經紀人在後台刊登、改價、下架 | 一般使用者搜尋、瀏覽、看地圖 |
| 量 | 少 | 多出好幾個數量級 |
| 在乎什麼 | 正確:權限、商業規則、資料完整性 | 快:多條件篩選、全文搜尋、地理範圍 |
| 資料的形狀 | 正規化:物件、社區、店鋪、照片分表存放 | 反正規化:一次拿到列表要顯示的所有欄位 |
當讀寫共用同一個資料庫、同一組資料表時,兩邊會互相拖累:為了讓搜尋變快加了一堆索引,寫入就變慢;一個列表查詢要 JOIN 五張表,再疊上 LIKE '%關鍵字%' 和範圍條件,資料量一大就要好幾秒;為了撐住查詢加了應用程式快取,資料更新後快取又不知道什麼時候才會過期。
CQRS:讀寫分開,各自最佳化
CQRS(Command Query Responsibility Segregation,命令查詢職責分離) 的想法很簡單:既然讀寫的需求不同,就用兩個不同的模型來處理。
- 寫入端(Command Side):處理「改變狀態」的請求,負責驗證商業規則,資料存放在關聯式資料庫,維持正規化與交易一致性。Command 不回傳查詢資料,頂多回傳成功與否、新建立的 ID
- 讀取端(Query Side):處理「查資料」的請求,不改變任何狀態。資料存放在為查詢最佳化的儲存體——在房仲網站的情境裡就是 Elasticsearch
- 同步機制:寫入端完成變更後發出事件,由 Projector 接收事件,把資料轉換成讀取模型需要的形狀,寫進 Elasticsearch
CQRS 常和 Event Sourcing 一起被提到,但兩者是獨立的概念。Event Sourcing 是「寫入端不存目前的狀態,而是存一連串的事件,狀態由事件重播得出」。CQRS 不一定要搭配 Event Sourcing,寫入端用一般的資料表存目前狀態,再另外發出事件同步給讀取端,就已經是 CQRS。
讀取模型怎麼設計:為畫面而生
讀取模型的設計原則和資料庫正規化剛好相反:畫面需要什麼,文件就長什麼樣子。
物件列表上的一張卡片要顯示:物件標題、總價、坪數、房數、社區名稱、店鋪名稱、主照片、經緯度。在寫入端,這些資料分散在物件、社區、店鋪、照片四張表;在讀取端,它們被預先組好放進同一份文件:
{
"id": "H-102938",
"title": "信義區三房車位 近捷運",
"price": 2380,
"area": 34.5,
"rooms": 3,
"community": { "id": "C-551", "name": "信義 A 社區" },
"store": { "id": "S-077", "name": "信義松仁店" },
"coverImage": "https://img.example.com/H-102938/1.jpg",
"location": { "lat": 25.0330, "lon": 121.5654 },
"updatedAt": "2023-03-20T08:15:00Z"
}
查詢時不需要任何 JOIN,一次 Elasticsearch 查詢就能拿到列表的所有資料,還能同時做全文搜尋、多條件篩選與地理範圍查詢。
代價是資料重複:社區名稱同時存在於每一筆物件文件裡。社區改名時,Projector 要找出這個社區底下的所有物件,一起更新。這是讀取模型用「寫入時多做事」換取「讀取時少做事」的典型取捨——而在讀多寫少的系統裡,這筆交易非常划算。
同一份寫入資料,也可以投影出好幾種讀取模型:物件搜尋用的索引、社區行情彙整用的索引、地圖用的精簡索引,各自為不同的查詢最佳化。
最終一致性:寫完不一定馬上讀得到
讀寫分開之後,最大的改變是:寫入成功的那一刻,讀取端還沒更新。事件從發出、經過訊息佇列、到 Projector 寫進 Elasticsearch,中間通常是毫秒到秒級的延遲,這就是最終一致性(Eventual Consistency)。
大部分情況下使用者感覺不到這個延遲,但有幾個地方要特別處理:
- 經紀人改完價格,馬上回到列表:看到的還是舊價格,會以為沒改成功。常見做法是寫入成功後,前端直接用送出的資料更新畫面,而不是重新查詢;或是後台的「我的物件」頁面直接查寫入端,不走讀取模型
- 需要強一致的檢查不能查讀取端:例如「這個經紀人還有沒有剩餘刊登額度」,一定要在寫入端、用交易內的資料判斷,不能查 Elasticsearch 的結果
- Projector 要冪等:同一個事件可能被投遞兩次,以物件 ID 為文件 ID 做 Upsert,處理幾次結果都一樣
- 事件的順序:同一個物件的「改價」和「下架」如果順序顛倒,讀取端可能出現已下架的物件又被寫回去。可以在文件中保存版本號或更新時間,Projector 只接受比目前更新的事件
什麼時候不該用 CQRS
CQRS 會讓系統多出一套儲存體、一條同步管線、一種一致性問題,並不是每個系統都需要:
- 讀寫比例差不多、查詢也不複雜:一般的後台 CRUD 系統,用同一個資料庫加上適當的索引就夠了
- 團隊還沒有處理非同步、最終一致性的經驗:同步管線出問題時,讀取端的資料會默默地和寫入端不一致,需要監控與重建的機制
- 業務要求「寫完立刻讀到」的地方很多:每一個地方都要特別處理,複雜度會快速上升
比較務實的做法是只在真正需要的地方用:房仲網站的物件搜尋、列表、地圖是典型的讀多寫少、查詢複雜,非常適合;但會員資料、後台設定這種單純的 CRUD,就留在原本的資料庫即可。
使用情境:房仲平台的物件搜尋
在房仲平台的重構中,物件、成交行情、社區三大頻道的查詢原本都直接打 MSSQL,再靠每個站台約 10G 的記憶體快取撐住流量。導入 CQRS 之後:
- 物件的刊登、改價、下架走寫入端,資料寫入 MSSQL 後,由同步排程偵測異動並發出事件
- 事件經由 RabbitMQ 送到 Projector,組成反正規化的文件寫進 Elasticsearch
- 前台的搜尋、列表、地圖全部改查 Elasticsearch,不再需要應用程式內的記憶體快取
複雜條件的查詢從 20 秒降到 1.5 秒,移除記憶體快取後,每個站台的記憶體用量也從 10G 降到 800MB 左右。
一句話總結
CQRS 把「改變狀態」和「查詢資料」拆成兩個模型:寫入端用關聯式資料庫守住正確性,讀取端用為畫面量身打造的反正規化文件換取速度,兩者之間靠事件非同步同步;代價是最終一致性與更多的維運成本,所以只用在讀多寫少、查詢複雜、真正需要它的地方。