分佈式系統中接口的冪等性

業務場景

公司有個借貸的項目,具體業務相似於阿里的螞蟻借唄,用戶在平臺上借款,而後規定一個到期時間,在該時間內用戶需將借款還清並收取必定的手續費,若是規定時間逾期未還上,則會產生滯納金。前端

用戶發起借款所以會產生一筆借款訂單,用戶可經過支付寶或在系統中綁定銀行卡到期自動扣款等方式進行還款。還款流程都走支付系統,所以用戶還款是否逾期以及逾期天數、逾期費等都經過系統來計算。java

可是在作訂單系統的時候,遇到這樣一個業務場景,因爲業務緣由容許用戶經過線下支付寶還款,即咱們提供一個公司官方的支付寶二維碼,用戶掃碼還款,而後財務不按期的去拉取該支付寶帳戶下的還款清單並生成規範化的Excel表格錄入到支付系統。程序員

支付系統將這些支付信息生成對應的支付訂單並落庫,同時針對每筆還款記錄生產一個消息信息到消息系統,消息的消費者就是訂單系統。訂單系統接受到消息後去結算當前用戶的金額清算:先還本金,本金還清再還滯納金,都還清則該筆訂單結清並提高可借貸額度,……,整個流程大體以下:web

從上面的流程描述能夠知道,至關於原來線上的支付如今轉移到線下進行,這會產生一個問題:支付結算的不及時。例如用戶的訂單在今天19-05-27到期,可是用戶在19-05-26還清,財務在19-05-27甚至更晚的時候從支付寶拉取清單錄入支付系統。這樣就形成了實際上用戶是未逾期還清借款而咱們這邊卻記錄的是用戶未還清且產生了滯納金。redis

固然以上的是業務範疇的問題,咱們今天要說的是支付系統發送消息到訂單系統的環節中的一個問題。你們都知道爲了不消息丟失或者訂單系統處理異常或者網絡問題等問題,咱們設計消息系統的時候都須要考慮消息持久化和消息的失敗重試機制。sql

對於重試機制,假如訂單系統消費了消息,可是因爲網絡等問題消息系統未收到反饋是否已成功處理。這時消息系統會根據配置的規則隔段時間就 retry 一次。你 retry 一次沒錯,是爲了保證系統的處理正常性,可是若是這時網絡恢復正常,我第一次收到的消息成功處理了,這時我又收到了一條消息,若是沒有作一些防禦措施,會產生以下狀況:用戶付款一次可是訂單系統計算了兩次,這樣會形成財務帳單異常對不上帳的狀況發生。那就可能用戶笑呵呵老闆哭兮兮了。數據庫

接口冪等性

爲了防止上述狀況的發生,咱們須要提供一個防禦措施,對於同一筆支付信息若是我其中某一次處理成功了,我雖然又接收到了消息,可是這時我不處理了,即保證接口的 冪等性編程

維基百科上的定義:後端

冪等(idempotent、idempotence)是一個數學與計算機學概念,常見於抽象代數中。緩存

**在編程中一個冪等操做的特色是其任意屢次執行所產生的影響均與一次執行的影響相同。**冪等函數,或冪等方法,是指可使用相同參數重複執行,並能得到相同結果的函數。這些函數不會影響系統狀態,也不用擔憂重複執行會對系統形成改變。例如,「setTrue()」函數就是一個冪等函數,不管屢次執行,其結果都是同樣的,更復雜的操做冪等保證是利用惟一交易號(流水號)實現.

任意屢次執行所產生的影響均與一次執行的影響相同,這是冪等性的核心特色。其實在咱們編程中主要操做就是CURD,其中讀取(Retrieve)操做和刪除(Delete)操做是自然冪等的,受影響的就是建立(Create)、更新(Update)。

對於業務中須要考慮冪等性的地方通常都是接口的重複請求,重複請求是指同一個請求由於某些緣由被屢次提交。致使這個狀況會有幾種場景:

  • 前端重複提交:提交訂單,用戶快速重複點擊屢次,形成後端生成多個內容重複的訂單。
  • 接口超時重試:對於給第三方調用的接口,爲了防止網絡抖動或其餘緣由形成請求丟失,這樣的接口通常都會設計成超時重試屢次。
  • 消息重複消費:MQ消息中間件,消息重複消費。

對於一些業務場景影響比較大的,接口的冪等性是個必需要考慮的問題,例如金錢的交易方面的接口。不然一個錯誤的、考慮不周的接口可能會給公司帶來鉅額的金錢損失,那麼背鍋的確定是程序員本身了。

冪等性實現方式

對於和web端交互的接口,咱們能夠在前端攔截一部分,例如防止表單重複提交,按鈕置灰、隱藏、不可點擊等方式。

可是前端作控制實際效益不是很高,懂點技術的都會模擬請求調用你的服務,因此安全的策略仍是須要從後端的接口層來作。

那麼後端要實現分佈式接口的冪等性有哪些策略方式呢?主要能夠從如下幾個方面來考慮實現:

Token機制

針對前端重複連續屢次點擊的狀況,例如用戶購物提交訂單,提交訂單的接口就能夠經過 Token 的機制實現防止重複提交。

主要流程就是:

  1. 服務端提供了發送token的接口。咱們在分析業務的時候,哪些業務是存在冪等問題的,就必須在執行業務前,先去獲取token,服務器會把token保存到redis中。(微服務確定是分佈式了,若是單機就適用jvm緩存)。
  2. 而後調用業務接口請求時,把token攜帶過去,通常放在請求頭部。
  3. 服務器判斷token是否存在redis中,存在表示第一次請求,這時把redis中的token刪除,繼續執行業務。
  4. 若是判斷token不存在redis中,就表示是重複操做,直接返回重複標記給client,這樣就保證了業務代碼,不被重複執行。

數據庫去重表

往去重表裏插入數據的時候,利用數據庫的惟一索引特性,保證惟一的邏輯。惟一序列號能夠是一個字段,例如訂單的訂單號,也能夠是多字段的惟一性組合。例如設計以下的數據庫表。

CREATE TABLE `t_idempotent` (
  `id` int(11) NOT NULL COMMENT 'ID',
  `serial_no` varchar(255)  NOT NULL COMMENT '惟一序列號',
  `source_type` varchar(255)  NOT NULL COMMENT '資源類型',
  `status` int(4) DEFAULT NULL COMMENT '狀態',
  `remark` varchar(255)  NOT NULL COMMENT '備註',
  `create_by` bigint(20) DEFAULT NULL COMMENT '建立人',
  `create_time` datetime DEFAULT NULL COMMENT '建立時間',
  `modify_by` bigint(20) DEFAULT NULL COMMENT '修改人',
  `modify_time` datetime DEFAULT NULL COMMENT '修改時間',
  PRIMARY KEY (`id`)
  UNIQUE KEY `key_s` (`serial_no`,`source_type`, `remark`)  COMMENT '保證業務惟一性'
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='冪等性校驗表';
複製代碼

咱們注意看以下這幾個關鍵性字段,

  • serial_no:惟一序列號的值,在這裏我設置的是經過註解@IdempotentKey來標識請求對象中的字段,經過對他們 MD5 加密獲取對應的值。
  • source_type:業務類型,區分不一樣的業務,訂單,支付等。
  • remark:是由標識字段的拼接成的字符串,拼接符爲 「|」。

因爲數據創建了 serial_no,source_type, remark 三個字段組合構成的惟一索引,因此能夠經過這個來去重達到接口的冪等性,具體的代碼設計以下,

public class PaymentOrderReq {

    /** * 支付寶流水號 */
    @IdempotentKey(order=1)
    private String alipayNo;

    /** * 支付訂單ID */
    @IdempotentKey(order=2)
    private String paymentOrderNo;

    /** * 支付金額 */
    private Long amount;
}
複製代碼

由於支付寶流水號和訂單號在系統中是惟一的,因此惟一序列號可由他們組合 MD5 生成,具體的生成方式以下:

private void getIdempotentKeys(Object keySource, Idempotent idempotent) {
    TreeMap<Integer, Object> keyMap = new TreeMap<Integer, Object>();
    for (Field field : keySource.getClass().getDeclaredFields()) {
        if (field.isAnnotationPresent(IdempotentKey.class)) {
            try {
                field.setAccessible(true);
                keyMap.put(field.getAnnotation(IdempotentKey.class).order(),
                        field.get(keySource));
            } catch (IllegalArgumentException | IllegalAccessException e) {
                logger.error("", e);
                return;
            }
        }
    }
    generateIdempotentKey(idempotent, keyMap.values().toArray());
}
複製代碼

生成冪等Key,若是有多個key能夠經過分隔符 "|" 鏈接,

private void generateIdempotentKey(Idempotent idempotent, Object... keyObj) {
     if (keyObj.length == 0) {
         logger.info("idempotentkey is empty,{}", keyObj);
         return;
     }
     StringBuilder serialNo= new StringBuilder();
     for (Object key : keyObj) {
         serialNo.append(key.toString()).append("|");
     }
     idempotent.setRemark(serialNo.toString());
     idempotent.setSerialNo(md5(serialNo));
 }
複製代碼

一切準備就緒,則可對外提供冪等性校驗的接口方法,接口方法爲:

public <T> void idempotentCheck(IdempotentTypeEnum idempotentType, T keyObj) throws IdempotentException {
    Idempotent idempotent = new Idempotent();
    getIdempotentKeys(keyObj, idempotent );
    if (StringUtils.isBlank(idempotent.getSerialNo())) {
        throw new ServiceException("fail to get idempotentkey");
    }
    idempotentEvent.setSourceType(idempotentType.name());
    try {
        idempotentMapper.saveIdempotent(idempotent);
    } catch (DuplicateKeyException e) {
        logger.error("idempotent check fail", e);
        throw new IdempotentException(idempotent);
    }
}
複製代碼

固然這個接口的方法具體在項目中合理的使用就看項目要求了,能夠經過@Autowire註解注入到須要使用的地方,可是缺點就是每一個地方都須要調用。我我的推薦的是自定義一個註解,在須要冪等性保證的接口上加上該註解,而後經過攔截器方法攔截使用。這樣簡單便不會形成代碼侵入和污染。

另外,使用數據庫防重表的方式它有個嚴重的缺點,那就是系統容錯性不高,若是冪等表所在的數據庫鏈接異常或所在的服務器異常,則會致使整個系統冪等性校驗出問題。若是作數據庫備份來防止這種狀況,又須要額外忙碌一通了啊。

Redis實現

上面介紹過防重表的設計方式和僞代碼,也說過它的一個很明顯的缺點。因此咱們另外介紹一個Redis的實現方式。

Redis實現的方式就是將惟一序列號做爲Key,惟一序列號的生成方式和上面介紹的防重表的同樣,value能夠是你想填的任何信息。惟一序列號也能夠是一個字段,例如訂單的訂單號,也能夠是多字段的惟一性組合。固然這裏須要設置一個 key 的過時時間,不然 Redis 中會存在過多的 key。具體校驗流程以下圖所示,實現代碼也很簡單這裏就不寫了。

因爲企業若是考慮在項目中使用 Redis,由於大部分會拿它做爲緩存來使用,那麼通常都會是集羣的方式出現,至少確定也會部署兩臺Redis服務器。因此咱們使用Redis來實現接口的冪等性是最適合不過的了。

狀態機

對於不少業務是有一個業務流轉狀態的,每一個狀態都有前置狀態和後置狀態,以及最後的結束狀態。例如流程的待審批,審批中,駁回,從新發起,審批經過,審批拒絕。訂單的待提交,待支付,已支付,取消。

以訂單爲例,已支付的狀態的前置狀態只能是待支付,而取消狀態的前置狀態只能是待支付,經過這種狀態機的流轉咱們就能夠控制請求的冪等。

public enum OrderStatusEnum {

    UN_SUBMIT(0, 0, "待提交"),
    UN_PADING(0, 1, "待支付"),
    PAYED(1, 2, "已支付待發貨"),
    DELIVERING(2, 3, "已發貨"),
    COMPLETE(3, 4, "已完成"),
    CANCEL(0, 5, "已取消"),
    ;

    //前置狀態
    private int preStatus;

    //狀態值
    private int status;

    //狀態描述
    private String desc;

    OrderStatusEnum(int preStatus, int status, String desc) {
        this.preStatus = preStatus;
        this.status = status;
        this.desc = desc;
    }

    //...
}
複製代碼

假設當前狀態是已支付,這時候若是支付接口又接收到了支付請求,則會拋異常或拒絕這次處理。

總結

經過以上的瞭解咱們能夠知道,針對不一樣的業務場景咱們須要靈活的選擇冪等性的實現方式。

例如防止相似於前端重複提交、重複下單的場景就能夠經過 Token 的機制實現,而那些有狀態前置和後置轉換的場景則能夠經過狀態機的方式實現冪等性,對於那些重複消費和接口重試的場景則使用數據庫惟一索引的方式實現更合理。


相關文章
相關標籤/搜索