介紹
在分散式系統、微服務架構大行其道的今天,服務間互相呼叫出現失敗已經成為常態。如何處理異常,如何保證資料一致性,成為微服務設計過程中,繞不開的一個難題。 在不同的業務場景下,解決方案會有所差異,常見的方式有:
- 阻塞式重試;
- 2PC、3PC 傳統事務;
- 使用佇列,後臺非同步處理;
- TCC 補償事務;
- 本地訊息表(非同步確保);
- MQ 事務。
本文側重於其他幾項,關於 2PC、3PC 傳統事務,網上資料已經非常多了,這裡不多做重複。
阻塞式重試
在微服務架構中,阻塞式重試是比較常見的一種方式。虛擬碼示例:
m := db.Insert(sql)
err := request(B-Service,m)
func request(url string,body interface{}){
for i:=0; i<3; i ++ {
result, err = request.POST(url,body)
if err == nil {
break
}else {
log.Print()
}
}
}
如上,當請求 B 服務的 API 失敗後,發起最多三次重試。如果三次還是失敗,就列印日誌,繼續執行下或向上層丟擲錯誤。這種方式會帶來以下問題
-
呼叫 B 服務成功,但由於網路超時原因,當前服務認為其失敗了,繼續重試,這樣 B 服務會產生 2 條一樣的資料。
-
呼叫 B 服務失敗,由於 B 服務不可用,重試 3 次依然失敗,當前服務在前面程式碼中插入到 DB 的一條記錄,就變成了髒資料。
-
重試會增加上游對本次呼叫的延遲,如果下游負載較大,重試會放大下游服務的壓力。
第一個問題:通過讓 B 服務的 API 支援冪等性來解決。
第二個問題:可以通過後臺定時腳步去修正資料,但這並不是一個很好的辦法。
第三個問題:這是通過阻塞式重試提高一致性、可用性,必不可少的犧牲。
阻塞式重試適用於業務對一致性要求不敏感的場景下。如果對資料一致性有要求的話,就必須要引入額外的機制來解決。
非同步佇列
在解決方案演化的過程中,引入佇列是個比較常見也較好的方式。如下示例:
m := db.Insert(sql)
err := mq.Publish("B-Service-topic",m)
在當前服務將資料寫入 DB 後,推送一條訊息給 MQ,由獨立的服務去消費 MQ 處理業務邏輯。和阻塞式重試相比,雖然 MQ 在穩定性上遠高於普通的業務服務,但在推送訊息到 MQ 中的呼叫,還是會有失敗的可能性,比如網路問題、當前服務當機等。這樣還是會遇到阻塞式重試相同的問題,即 DB 寫入成功了,但推送失敗了。
理論上來講,分散式系統下,涉及多個服務呼叫的程式碼都存在這樣的情況,在長期執行中,呼叫失敗的情況一定會出現。這也是分散式系統設計的難點之一。
TCC 補償事務
在對事務有要求,且不方便解耦的情況下,TCC 補償式事務是個較好的選擇。
TCC 把呼叫每個服務都分成 2 個階段、 3 個操作:
- 階段一、Try 操作:對業務資源做檢測、資源預留,比如對庫存的檢查、預扣。
- 階段二、Confirm 操作:提交確認 Try 操作的資源預留。比如把庫存預扣更新為扣除。
- 階段二、Cancel 操作:Try 操作失敗後,釋放其預扣的資源。比如把庫存預扣的加回去。
TCC 要求每個服務都實現上面 3 個操作的 API,服務接入 TCC 事務前一次呼叫就完成的操作,現在需要分 2 階段完成、三次操作來完成。
比如一個商城應用需要呼叫 A 庫存服務、B 金額服務、C 積分服務,如下虛擬碼:
m := db.Insert(sql)
aResult, aErr := A.Try(m)
bResult, bErr := B.Try(m)
cResult, cErr := C.Try(m)
if cErr != nil {
A.Cancel()
B.Cancel()
C.Cancel()
} else {
A.Confirm()
B.Confirm()
C.Confirm()
}
程式碼中分別呼叫 A、B、C 服務 API 檢查並保留資源,都返回成功了再提交確認(Confirm)操作;如果 C 服務 Try 操作失敗後,則分別呼叫 A、B、C 的 Cancel API 釋放其保留的資源。
TCC 在業務上解決了分散式系統下,跨多個服務、跨多個資料庫的資料一致性問題。但 TCC 方式依然存在一些問題,實際使用中需要注意,包括上面章節提到的呼叫失敗的情況。
空釋放
上面程式碼中如果 C.Try() 是真正呼叫失敗,那下面多餘的 C.Cancel() 呼叫會出現釋放並沒有鎖定資源的行為。這是因為當前服務無法判斷呼叫失敗是不是真的鎖定 C 資源了。如果不呼叫,實際上成功了,但由於網路原因返回失敗了,這會導致 C 的資源被鎖定,一直得不到釋放。
空釋放在生產環境經常出現,服務在實現 TCC 事務 API 時,應支援空釋放的執行。
時序
上面程式碼中如果 C.Try() 失敗,接著呼叫 C.Cancel() 操作。因為網路原因,有可能會出現 C.Cancel() 請求會先到 C 服務,C.Try() 請求後到,這會導致空釋放問題,同時引起 C 的資源被鎖定,一直得不到釋放。
所以 C 服務應拒絕釋放資源之後的 Try() 操作。具體實現上,可以用唯一事務ID來區分第一次 Try() 還是釋放後的 Try()。
呼叫失敗
Cancel 、Confirm 在呼叫過程中,還是會存在失敗的情況,比如常見的網路原因。
Cancel() 或 Confirm() 操作失敗都會導致資源被鎖定,一直得不到釋放。這種情況常見解決方案有:
-
阻塞式重試。但有同樣的問題,比如當機、一直失敗的情況。
-
寫入日誌、佇列,然後有單獨的非同步服務自動或人工介入處理。但一樣會有問題,寫日誌或佇列時,會存在失敗的情況。
理論上來講非原子性、事務性的二段程式碼,都會存在中間態,有中間態就會有失敗的可能性。
本地訊息表
本地訊息表最初是 ebay 提出的,它讓本地訊息表與業務資料表處於同一個資料庫中,這樣就能利用本地事務來滿足事務特性。
具體做法是在本地事務中插入業務資料時,也插入一條訊息資料。然後在做後續操作,如果其他操作成功,則刪除該訊息;如果失敗則不刪除,非同步監聽這個訊息,不斷重試。
本地訊息表是一個很好的思路,可以有多種使用方式:
配合MQ
示例虛擬碼:
messageTx := tc.NewTransaction("order")
messageTxSql := tx.TryPlan("content")
m,err := db.InsertTx(sql,messageTxSql)
if err!=nil {
return err
}
aErr := mq.Publish("B-Service-topic",m)
if aErr!=nil { // 推送到 MQ 失敗
messageTx.Confirm() // 更新訊息的狀態為 confirm
}else {
messageTx.Cancel() // 刪除訊息
}
// 非同步處理 confirm 的訊息,繼續推送
func OnMessage(task *Task){
err := mq.Publish("B-Service-topic", task.Value())
if err==nil {
messageTx.Cancel()
}
}
上面程式碼中其 messageTxSql 是插入本地訊息表的一段 SQL :
insert into `tcc_async_task` (`uid`,`name`,`value`,`status`) values ('?','?','?','?')
它和業務 SQL 在同一個事務中去執行,要麼成功,要麼失敗。
成功則推送到佇列,推送成功,則呼叫 messageTx.Cancel() 刪除本地訊息;推送失敗則標記訊息為 confirm
。本地訊息表中 status
有 2 種狀態 try
、confirm
, 無論哪種狀態在 OnMessage
都可以監聽到,從而發起重試。
本地事務保障訊息和業務一定會寫入資料庫,此後的執行無論當機還是網路推送失敗,非同步監聽都可以進行後續處理,從而保障了訊息一定會推到 MQ。
而 MQ 則保障一定會到達消費者服務中,利用 MQ 的 QOS 策略,消費者服務一定能處理,或繼續投遞到下一個業務佇列中,從而保障了事務的完整性。
配合服務呼叫
示例虛擬碼:
messageTx := tc.NewTransaction("order")
messageTxSql := tx.TryPlan("content")
body,err := db.InsertTx(sql,messageTxSql)
if err!=nil {
return err
}
aErr := request.POST("B-Service",body)
if aErr!=nil { // 呼叫 B-Service 失敗
messageTx.Confirm() // 更新訊息的狀態為 confirm
}else {
messageTx.Cancel() // 刪除訊息
}
// 非同步處理 confirm 或 try 的訊息,繼續呼叫 B-Service
func OnMessage(task *Task){
// request.POST("B-Service",body)
}
這是本地訊息表 + 呼叫其他服務的例子,沒有 MQ 的引入。這種使用非同步重試,並用本地訊息表保障訊息的可靠性,解決了阻塞式重試帶來的問題,在日常開發中比較常見。
如果本地沒有要寫 DB 的操作,可以只寫入本地訊息表,同樣在 OnMessage
中處理:
messageTx := tc.NewTransaction("order")
messageTx := tx.Try("content")
aErr := request.POST("B-Service",body)
// ....
訊息過期
配置本地訊息表的 Try
和 Confirm
訊息的處理器:
TCC.SetTryHandler(OnTryMessage())
TCC.SetConfirmHandler(OnConfirmMessage())
在訊息處理函式中要判斷當前訊息任務是否存在過久,比如一直重試了一小時,還是失敗,就考慮發郵件、簡訊、日誌告警等方式,讓人工介入。
func OnConfirmMessage(task *tcc.Task) {
if time.Now().Sub(task.CreatedAt) > time.Hour {
err := task.Cancel() // 刪除該訊息,停止重試。
// doSomeThing() 告警,人工介入
return
}
}
在 Try
處理函式中,還要單獨判斷當前訊息任務是否存在過短,因為 Try
狀態的訊息,可能才剛剛建立,還沒被確認提交或刪除。這會和正常業務邏輯的執行重複,意味著成功的呼叫,也會被重試;為儘量避免這種情況,可以檢測訊息的建立時間是否很短,短的話可以跳過。
重試機制必然依賴下游 API 在業務邏輯上的冪等性,雖然不處理也可行,但設計上還是要儘量避免干擾正常的請求。
獨立訊息服務
獨立訊息服務是本地訊息表的升級版,把本地訊息表抽離成一個獨立的服務。所有操作之前先在訊息服務新增個訊息,後續操作成功則刪除訊息,失敗則提交確認訊息。
然後用非同步邏輯去監聽訊息,做對應的處理,和本地訊息表的處理邏輯基本一致。但由於向訊息服務新增訊息,無法和本地操作放到一個事務裡,所以會存在新增訊息成功,後續失敗,則此時的訊息就是個無用訊息。
如下示例場景:
err := request.POST("Message-Service",body)
if err!=nil {
return err
}
aErr := request.POST("B-Service",body)
if aErr!=nil {
return aErr
}
這個無用的訊息,需要訊息服務去確認這個訊息是否執行成功,沒有則刪除,有繼續執行後續邏輯。相比本地事務表 try
和 confirm
,訊息服務在前面多了一種狀態 prepare
。
MQ 事務
有些 MQ 的實現支援事務,比如 RocketMQ 。MQ 的事務可以看作獨立訊息服務的一種具體實現,邏輯完全一致。
所有操作之前先在 MQ 投遞個訊息,後續操作成功則 Confirm
確認提交訊息,失敗則Cancel
刪除訊息。MQ 事務也會存在 prepare
狀態,需要 MQ 的消費處理邏輯來確認業務是否成功。
總結
從分散式系統實踐中來看,要保障資料一致性的場景,必然要引入額外的機制處理。
TCC 的優點是作用於業務服務層,不依賴某個具體資料庫、不與具體框架耦合、資源鎖的粒度比較靈活,非常適用於微服務場景下。缺點是每個服務都要實現 3 個 API,對於業務侵入和改動較大,要處理各種失敗異常。開發者很難完整處理各種情況,找個成熟的框架可以大大降低成本,比如阿里的 Fescar。
本地訊息表的優點是簡單、不依賴其他服務的改造、可以很好的配合服務呼叫和 MQ 一起使用,在大多業務場景下都比較實用。缺點是本地資料庫多了訊息表,和業務表耦合在一起。文中本地訊息表方式的示例,來源於作者寫的一個庫,有興趣的同學可以參考下 https://github.com/mushroomsir/tcc
MQ 事務和獨立訊息服務的優點是抽離出一個公共的服務來解決事務問題,避免每個服務都有訊息表和服務耦合在一起,增加服務自身的處理複雜性。缺點是支援事務的 MQ 很少;且每次操作前都先呼叫 API 新增個訊息,會增加整體呼叫的延遲,在絕大多數正常響應的業務場景下,是一種多餘的開銷。
TCC 參考:https://www.sofastack.tech/blog/seata-tcc-theory-design-realization/