l

2022年7月13日 星期三

事件溯源(16):分散式系統的事件語意與Idempotent

July 6 23:14~24:00;July 7 00:00~01:39

▲快寫到體力不支了 XD

 

前言

鄉民們之所以要採用Event Sourcing與CQRS,很多情況都是為了開發微服務。微服務架構屬於分散式系統,相較於集中式系統,分散式系統具有異質性、容易擴展、比較強健(robust)、容錯性高且系統模組之間的耦合性較低等特性。但相對地,分散式系統的開發也比較複雜且一不小心就容易「出錯」。

在DDD中,Aggregate之間的狀態透過領域事件達到最終一致性(eventual consistency)。相似地,在微服務架構下,各個微服務之間的狀態同步也是透過事件或是訊息達到最終一致性。但在分散式系統中,事件傳遞本身也可能發生遺失,導致接收者收不到事件,也就做不到最終一致性。

本集介紹在分散式系統中為了正確做到最終一致性,事件傳遞與事件處理器(event handler)須具備那些特性。

 

***

保證事件的順序

首先,事件本身的順序不能亂掉,否則事件接收者的狀態一定會出錯。當事件被寫入Event Store的當下,Event Store本身必須保證在同一個event stream裡面事件必須依據發生(寫入時間點)的先後順序排序,這點基本上沒有問題。看到這裡鄉民們可能會想:「事件在Event Store中既然已經排序過,為什麼還會發生順序亂掉的情況?」

請參考圖1,事件在Event Store中一開始順序是正確的,假設這個事件是ezKanban裡面User Management Bounded Context所產生的事件,像是UserCreated、UserRenamed、UserEmailChanged等。在ezKanban中,Kanban Board Bounded Context(ezKanban的Core Domain)需要在畫面上顯示使用者名稱,所以它會聽UserCreated、UserRenamed等領域事件,然後在自己本地端建立一份User資料的快取。因此,User Management Bounded Context會把內部的UserCreated這些領域事件往外傳,寫到Pulsar的Topic A裡面。

假設在Kanban Board Bounded Context裡面,為了「求快」啟動兩隻consumer(event handler)程式同時間去讀取Topic A的資料。這種consumer在訊息導向架構中稱為Competing Consumer(競爭消費者),它們會搶著處理Topic裡面的資料。圖1中的Consumer A和Consumer B從Topic A處理完事件之後會寫另外一筆事件到Topic B。現在Consumer A拿到了1和3這兩個訊息,Consumer B拿到了2和4這兩個訊息,因為它們執行的速度不同,所以處理完之後最後Topic B裡面事件的順序變成2, 1, 4, 3,和原始事件產生的順序不同。



 ▲圖1:Event out of order示意圖

 

如果今天Topic A存放的是轉檔需求,而Topic B存放的是轉檔完成的結果,在這種情況下通常來說事件順序並不重要。但是如果是要透過Event Broker傳遞事件然後希望接收者達到最終一致性,就要注意在事件傳遞的過程中,不要不小心造成事件順序亂掉的情況。

***

至少一次(At least once)

除了事件順序不能錯,另一個關於事件傳遞的要求,就是事件要保證至少會被接收者看到一次(at least once)

為什麼要這麼麻煩?怎麼不規定恰好一次(exactly once)就好?因為做不到。請參考圖2,Consumer把Event 1從Topic A讀出之後,它事情還沒做完就當掉。Consumer重啟之後,因為Event 1已經被讀走,Topic A裡面最新的事件變成Event 2。但是Event 1還沒被Consumer處理,也就是說Event 1從此就從地球上消失。

 

▲圖2:Consumer讀取事件之後,事情還沒做完就當掉

 

***

要解決事件消失的問題也很簡單,就是Consumer讀出事件之後,Topic不會立刻把事件刪除,一直到Consumer處理完畢並通知(ack)Topic,此時Topic才會把事件刪除,如圖3所示。在圖3中,情況1,Consumer讀出事件1後立刻當掉。但因為Consumer沒有ack,所以事件1還存在Topic A。情況2當Consumer重啟之後,又可以讀到一次事件1(這就是at least once,至少一次,至多不限)。情況3當Consumer執行完畢並且ack,事件1才會從Topic A移除。


▲圖3:Consumer通知Topic之後被讀出的事件才會從Topic刪除

 

***

 

Idempotent

現在新的問題來了,ack的作法雖然可以確保事件至少被Consumer收到一次,但當Consumer重複收到同一個事件怎麼辦?例如收到重複的扣款請求?那就真的變成「詐騙集團」常用的台詞:「系統設定錯誤造成重複扣款XD」。

請參考圖4,在情況4中Consumer收到事件1並且已經把事情做完(更新系統狀態),就在它要ack之前,它當掉了,所以沒有ack成功。情況5當它下次重啟,事件1又被處理一次,這樣顯然不OK。因此Consumer(Event Handler)需要具備Idempotent。


▲圖4:事件1被Consumer處理2次

 

Idempotent意指相同操作就算重複執行也不會影響系統狀態。例如,把任何數字乘以1,最後結果還是不變,因此「乘以1」這個操作就是idempotent。在軟體開發中,傳統的CRUD操作,基本上RUD都是idempotent。R不用說,讀取資料N次也不會改變系統狀態,用固定值更新與刪除同一筆資料N次也不會改變系統狀態。但是C(新增)就不是idempotent。

Consumer需要具備idempotent的意思,就是說Consumer可以很神奇地讓兩次相同新增的效果變成一次。怎麼做到?原則上就是讓Consumer把它讀過的事件編號(event id)記錄下來。每次從Topic讀取事件之後,先到自己本地端資料庫查詢這筆事件以前有沒有看過?如果有就直接ack繼續處理下一筆,如果沒有就正常處理,如圖5所示。

圖5中有一個細節要注意,Consumer儲存處理過的領域事件與因為處理該事件所造成的系統狀態更新,這兩的操作必須要放在同一個交易中,否則又可能會發生Consumer改變狀態但來不及紀錄事件,或是先記錄事件但是來不及保存狀態的錯誤狀況

 

▲圖5:儲存處理過的事件以達到idempotent


 

***


好多細節

看到這理請鄉民們回頭看<Consumer事件溯源(10):實作Projector>,Projector是一種Consumer,因此在實作Projector的時候就必須考慮到本集所提到event ordering、at least once以及Idempotent的問題。

▲圖6:Read Model的Projector需要具備Idempotent

 

***

 

下集預告

如果鄉民使用EventStoreDB,它本身既是Event Store也是一個簡易的Event Broker,因此支援event ordering與at least once。Kafka與Pulsar更是強大的Event Broker,當然也有支援。但是,如果鄉民是自己用Rational Database或是NoSQL「手刻Event Store」,那麼就需要自己確保event ordering與at least once。下集談在手刻Event Store的情況下,要如何做到event ordering與at least once。

***

友藏內心獨白:身為Maker一定要自幹Event Store的啦XD。

沒有留言:

張貼留言