說到分散式事務,就會談到那個經典的”賬號轉賬”問題:2個賬號,分佈處於2個不同的DB,或者說2個不同的子系統裡面,A要扣錢,B要加錢,如何保證原子性? 一般的思路都是通過訊息中介軟體來實現“最終一致性”:A系統扣錢,然後發條訊息給中介軟體,B系統接收此訊息,進行加錢。
但這裡面有個問題:A是先update DB,後傳送訊息呢? 還是先傳送訊息,後update DB? 假設先update DB成功,傳送訊息網路失敗,重發又失敗,怎麼辦? 假設先傳送訊息成功,update DB失敗。訊息已經發出去了,又不能撤回,怎麼辦? 所以,這裡下個結論: 只要傳送訊息和update DB這2個操作不是原子的,無論誰先誰後,都是有問題的。 那這個問題怎麼解決呢?? 錯誤的方案0 有人可能想到了,我可以把“傳送訊息”這個網路呼叫和update DB放在同1個事務裡面,如果傳送訊息失敗,update DB自動回滾。這樣不就保證2個操作的原子性了嗎? 這個方案看似正確,其實是錯誤的,
原因有2:
(1)網路的2將軍問題:傳送訊息失敗,傳送方並不知道是訊息中介軟體真的沒有收到訊息呢?還是訊息已經收到了,只是返回response的時候失敗了? 如果是已經收到訊息了,而傳送端認為沒有收到,執行update db的回滾操作。則會導致A賬號的錢沒有扣,B賬號的錢卻加了。 (2)把網路呼叫放在DB事務裡面,可能會因為網路的延時,導致DB長事務。嚴重的,會block整個DB。這個風險很大。 基於以上分析,我們知道,這個方案其實是錯誤的!
解決方案如下:
- (1)Producer端準備1張訊息表,把update DB和insert message這2個操作,放在一個DB事務裡面。
- (2)準備一個後臺程式,源源不斷的把訊息表中的message傳送給訊息中介軟體。失敗了,不斷重試重傳。允許訊息重複,但訊息不會丟,順序也不會打亂。
- (3)Consumer端準備一個判重表。處理過的訊息,記在判重表裡面。實現業務的冪等。
但這裡又涉及一個原子性問題:如果保證訊息消費 + insert message到判重表這2個操作的原子性? 消費成功,但insert判重表失敗,怎麼辦?關於這個,在Kafka的原始碼分析系列,第1篇, exactly once問題的時候,有過討論。 通過上面3步,我們基本就解決了這裡update db和傳送網路訊息這2個操作的原子性問題。
但這個方案的一個缺點就是:需要設計DB訊息表,同時還需要一個後臺任務,不斷掃描本地訊息。導致訊息的處理和業務邏輯耦合額外增加業務方的負擔。 方案2 – RocketMQ 事務訊息 為了能解決該問題,同時又不和業務耦合,RocketMQ提出了“事務訊息”的概念。
具體來說,就是把訊息的傳送分成了2個階段:Prepare階段和確認階段。 具體來說,
上面的2個步驟,被分解成3個步驟:
- (1) 傳送Prepared訊息
- (2) update DB
- (3) 根據update DB結果成功或失敗,Confirm或者取消Prepared訊息。
可能有人會問了,前2步執行成功了,最後1步失敗了怎麼辦?這裡就涉及到了RocketMQ的關鍵點:RocketMQ會定期(預設是1分鐘)掃描所有的Prepared訊息,詢問傳送方,到底是要確認這條訊息發出去?還是取消此條訊息?
具體程式碼實現如下:
也就是定義了一個checkListener,RocketMQ會回撥此Listener,從而實現上面所說的方案。
// 也就是上文所說的,當RocketMQ發現`Prepared訊息`時,會根據這個Listener實現的策略來決斷事務 TransactionCheckListener transactionCheckListener = new TransactionCheckListenerImpl();
// 構造事務訊息的生產者 TransactionMQProducer producer = new TransactionMQProducer("groupName");
// 設定事務決斷處理類 producer.setTransactionCheckListener(transactionCheckListener);
// 本地事務的處理邏輯,相當於示例中檢查Bob賬戶並扣錢的邏輯 TransactionExecuterImpl tranExecuter = new TransactionExecuterImpl();
producer.start()
// 構造MSG,省略構造引數 Message msg = new Message(......);
// 傳送訊息 SendResult sendResult = producer.sendMessageInTransaction(msg, tranExecuter, null);
producer.shutdown();
public TransactionSendResult sendMessageInTransaction(.....)
{
// 邏輯程式碼,非實際程式碼 // 1.傳送訊息 sendResult = this.send(msg);
// sendResult.getSendStatus() == SEND_OK
// 2.如果訊息傳送成功,處理與訊息關聯的本地事務單元 LocalTransactionState localTransactionState = tranExecuter.executeLocalTransactionBranch(msg, arg);
// 3.結束事務 this.endTransaction(sendResult, localTransactionState, localException);
}
總結:
對比方案2和方案1,RocketMQ最大的改變,其實就是把“掃描訊息表”這個事情,不讓業務方做,而是訊息中介軟體幫著做了。 至於訊息表,其實還是沒有省掉。因為訊息中介軟體要詢問傳送方,事物是否執行成功,還是需要一個“變相的本地訊息表”,記錄事物執行狀態。 人工介入 可能有人又要說了,無論方案1,還是方案2,傳送端把訊息成功放入了佇列,但消費端消費失敗怎麼辦? 消費失敗了,重試,還一直失敗怎麼辦?是不是要自動回滾整個流程? 答案是人工介入。從工程實踐角度講,這種整個流程自動回滾的代價是非常巨大的,不但實現複雜,還會引入新的問題。比如自動回滾失敗,又怎麼處理? 對應這種極低概率的case,採取人工處理,會比實現一個高複雜的自動化回滾系統,更加可靠,也更加簡單。