微服務淺談(下) ACID事務處理 & CAP & BASE
==========================
###### tags: `MicroService`
當開始評估怎設計微服務架構時, 就要來關心如何實現跨多個服務的事務(Transaction)交易了。
事務交易才能確保資料的一致性。
舉例, Order Service和Customer Service, 一個負責訂單, 一個負責客戶資料與信用額度。
兩個服務有獨立的資料庫存放各自領域的數據。
![](https://i.imgur.com/1hRoQ9a.png)
但是有些業務跨越多個服務,彼此的資料庫與連線事務也不同, 沒法用一般的事務交易方式來完成事務處理。
例如一個電商平台, 每個客戶的建立需要綁信用額度。確保每筆新訂單不會超過用戶的信用額度。
由於Order與Customer是不同服務且擁有不同的資料庫, 所以程式無法簡單的用Local的[ACID事務](https://zh.wikipedia.org/zh-tw/ACID)。
# 單機 ACID
* Atomic
* Consistent
* Isolation
* Duration
## Atomic 原子性
同一個事務內的所有命令, 對其資料進行修改, 要就是全部完成, 或者全部不完成。發生任意錯誤可以Rollback到事務開始前的狀態。事務本身是最小工作單元, 不可分割。
MySQL是透過Undo log保存變更前的邏輯變化, 因為不是物理頁上的資料, Rollback就讀取undo log做回復。
## Consistent 一致性
事務在完成之前, 必須使所有資料都保持一致狀態, 事務完成後, 所有資料(包含資料庫的B樹索引、雙向鍊結表...)都必須是正確的。
## Isolation 隔離性
資料庫允許事務併發被執行, 但多個事務間若同時操作同一筆資料, 必定會存在衝突, 事務的中間狀態可能暴露給其他事務, 導致一些事務依據其他事務的中間狀態, 把錯誤的值寫到資料庫內。
這時就需要事務的isolation, 確保事務執行不受併發事務的影響。
也是透過Undo Log和讀寫鎖來實現MVCC(多版本控管)。
Isolation讓客戶可以專注於單個事務的邏輯操作, 不用考慮race condition. 也能考慮降低一些隔離性, 提昇效能, 就是事務隔離級別。
事務隔離分為不同級別,包括未提交讀(Read uncommitted)、提交讀(Read committed)、可重複讀(Repeatable read)和串行化(Serializable)。
## Duration 持久化
事務完成之後, 對系統的修改是永久性的。
MySQL透過Redo Log, 紀錄每次操作上的物理修改。
啟動時, 不管上次資料庫是否正常關閉, 都會嘗試從redo log中進行恢復。
所以只要redo log有寫入, 基本上資料就是保持一致了。
## 簡化版MySQL事務過程
1. 開始Transaction
2. 先紀錄undo/redo log, 確保log先被寫到硬碟上進行持久化(Write Ahead Log)
3. commit transaction
4. 清理undo資訊
5. 釋放lock
6. 在Redo log裡面寫入commit紀錄
因為大部分耗時的操作都在commit之前就完成了, commit指令做的可說是"瞬間"完成的。
## MySQL主從同步
![](https://i.imgur.com/KeP4QlW.png)
### 異步複製
MySQL默認的機制是異步複製, 就是提交的事務只要在主庫寫入binlog完成, 就會返回結果給client, 不必等待從庫是否已經接收完畢.
但這樣就是犧牲了強一致性, 因為網路傳遞也需要時間, 所以這種機制是**A+P**架構, 但最終資料會一致的.
就是主庫如果crash了, 寫入的場景就失敗, 自然也不會有事務提交到主庫並且複製給從庫, 所以兩邊資料是一致的;
這時把從庫變成主庫了, 大家改寫入新主庫(原先的從庫), 因為可能有些還沒複製過來的binlog, 所以這新的主庫可能資料是不完整的.
因為上圖從庫的I/O Thread跟SQL Thread其實是單線程,
但主庫允許多線程來提交事務, 所以若是主庫TPS(Transaction per second每秒事務量)很大時, 往往從庫是跟不太上的.
若是複製過來的單個事務是很多大型查詢命令時, 從庫的SQL Thread其實執行起來不快.
### 半同步複製
MySQL在主庫寫入binlog成功後, 不立刻返回結果, 而是等待任一個從庫同步完成, 才返回結果.
可是若有多個從庫, 還是無法保證全部從庫都完成同步, 所以依然不是**C+P**系統, 只是**A+P**系統.
MySQL 5.5新增了半同步複製的外掛套件 **rpl_semi_sync_master**
MySQL 5.7引入了**rpl_semi_sync_master_wait_for_slave_count**, 用來設置主庫需要等待多少個從庫的同步回應後, 才能返回結果, 預設就是1個.
[Semisynchronous Replication Installation and Configuration](https://dev.mysql.com/doc/refman/8.0/en/replication-semisync-installation.html)
這些都是所謂的強一致性情境下的事務模型。
當然微服務架構下, 無論網路是否跨區, 也有2PC(Two Phase Commit)這類的事務模型.
但2PC不是個太好的選擇, 因為過程中, 會一直對資源上鎖. 且相對於單個微服務的操作時間相比非常慢.
所以很適合短事務操作, 併發較低的場景. 之後在介紹SAGA事務模型.
# CAP理論
![](https://i.imgur.com/KGNGqlo.png)
1. Consistency一致性 : 所有節點上的數據都能隨時保持著同步的狀態
2. Availability可用性 : 每次請求都能得到一個回應, 無論回應成功或是失敗
3. Partition-tolerance分區容錯 : 系統因為網路問題或是某節點發生故障, 導致系統被切分成數個子系統, 且彼此無法通訊溝通時, 系統還是能提供服務. 畢竟網路並非100%可靠.
CAP理論主要是說, 在分散式架構下, 只要涉及讀寫操作時, 上面三個性質, 最多保證兩個滿足, 令一個最多盡量滿足, 又或是被犧牲.
## C+A
滿足了一致性+高可用, 但就犧牲了分區容錯.
But!!! 分散式架構下, 集群的節點間不可能保證網路絕對沒問題, 所以P基本上是必須考慮的.
## C+P
滿足了資料的一致性跟分區容錯. 如果節點發生錯誤, 就會導致該系統某些請求會失敗.
## A+P
滿足了高可用性和分區容錯, 但資料的強一致性會犧牲掉, 但能夠確保資料的最終一致性.
大部分的NoSQL都選擇此策略來實現叢集架構.
# Strong Consistency / Weak Consistency / Eventually Consistency
## NWR模型
CAP理論中的Consistency一致性, 能靠NWR模型知道這系統是強一致性還是弱一致性
N : 表示N個replica(副本); 資料被複製到多少個節點
R : 表示一次讀取操作中, 成功讀取操作的最小節點數(就是要讀幾個replica, 然後從中返回最新版本的資料)
W : 成功寫入操作的最小節點數(就是要完成幾個replica的同步才叫寫入完成)
## NWR模型與CAP
1. C+A 滿足了一致性+高可用, 但就犧牲了分區容錯; 讀寫均衡一致.
副本數量就是1, 就沒得擴充.
2. C+P 滿足了資料的一致性跟分區容錯; 讀多,寫少. W=N的配置
3. A+P 滿足了高可用性和分區容錯; 寫多,讀少.
W=1, 寫入一個副本(單機也算)就算成功, 其他副本就慢慢複製.
## Quorum法定數量機制
用來設定每次執行更新操作時要達成的consistency level
也是用來保證資料冗餘與最終一致性的投票演算法.
有幾種一致性級別, 這些級別是能依靠NWR模型來定義.
概念類似於[鴿籠原理](https://zh.wikipedia.org/wiki/%E9%B4%BF%E5%B7%A2%E5%8E%9F%E7%90%86)
1. Any : master收到更新命令後, 就寫入log, 快速回結果給client; MySQL異步複製就是這機制.
2. All : master收到更新命令後, 就寫入log, 並且等待所有的replica都同步了, 才返回結果.
3. One : 有一個replica返回ack給master後, 就返回結果; 上面提的rpl_semi_sync_master_wait_for_slave_count預設就是1個
4. Quorum : 設定需要收到幾個ack訊號來確保同步的完成; 上面提的rpl_semi_sync_master_wait_for_slave_count設定成>1的數字
## Strong Consistency 強一致性
如果資料庫做主從抄寫, 當更新請求發出到主庫時, 客戶端需要等待主庫複製到從庫完成, 之後所有查詢該紀錄的請求都會拿到最新的資料, 那這情境就是強一致性的保證.
RDBMS能確保這件事情都是依賴對資料加上Lock實現的, 但吞吐量就會下降.
![](https://i.imgur.com/8jB10rf.png)
W+R > N 時 == 強一致性
![](https://i.imgur.com/Hku70oW.png)
3個副本節點時, 寫入要求至少2個有ack, 讀取也是任意讀取2節點,挑選版本最新的資料.
這樣寫入時過半節點都能確保有更新成功, 讀取節點也過半, 一定會撈到最新版本的資料.
這樣就能確保系統在讀取時都能拿到最新的資料.
## Weak Consistency
不同replica上的資料有新有舊.
W + R <= N == Weak consistency
上面為例, 寫入要求2個ack, 讀取只讀取1個節點; W+R=3 <= 3, 有可能還是讀到舊資料.
寫入要求1個ack, 讀取只讀取2個節點; 還是可能讀到舊資料.
### 讀寫均衡
NWR模型中;
W要是越大, TPS就越低.
R要是越大, QPS就越低.
為了保持讀寫均衡, 通常的配置係數是 : W=R=Q, Q=(N/2)+1 (i.e. Quorum > 副本數量/2)
# BASE原則
CAP只侷限於原子讀寫的NoSQL場景下, 且也無法支援資料庫的事務交易這類的場景.
所以在資料庫如果分片分表或是多個實例節點架構上, CAP並不適用.
有些事務交易模型雖能保證分散式架構下的ACID特性(XA事務, 2PC), 但吞吐量下降很多.
對於有些產業需要高併發+低回應時間是難以接受的.
2008年, eBay公司選擇把資料庫事務的ACID這原則放寬, 於ACM提出了一套[BASE原則(Basically Available, Soft-state, Eventually Consistent)](https://dl.acm.org/doi/10.1145/1394127.1394128).
並且給出它們在實踐這理論於分散式系統的解決方案.
之前的文章, 會發現如果要支援高併發+低回應時間, C+P模型要寫入非常多節點.
所以BASE理論的核心思想就是即使無法作到Strong Consistency, 但可以用適當的方式達成Eventually Consistency與Availability. 因此算是CAP理論的延伸.
BASE:
1. Basically Available
舉例, 把MySQL採用了partitioned分片模式, 100萬個用戶資料分在5個實例上. 但其中一個實例損毀了, 其系統可用性還有80%, 所以系統是"**基本可用**", 至少還有80%用戶可正常登入使用.
2. Soft-state
這個很抽象XD
在Client與Server的交互過程中, Server端會暫存Client的狀態資料, 但是僅僅就維持一小段時間(暫存的中間狀態), 過了這時間, 狀態就會轉成terminate或是最終狀態.
也就是說這中間狀態, 允許系統在多個不同節點間存在資料同步延時(可以有一段時間的不同步), 但這延時不可影響可用性.
像是某件事情的處理進入了retry狀態, 此時就會由client來主動刷新該狀態, 要是一段時間沒來刷新了, 又或者是刷新成功, 就會轉成令一個狀態, 並且儲存同步給所有節點了.
有Soft-State, 就有Hard-State, 同等於Strong Consistency, 要求所有數據副本的該資料狀態都是一致的.
3. Eventually Consistent
不保證任意時刻下任意節點的同一份資料都是相同的數據.
但是隨著時間, 不同節點同一份資料總是朝著相同的方向變化.
也就是說在一段時間後, 副本間的資料最終會達到一致的狀態.
![](https://i.imgur.com/vH7Fzj2.jpg)
[Eventual Consistency](https://twitter.com/gregyoung/status/1101642600342265857/photo/1)
所以對於RDBMS(關聯式資料庫), BASE理論主要實現
如在Basically Available的舉例
1. 對業務/功能資料進行垂直拆分
2. Sharding分片([Mycat](http://www.mycat.org.cn/), Atlas, [kingshard](https://github.com/flike/kingshard), [Vitess](https://vitess.io/)...)
至於NoSQL幾乎就在BASE基礎上, 出了很多系統.
Amazone Dynamo DB, Mongo...etc.
# 分散式事務
## eBay範例
eBay提出一個分佈式事務解決方案, 就是透過message queue來輔助實現事務控制流程, 或者啟用排程服務來檢查是否有未投遞的資料, 來達成Eventually Consistent.
支付交易場景, 如果某個user購買了某商品, 需要在order表之中增加紀錄, 也要修改商品庫存.
由於這兩張表屬於不同的資料庫, 所以就涉及分散式事務與資料一致性問題.
核心概念是把大事務轉變成小事務.
![](https://i.imgur.com/oPhjW5I.png)
[eBay BASE use case](https://queue.acm.org/detail.cfm?id=1394128%E3%80%82)
1. 分散式事務發起方開啟一個本地事務, 然後進行業務操作, 這裡就是建立訂單, 並把各項資訊寫到message表(本地消息表), 成功就Commit, 往下一步走; 發生錯誤則Rollback, 回絕請求.
這裡的trans message表(本地消息表), 就是用來紀錄分散式事務的內容與狀態.
```sql
START TRANSACTION;
INSERT INTO `order` (`order_id`,`user_id`,`tx_id`,`tx_time`,`product_id`,`amount`,`price`) VALUES(123,456,9999,'2020-01-01 01:01:01',1000,1,100);
INSERT INTO `trans_message` (`tx_id`,'2020-01-01 01:01:01',`tx_time`,`status`,`user_id`,`product_id`,`amount`) VALUES(9999, `in progressing`, 1000, 1);
COMMIT;
```
2. 分散式事務發起方透過MQ, 發送一則事務代處理訊息給分散式事務接收方
3. 分散式事務接收方透過MQ收到訊息, 並且傳Ack給MQ
4. 開啟一個本地事務, 來處理扣商品庫存的事務
5. 分散式事務接收方透過MQ, 發送一則事務完成訊息給分散式事務發起方
6. 分散式事務發起方透過MQ收到訊息
7. 分散式事務發起方開啟本地事務, 更新trans message表(本地消息表), 並且傳Ack給MQ
也能不用Message Queue, 而是排程檢查本地消息表, 把還未投遞出去的資料給投遞出去.
分散式事務接收方再打API來更新狀態.
這樣當用戶送出訂單時, API返回的會是處理中(in progressing), 過一下子就會轉成訂單成功建立.
但這方案有幾個要留意的地方
1. 如果推送MQ失敗, 雖然訂單還是處理中, 問題不大就是, 但如果retry失敗太多次, 也能告警處理; 如果是分散式事務接收方的完成事務的訊息推送失敗, 還是要仰賴(本地消息表)了, 或者告警人為介入
2. 因為MQ的At least once(確保訊息最少會被投遞一次), 或者排程會定期掃描未完成的任務來重新發送, 所以需要確保[冪等性](https://codertw.com/%E7%A8%8B%E5%BC%8F%E8%AA%9E%E8%A8%80/614601/)
3. 如果引入了MQ, 設計上會變得相較複雜; 畢竟MQ也是需要做叢集滿足高可用性
4. 用MQ比用排程掃描, 會相對很即時; 且相對服務間就解耦合了
5. 這種流程, 只適用於執行週期長, 且對實時性要求不高的場景;如果用排程掃描, 不可能秒秒都在掃描的