[{"data":1,"prerenderedAt":536},["ShallowReactive",2],{"article-\u002Farticles\u002Fcqrs-elasticsearch-read-model":3},{"id":4,"title":5,"body":6,"date":521,"description":188,"excerpt":522,"extension":523,"meta":524,"navigation":525,"path":526,"seo":527,"stem":528,"tags":529,"__hash__":535},"articles\u002Farticles\u002Fcqrs-elasticsearch-read-model.md","CQRS 實戰：把讀取交給 Elasticsearch，寫入留給關聯式資料庫",{"type":7,"value":8,"toc":512},"minimark",[9,14,18,91,99,103,113,120,154,164,168,175,182,384,387,394,401,405,415,418,444,448,451,471,478,482,485,499,502,505,508],[10,11,13],"h2",{"id":12},"讀和寫其實是兩種完全不同的需求","讀和寫，其實是兩種完全不同的需求",[15,16,17],"p",{},"以房仲網站的「物件」為例：",[19,20,21,36],"table",{},[22,23,24],"thead",{},[25,26,27,30,33],"tr",{},[28,29],"th",{},[28,31,32],{},"寫入（Command）",[28,34,35],{},"讀取（Query）",[37,38,39,51,62,80],"tbody",{},[25,40,41,45,48],{},[42,43,44],"td",{},"誰在做",[42,46,47],{},"房仲經紀人在後台刊登、改價、下架",[42,49,50],{},"一般使用者搜尋、瀏覽、看地圖",[25,52,53,56,59],{},[42,54,55],{},"量",[42,57,58],{},"少",[42,60,61],{},"多出好幾個數量級",[25,63,64,67,74],{},[42,65,66],{},"在乎什麼",[42,68,69,73],{},[70,71,72],"strong",{},"正確","：權限、商業規則、資料完整性",[42,75,76,79],{},[70,77,78],{},"快","：多條件篩選、全文搜尋、地理範圍",[25,81,82,85,88],{},[42,83,84],{},"資料的形狀",[42,86,87],{},"正規化：物件、社區、店鋪、照片分表存放",[42,89,90],{},"反正規化：一次拿到列表要顯示的所有欄位",[15,92,93,94,98],{},"當讀寫共用同一個資料庫、同一組資料表時，兩邊會互相拖累：為了讓搜尋變快加了一堆索引，寫入就變慢；一個列表查詢要 JOIN 五張表，再疊上 ",[95,96,97],"code",{},"LIKE '%關鍵字%'"," 和範圍條件，資料量一大就要好幾秒；為了撐住查詢加了應用程式快取，資料更新後快取又不知道什麼時候才會過期。",[10,100,102],{"id":101},"cqrs讀寫分開各自最佳化","CQRS：讀寫分開，各自最佳化",[15,104,105,108,109,112],{},[70,106,107],{},"CQRS（Command Query Responsibility Segregation，命令查詢職責分離）"," 的想法很簡單：既然讀寫的需求不同，就",[70,110,111],{},"用兩個不同的模型","來處理。",[15,114,115],{},[116,117],"img",{"alt":118,"src":119},"CQRS 架構示意圖：寫入端由 Command Handler 驗證商業規則後寫入 MSSQL 正規化的寫入模型並產生事件；Projector 非同步接收事件，組出反正規化的文件寫入 Elasticsearch 讀取模型；讀取端由 Query Handler 查詢 Elasticsearch 回傳搜尋、列表與地圖資料","\u002Fimages\u002Farticles\u002Fcqrs-read-model.svg",[121,122,123,134,144],"ul",{},[124,125,126,129,130,133],"li",{},[70,127,128],{},"寫入端（Command Side）","：處理「改變狀態」的請求，負責驗證商業規則，資料存放在關聯式資料庫，維持正規化與交易一致性。Command ",[70,131,132],{},"不回傳查詢資料","，頂多回傳成功與否、新建立的 ID",[124,135,136,139,140,143],{},[70,137,138],{},"讀取端（Query Side）","：處理「查資料」的請求，",[70,141,142],{},"不改變任何狀態","。資料存放在為查詢最佳化的儲存體——在房仲網站的情境裡就是 Elasticsearch",[124,145,146,149,150,153],{},[70,147,148],{},"同步機制","：寫入端完成變更後發出事件，由 ",[70,151,152],{},"Projector"," 接收事件，把資料轉換成讀取模型需要的形狀，寫進 Elasticsearch",[155,156,157],"blockquote",{},[15,158,159,160,163],{},"CQRS 常和 ",[70,161,162],{},"Event Sourcing"," 一起被提到，但兩者是獨立的概念。Event Sourcing 是「寫入端不存目前的狀態，而是存一連串的事件，狀態由事件重播得出」。CQRS 不一定要搭配 Event Sourcing，寫入端用一般的資料表存目前狀態，再另外發出事件同步給讀取端，就已經是 CQRS。",[10,165,167],{"id":166},"讀取模型怎麼設計為畫面而生","讀取模型怎麼設計：為畫面而生",[15,169,170,171,174],{},"讀取模型的設計原則和資料庫正規化剛好相反：",[70,172,173],{},"畫面需要什麼，文件就長什麼樣子","。",[15,176,177,178,181],{},"物件列表上的一張卡片要顯示：物件標題、總價、坪數、房數、社區名稱、店鋪名稱、主照片、經緯度。在寫入端，這些資料分散在物件、社區、店鋪、照片四張表；在讀取端，它們被",[70,179,180],{},"預先組好","放進同一份文件：",[183,184,189],"pre",{"className":185,"code":186,"language":187,"meta":188,"style":188},"language-json shiki shiki-themes github-dark","{\n  \"id\": \"H-102938\",\n  \"title\": \"信義區三房車位 近捷運\",\n  \"price\": 2380,\n  \"area\": 34.5,\n  \"rooms\": 3,\n  \"community\": { \"id\": \"C-551\", \"name\": \"信義 A 社區\" },\n  \"store\": { \"id\": \"S-077\", \"name\": \"信義松仁店\" },\n  \"coverImage\": \"https:\u002F\u002Fimg.example.com\u002FH-102938\u002F1.jpg\",\n  \"location\": { \"lat\": 25.0330, \"lon\": 121.5654 },\n  \"updatedAt\": \"2023-03-20T08:15:00Z\"\n}\n","json","",[95,190,191,200,217,230,243,256,269,300,326,339,367,378],{"__ignoreMap":188},[192,193,196],"span",{"class":194,"line":195},"line",1,[192,197,199],{"class":198},"s95oV","{\n",[192,201,203,207,210,214],{"class":194,"line":202},2,[192,204,206],{"class":205},"sDLfK","  \"id\"",[192,208,209],{"class":198},": ",[192,211,213],{"class":212},"sU2Wk","\"H-102938\"",[192,215,216],{"class":198},",\n",[192,218,220,223,225,228],{"class":194,"line":219},3,[192,221,222],{"class":205},"  \"title\"",[192,224,209],{"class":198},[192,226,227],{"class":212},"\"信義區三房車位 近捷運\"",[192,229,216],{"class":198},[192,231,233,236,238,241],{"class":194,"line":232},4,[192,234,235],{"class":205},"  \"price\"",[192,237,209],{"class":198},[192,239,240],{"class":205},"2380",[192,242,216],{"class":198},[192,244,246,249,251,254],{"class":194,"line":245},5,[192,247,248],{"class":205},"  \"area\"",[192,250,209],{"class":198},[192,252,253],{"class":205},"34.5",[192,255,216],{"class":198},[192,257,259,262,264,267],{"class":194,"line":258},6,[192,260,261],{"class":205},"  \"rooms\"",[192,263,209],{"class":198},[192,265,266],{"class":205},"3",[192,268,216],{"class":198},[192,270,272,275,278,281,283,286,289,292,294,297],{"class":194,"line":271},7,[192,273,274],{"class":205},"  \"community\"",[192,276,277],{"class":198},": { ",[192,279,280],{"class":205},"\"id\"",[192,282,209],{"class":198},[192,284,285],{"class":212},"\"C-551\"",[192,287,288],{"class":198},", ",[192,290,291],{"class":205},"\"name\"",[192,293,209],{"class":198},[192,295,296],{"class":212},"\"信義 A 社區\"",[192,298,299],{"class":198}," },\n",[192,301,303,306,308,310,312,315,317,319,321,324],{"class":194,"line":302},8,[192,304,305],{"class":205},"  \"store\"",[192,307,277],{"class":198},[192,309,280],{"class":205},[192,311,209],{"class":198},[192,313,314],{"class":212},"\"S-077\"",[192,316,288],{"class":198},[192,318,291],{"class":205},[192,320,209],{"class":198},[192,322,323],{"class":212},"\"信義松仁店\"",[192,325,299],{"class":198},[192,327,329,332,334,337],{"class":194,"line":328},9,[192,330,331],{"class":205},"  \"coverImage\"",[192,333,209],{"class":198},[192,335,336],{"class":212},"\"https:\u002F\u002Fimg.example.com\u002FH-102938\u002F1.jpg\"",[192,338,216],{"class":198},[192,340,342,345,347,350,352,355,357,360,362,365],{"class":194,"line":341},10,[192,343,344],{"class":205},"  \"location\"",[192,346,277],{"class":198},[192,348,349],{"class":205},"\"lat\"",[192,351,209],{"class":198},[192,353,354],{"class":205},"25.0330",[192,356,288],{"class":198},[192,358,359],{"class":205},"\"lon\"",[192,361,209],{"class":198},[192,363,364],{"class":205},"121.5654",[192,366,299],{"class":198},[192,368,370,373,375],{"class":194,"line":369},11,[192,371,372],{"class":205},"  \"updatedAt\"",[192,374,209],{"class":198},[192,376,377],{"class":212},"\"2023-03-20T08:15:00Z\"\n",[192,379,381],{"class":194,"line":380},12,[192,382,383],{"class":198},"}\n",[15,385,386],{},"查詢時不需要任何 JOIN，一次 Elasticsearch 查詢就能拿到列表的所有資料，還能同時做全文搜尋、多條件篩選與地理範圍查詢。",[15,388,389,390,393],{},"代價是",[70,391,392],{},"資料重複","：社區名稱同時存在於每一筆物件文件裡。社區改名時，Projector 要找出這個社區底下的所有物件，一起更新。這是讀取模型用「寫入時多做事」換取「讀取時少做事」的典型取捨——而在讀多寫少的系統裡，這筆交易非常划算。",[15,395,396,397,400],{},"同一份寫入資料，也可以",[70,398,399],{},"投影出好幾種讀取模型","：物件搜尋用的索引、社區行情彙整用的索引、地圖用的精簡索引，各自為不同的查詢最佳化。",[10,402,404],{"id":403},"最終一致性寫完不一定馬上讀得到","最終一致性：寫完不一定馬上讀得到",[15,406,407,408,411,412,174],{},"讀寫分開之後，最大的改變是：",[70,409,410],{},"寫入成功的那一刻，讀取端還沒更新","。事件從發出、經過訊息佇列、到 Projector 寫進 Elasticsearch，中間通常是毫秒到秒級的延遲，這就是",[70,413,414],{},"最終一致性（Eventual Consistency）",[15,416,417],{},"大部分情況下使用者感覺不到這個延遲，但有幾個地方要特別處理：",[121,419,420,426,432,438],{},[124,421,422,425],{},[70,423,424],{},"經紀人改完價格，馬上回到列表","：看到的還是舊價格，會以為沒改成功。常見做法是寫入成功後，前端直接用送出的資料更新畫面，而不是重新查詢；或是後台的「我的物件」頁面直接查寫入端，不走讀取模型",[124,427,428,431],{},[70,429,430],{},"需要強一致的檢查不能查讀取端","：例如「這個經紀人還有沒有剩餘刊登額度」，一定要在寫入端、用交易內的資料判斷，不能查 Elasticsearch 的結果",[124,433,434,437],{},[70,435,436],{},"Projector 要冪等","：同一個事件可能被投遞兩次，以物件 ID 為文件 ID 做 Upsert，處理幾次結果都一樣",[124,439,440,443],{},[70,441,442],{},"事件的順序","：同一個物件的「改價」和「下架」如果順序顛倒，讀取端可能出現已下架的物件又被寫回去。可以在文件中保存版本號或更新時間，Projector 只接受比目前更新的事件",[10,445,447],{"id":446},"什麼時候不該用-cqrs","什麼時候不該用 CQRS",[15,449,450],{},"CQRS 會讓系統多出一套儲存體、一條同步管線、一種一致性問題，並不是每個系統都需要：",[121,452,453,459,465],{},[124,454,455,458],{},[70,456,457],{},"讀寫比例差不多、查詢也不複雜","：一般的後台 CRUD 系統，用同一個資料庫加上適當的索引就夠了",[124,460,461,464],{},[70,462,463],{},"團隊還沒有處理非同步、最終一致性的經驗","：同步管線出問題時，讀取端的資料會默默地和寫入端不一致，需要監控與重建的機制",[124,466,467,470],{},[70,468,469],{},"業務要求「寫完立刻讀到」的地方很多","：每一個地方都要特別處理，複雜度會快速上升",[15,472,473,474,477],{},"比較務實的做法是",[70,475,476],{},"只在真正需要的地方用","：房仲網站的物件搜尋、列表、地圖是典型的讀多寫少、查詢複雜，非常適合；但會員資料、後台設定這種單純的 CRUD，就留在原本的資料庫即可。",[10,479,481],{"id":480},"使用情境房仲平台的物件搜尋","使用情境：房仲平台的物件搜尋",[15,483,484],{},"在房仲平台的重構中，物件、成交行情、社區三大頻道的查詢原本都直接打 MSSQL，再靠每個站台約 10G 的記憶體快取撐住流量。導入 CQRS 之後：",[121,486,487,490,493],{},[124,488,489],{},"物件的刊登、改價、下架走寫入端，資料寫入 MSSQL 後，由同步排程偵測異動並發出事件",[124,491,492],{},"事件經由 RabbitMQ 送到 Projector，組成反正規化的文件寫進 Elasticsearch",[124,494,495,496],{},"前台的搜尋、列表、地圖全部改查 Elasticsearch，",[70,497,498],{},"不再需要應用程式內的記憶體快取",[15,500,501],{},"複雜條件的查詢從 20 秒降到 1.5 秒，移除記憶體快取後，每個站台的記憶體用量也從 10G 降到 800MB 左右。",[10,503,504],{"id":504},"一句話總結",[15,506,507],{},"CQRS 把「改變狀態」和「查詢資料」拆成兩個模型：寫入端用關聯式資料庫守住正確性，讀取端用為畫面量身打造的反正規化文件換取速度，兩者之間靠事件非同步同步；代價是最終一致性與更多的維運成本，所以只用在讀多寫少、查詢複雜、真正需要它的地方。",[509,510,511],"style",{},"html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":188,"searchDepth":202,"depth":202,"links":513},[514,515,516,517,518,519,520],{"id":12,"depth":202,"text":13},{"id":101,"depth":202,"text":102},{"id":166,"depth":202,"text":167},{"id":403,"depth":202,"text":404},{"id":446,"depth":202,"text":447},{"id":480,"depth":202,"text":481},{"id":504,"depth":202,"text":504},"2023-11-14",null,"md",{},true,"\u002Farticles\u002Fcqrs-elasticsearch-read-model",{"title":5,"description":188},"articles\u002Fcqrs-elasticsearch-read-model",[530,531,532,533,534],"CQRS","Elasticsearch","系統設計","架構設計","事件驅動","c6stcHJhct1IxbJJnyTkWRksdntS8Ct4qtFSdlIpaRM",1792959150487]