事務型消息-保證最終一致性
概述
本章延續之前緩存庫存所引入的事務不一致的問題,使用了異步化的事務型消息解決了最終一致性的問題,同時引入庫存售罄這樣的方案解決過載擊穿的問題。
本文的目標:
- 掌握異步化事務型消息模型;
- 掌握庫存售罄模型;
一、事務型消息原理
參考了這篇博主的文章消息隊列事務型消息原理淺析
消息隊列是普遍被應用的異步通信產品,本小節主要分爲以下幾個小結,循序漸進的對消息隊列產品事務型消息設計原理進行分析和闡述:
- 1、消息隊列簡介
- 2、消息隊列應用實例
- 3、事務型消息設計方案
- 4、事務型消息總結
1.1 消息隊列簡介
在分佈式系統架構中,消息隊列的核心職責是爲不同的應用系統提供異步通信服務,通常涉及以下三個重要角色:
- 消息發佈者,發送消息的應用系統,負責創建消息對象並通過網絡發佈到消息 Broker,發佈的過程一般是同步的;
- 消息 Broker,異步消息的“代理人”,負責接收並持久化消息,保證將消息投遞到指定的消息訂閱者應用系統;
- 消息訂閱者,訂閱消息的應用系統,負責消費消息 Broker 投遞過來的消息。
在分佈式系統架構中,引入消息隊列帶來的三大核心優勢如下:
- 1、提高核心鏈路吞吐量;
- 2、降低應用系統之間的耦合度
- 3、增強整體服務的高可用能力
1.2 消息隊列應用實例
分析和設計一個典型的支付應用業務邏輯,以 “賬單查詢 Case” 爲例,基本業務邏輯如下:
- 檢索數據庫,獲取指定賬戶的賬單記錄;
- 記錄用戶的檢索行爲,爲風險控制提供數據積累;
- 發送短信到用戶手機,通知用戶其賬單被查詢事件;
依賴 “同步 RPC” 的設計方案 A 如下所示:
依賴 “異步消息隊列” 的設計方案 B 如下所示:
對比以上 A, B 兩個設計方案,引入消息隊列的設計方案 B 具有如下優勢:
- “賬單服務” 處理 “賬單查詢 Case” 的耗時由 60 ms 縮減至 13 ms, 提高了服務的吞吐量;
- “賬單服務” 和 “風險控制服務”、“短信通知服務” 完全解耦,如果在業務演進過程中,增加了新的下游服務,“賬單服務” 完全無需變更;
- 當 “風險控制服務” 和 “短信通知服務” 不可用時,不會導致 “賬單服務” 不可用;
通過以上 “賬單查詢 Case” 的設計方案,可以闡明引入消息隊列給分佈式應用架構帶來的三大核心優勢;
下面繼續分析和設計 “賬單變更 Case”,基本業務邏輯如下:
- 寫入數據庫,變更指定賬戶的賬單記錄;
- 記錄用戶檢索行爲,爲風險控制提供數據積累;
- 發送短信到用戶手機,通知用戶其賬戶變更金額。
與 “賬單查詢 Case” 的區別在於數據庫操作是寫入,而不再是檢索。二者的區別是數據庫的檢索不涉及數據庫事務,數據庫寫入一定會涉及到數據庫事務,按照之前的引入消息隊列設計思路,“賬單變更 Case” 的設計方案 C 如下:
但是這種設計方案存在一個嚴重的問題就是,如果 “發佈賬單變更消息” 發送失敗(例如網絡異常或者消息隊列服務不可用),則會導致 “記錄用戶行爲” 和 “短信通知用戶” 後續動作失敗,無法完成風險控制數據積累,用戶也無法及時獲取到賬戶變更信息。
爲了解決以上設計方案 C 的嚴重問題,初步考慮先發布 “賬單變更” 消息,再做數據庫變更的設計方案,但還是無法解決 “消息發佈” 和 “數據庫事務” 可能不一致性的嚴重問題,如果消息已發佈成功過了,數據庫變更事務回滾了,就會導致用戶的賬單沒有變更,但用戶卻收到了賬戶變更短信,存在一致性漏洞的 “賬單變更 Case” 消息隊列設計方案 D 如下所示:
可以梳理一下完美解決 “賬單變更 Case” 需要解決的關鍵點:
- 必須滿足“一致性”要求,即賬單服務數據庫變更事務提交成功,風險控制服務和短信通知服務收到“賬單變更”消息;
- 賬單服務數據庫變更事務回滾,風險控制服務和短信通知服務不會收到“賬單變更”消息;
- “賬單變更”消息發佈失敗,儘量避免導致數據庫變更事務的回滾;
1.3 事務型消息設計方案
爲了解決以上描述的兩個需求,消息隊列需要提供一種特殊類型的消息**:消息隊列收到消息後不會立刻投遞消息到消息訂閱者,而是根據消息發佈者應用的數據庫事務狀態決定消息是否投遞。如果數據庫事務提交,則消息投遞到訂閱者;反之則不投遞。此類消息被命名爲 “事務型消息”**。具體設計方案如下:
事務型消息設計方案 E按照 “事務型消息設計方案 E” 的時序圖,消息發佈者和消息隊列之間增加了一個 “二階段” 消息,用來標明對應事務型消息的 “事務狀態”,消息隊列根據 “二階段” 消息決定是否投遞消息到下游消息訂閱者。應用 “事務型消息”,“賬單變更 Case” 的可行解決方案如下所示:
按照 “賬單變更 Case” 消息隊列-事務型消息設計方案 F,可以滿足“賬單服務數據庫變更”與“異步消息是否投遞到訂閱者應用”的事務一致性需求。
消息隊列 “事務型消息” 的設計方案和實現原理基本闡明清楚了,還遺留兩個可以深究的關鍵點:
- 爲什麼消息發佈方法需要在本地數據庫事務方法之前?
- 如果消息隊列收不到事務型消息的二階段“提交 or 回滾” 消息,如何處理?
針對第一個關鍵點,假定方法執行時序是先執行本地數據庫事務方法,之後發佈 “事務型” 消息,那麼消息發佈失敗會導致消息發佈者本地事務回滾,這明顯是不符合預期的,因爲數據庫事務回滾的成本比較消息發佈失敗是高昂的;
針對第二個關鍵點,在分佈式網絡架構中是可能出現的,比如網絡異常、消息隊列服務短時間不可用等。這也是消息隊列提供嚴謹的 “事務型消息” 特性必須要解決的問題,如果消息隊列沒有收到 “提交 or 回滾” 回滾消息,則無法決定是否投遞消息到消息訂閱者,因此,嚴謹的 “事務型消息” 設計方案需要有一個異常場景,命名爲 “事務型消息狀態回查”,具體設計方案如下:
需要明確的是,“事務型消息狀態回查” 只在 “提交 or 回滾消息” 失敗的場景下被觸發,屬於異常路徑。
1.4 事務型消息總結
在分佈式系統架構中,尤其是金融級業務應用的解決方案設計中,消息隊列提供 “事務型消息” 特性是必不可少的,“數據一致性” 是金融級分佈式架構的基本要求,本節通過實例逐步說明消息隊列產品支持 “事務型消息” 的必要性、設計方案和原理,定義了明確的消息隊列事務型消息的核心原理:
- 消息隊列事務型消息基於 “二階段” 消息實現;
- 事務型消息是否投遞與消息發佈者本地事務狀態保持一致;
- 事務型消息狀態回查是保證了 “事務型消息” 的嚴謹性。
二、操作流水
問題本質:
沒有庫存操作流水:
對於操作型數據:log data,意義是庫存扣減的操作記錄下來,便於追蹤庫存操作流水具體的狀態;根據這個狀態去做對應的回滾,或者查詢對應的狀態,使很多異步型的操作可以在操作型數據上,例如編譯人員在後臺創建的一些配置。
主業務數據:master data,ItemModel就是主業務數據,記錄了對應商品的主數據;ItemStock對應的庫存也是主業務數據;
2.1 庫存數據庫最終一致性保證
方案:
- 引入庫存操作流水,能夠做到redis和數據庫之間最終的一致性;
- 引入事務性消息機制;
帶來的問題是:
- redis不可用時如何處理;
- 扣減流水錯誤如何處理;
2.2 業務場景決定高可用技術實現
設計原則
寧可少賣,不可超賣;
方案
- redis可以比實際數據庫中少;
- 超時釋放;
2.3 庫存售罄
-
庫存售罄標識;
-
售罄後不去操作後續流程;
-
售罄後通知各系統售罄;
-
回補上新
2.4 後置流程
- 銷量邏輯異步化;
- 交易單邏輯異步化;
2.5 交易單邏輯異步化
- 生成交易單sequence後直接異步返回;
- 前端輪詢異步單狀態;
我們必須有交易單才能夠進行支付,因此對於交易單的時候必須要生成交易單sequence,還需要在前端進行輪詢去查詢訂單狀態,生成之後纔可以進行支付操作。
三、異步更新庫存
ItemService.java
新建一個方法
//異步更新庫存
boolean asyncDecreaseStock(Integer itemId,Integer amount);
//庫存回補
boolean increaseStock(Integer itemId,Integer amount)throws BusinessException;
ItemServiceImpl.java
@Override
public boolean asyncDecreaseStock(Integer itemId, Integer amount) {
boolean mqResult = mqProducer.asyncReduceStock(itemId,amount);
return mqResult;
}
@Override
public boolean increaseStock(Integer itemId, Integer amount) throws BusinessException {
redisTemplate.opsForValue().increment("promo_item_stock_"+itemId,amount.intValue());
return true;
}
等到前面所有的事務更新完畢,最後再發異步操作.我們異步更新了庫存的操作,並且根據對應的mqResult的狀態去回補庫存。
四、事務型消息應用
什麼叫事務性呢?就是保證數據庫的事務提交,只要事務提交了就一定會保證消息發送成功。數據庫內事務回滾了,消息必定不發送,事務提交未知,消息也處於一個等待的狀態;
MqProducer.java
//事務型同步庫存扣減消息
public boolean transactionAsyncReduceStock(Integer userId,Integer itemId,Integer promoId,Integer amount,String stockLogId){
Map<String,Object> bodyMap = new HashMap<>();
bodyMap.put("itemId",itemId);
bodyMap.put("amount",amount);
bodyMap.put("stockLogId",stockLogId);
Map<String,Object> argsMap = new HashMap<>();
argsMap.put("itemId",itemId);
argsMap.put("amount",amount);
argsMap.put("userId",userId);
argsMap.put("promoId",promoId);
argsMap.put("stockLogId",stockLogId);
Message message = new Message(topicName,"increase",
JSON.toJSON(bodyMap).toString().getBytes(Charset.forName("UTF-8")));
TransactionSendResult sendResult = null;
try {
sendResult = transactionMQProducer.sendMessageInTransaction(message,argsMap);
} catch (MQClientException e) {
e.printStackTrace();
return false;
}
if(sendResult.getLocalTransactionState() == LocalTransactionState.ROLLBACK_MESSAGE){
return false;
}else if(sendResult.getLocalTransactionState() == LocalTransactionState.COMMIT_MESSAGE){
return true;
}else{
return false;
}
}
//判斷庫存是否扣減成功,到resi內判斷是否扣減成功
@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
//根據是否扣減庫存成功,來判斷要返回COMMIT,ROLLBACK還是繼續UNKNOWN
String jsonString = new String(msg.getBody());
Map<String,Object>map = JSON.parseObject(jsonString, Map.class);
Integer itemId = (Integer) map.get("itemId");
Integer amount = (Integer) map.get("amount");
String stockLogId = (String) map.get("stockLogId");
StockLogDO stockLogDO = stockLogDOMapper.selectByPrimaryKey(stockLogId);
if(stockLogDO == null){
return LocalTransactionState.UNKNOW;
}
if(stockLogDO.getStatus().intValue() == 2){
return LocalTransactionState.COMMIT_MESSAGE;
}else if(stockLogDO.getStatus().intValue() == 1){
return LocalTransactionState.UNKNOW;
}
return LocalTransactionState.ROLLBACK_MESSAGE;
}
});
}