--- tags: 鐵人賽 --- # Day 4, Distributed Transaction Introduction 在開始介紹微服務之前,有一個很重要的概念必須要先闡明,那就是分散式交易。 因此這篇文章會分為幾個部分: 1. 解釋什麼是分散式交易 2. 介紹如何解決分散式系統的問題 3. 提供一個實際的例子 但在解釋什麼是分散式交易前,讓我們來回顧一下什麼是交易。 ## 交易(Transaction) 傳統的交易指的是資料庫能夠提供四項保證,也就是俗稱的ACID。 - 原子性(Atomicity): 所有的任務要嘛全部完成要嘛全部失敗,不會有部分成功。 - 一致性(Consistency): 在交易內的所有操作都遵循既定的限制,例如唯一性。且資料一但進入資料庫,所有的客戶端都會看到一致的結果。 - 隔離性(Isolation): 隔離性又被定義了四種不同的程度,分別是 讀未提交(`Read Uncommitted`)、讀已提交(`Read Committed`)、可重複讀(`Repeatable Read`)和可序列化(`Serializable`)。可序列化是最嚴格的保證,任何操作都會強制遵循先後順序,因此不會碰到競爭條件(racing condition)。 - 持久性(Durability): 一但資料被提交進資料庫,那麼資料就不會遺失,即便系統毀壞重啟,資料依然存在。 ## 哪些資料庫提供交易保證? 在簡單的描述完ACID,讓我們看看有哪些資料庫支援。我挑選了三個常見的目標,分別是`MySQL`、`Mongo`和`Redis`。 - MySQL: 當然支援。身為OLTP(Online Transactional Processing)的代表,MySQL對交易的支援非常完整並常被使用在各種交易場景中。儘管隔離等級通常都是設為可重複讀,MySQL依然是最合適的方案。 - Mongo: 當MongoDB 4.0發布後,MongoDB也開始支援交易。只要正確設定`Write Concern`和`Read Concern`就能保證交易的一致性。我認為,MongoDB非常適合用在非正規化的使用場景。 - Redis: 很微妙。我們知道Redis因為是單執行緒的設計所以具有原子性操作。此外,Redis也可以透過`MULTI`一次執行很多命令或者透過`EVAL`呼叫一個`Lua`腳本。看起來Redis可以支援一次大量更新。更有甚者,Redis可以透過AOF或RDB的機制持久化存儲。但我必須說,Redis僅支援交易的部分保證,而不是完整的交易系統。 讓我們總結一下。 | | MySQL | Mongo | Redis | | -------- | -------- | -------- | --- | | Atomicity | V | V | X | | Consistency | V | Write concern | V | | Isolation | V | Read concern | V | | Durability | V | V | X | ## 那什麼是分散式交易? 我們已經了解傳統的交易了,現在我們來聊聊分散式交易。 為了簡化問題,我提供一個簡單的定義,那就是,在多個同質性/異質性的資料源上提供ACID保證。 1. 插入一筆資料進其中一個MySQL資料庫同時插入另一筆資料進另外一個MySQL資料庫。 2. 插入一筆資料進其中一個MySQL資料庫同時插入另一筆資料進另外一個**MongoDB**資料庫。 3. 插入一筆資料進其中一個MongoDB資料分片同時插入另一筆資料進另外一個MongoDB資料分片。 4. 以上資料庫全部換成微服務也適用。 > 第三個例子在MongoDB的4.2版發布後已經解決。 ## 一個簡單但不正確的設計 讓我們快速看一個基本的設計概念。 我們對每一個資料源都使用交易,如果有任何一個失敗,那麼就把其他已提交的資料回滾。因此實作會類似這樣: ```sequence Client -> A: Begin A --> Client: Done Client -> A: Commit Client -> B: Begin note over Client: and so on... ``` 問題在於,當我們提交給A後,我們很難在B失敗時將這些資料回滾。 即便我們採用巢狀交易,這個問題依然無解。 ```sequence Client -> A: Begin Client -> B: Begin Client -> C: Begin C --> Client: Done Client -> C: Commit B --> Client: Failure ``` 根據流程顯示,我們很難回滾C。 ## 二階段提交協定 在微服務的領域,這樣的問題很常見,因此有很多協定被提出來以解決這個常見的設計問題,例如`XA`。 `XA`是根據`2PC`(two-phase commit、二階段提交)協定設計的,但我不打算深入講二階段提交的細節。我只提供一個簡單的概念解釋。 整個交易系統會有一個協調者,任何要執行分散式交易的人都必須向協調者註冊(第一次提交)。協調者收到註冊後會向所有參與的資料源進行輪詢,若是有任何一個資料源拒絕,那麼協調者就會回滾所有的輪詢。 若很幸運的所有資料源都同意,那麼協調者就會告知客戶端,而客戶端就可以發起正式的提交(第二次提交)。 ```sequence Client -> Coordinator: DT Coordinator -> A: DT Ok? A -> Coordinator: Ok Coordinator -> B: DT Ok? B -> Coordinator: Ok Coordinator -> Client: Ok Client -> Coordinator: Commit Coordinator -> A: Commit Coordinator -> B: Commit ``` 但這樣的流程有三個問題: 1. 非常耗時。客戶端必須等待所有的資料源同意請求才能提交。雖然這樣的同步處理可以強化一致性,但必須要投資更多的時間。 2. 單點失效。當協調者故障,所有的分散式交易都會停擺。 3. 如果協調者在進行第二次提交的過程中有任何錯誤,可能因為網路問題或其他原因,那在多個參與的資料源上依然會發生資料不一致。 ## 最終一致性 看起來多階段提交協定不太實用,我們應該嘗試另一種比較輕量的方案,稱為最終一致性。 字面上的意思是我們放棄了交易系統提供的強一致性保證來避免實作的開銷,轉而希望資料最終能夠一致。為了達成最終一致性的方案,有兩個重要的關鍵字: 1. 事件朔源(event sourcing): 為了容易理解這個名詞,我們用個簡單例子。我們紀錄所有狀態的改變,不僅僅只有紀錄狀態。例如存摺裡面一筆一筆的交易紀錄就是狀態的改變,而銀行餘額則是狀態本身。 2. 冪等性: 這個名詞很直覺,指的是同樣的任務無論執行幾次都可以得到相同的結果。 結合這兩個概念,現在我們可以開始設計系統了。 首先,我們將所有的交易都當成事件,當一個客戶端想要執行分散式交易時,就直接將交易事件發送出去。事件處理者收到交易事件後就在多個資料源上進行處理。 當所有任務都正確結束,就可以將交易事件標記成已完成,事件處理者就可以處理下一個任務。若是任務中有任何失敗,那麼就不會標註交易事件,等到下次處理者進行重試。 因為冪等性,所以交易事件可以重試無數次直到成功。儘管如此,我還是建議至少設個重試上限。 讓我們來看一下流程圖。 ```sequence Client -> Queue: Event Queue -> Processor: Event Processor -> A: Being A --> Processor: Done Processor -> A: Commit Processor -> B: Being B --> Processor: Failure note over Processor: a few time later Processor -> A: Being A --> Processor: Done Processor -> A: Commit Processor -> B: Being B --> Processor: Done Processor -> B: Commit ``` 完美,現在我們可以正確處理分散式交易了,所有的資料最終都會保持一致。 雖然在流程圖中有一個訊息佇列(Queue),事實上,不一定需要一個真正的訊息佇列系統。有很多替代方案,例如將所有事件存在資料庫中,為每個事件建立一個狀態欄位,讓處理者定期從資料庫拿。可以根據需求選擇自己偏好的方式。