寫一個通用的冪等元件,我覺得很有必要

猿天地發表於2020-09-09

本文目錄

  1. 背景

  2. 簡單冪等實現

2.1 資料庫記錄判斷

2.2 併發問題解決

  1. 通用冪等實現

3.1 設計方案

3.1.1 通用儲存

3.1.2 使用簡單

3.1.3 支援註解

3.1.4 多級儲存

3.1.5 併發讀寫

3.1.6 執行流程

3.2 冪等介面

3.3 冪等註解

3.4 自動區分重複請求

3.5 儲存結構

3.6 原始碼地址

背景

回答群友的問題:冪等有沒有什麼通用的方案和實踐?

關於什麼是冪等,本文就不再闡述了。相信大家都知道,並且也都遇到過類似的問題以及有自己的一套解決方案。

基本上所有業務系統中的冪等都是各自進行處理,也不是說不能統一處理,統一處理的話需要考慮的內容會比較多。

我個人認為核心的業務還是適合業務方自己去處理,比如訂單支付,會有個支付記錄表,一個訂單隻能被支付一次,通過支付記錄表就可以達到冪等的效果。

還有一些不是核心的業務,但是也有冪等的需求。比如網路問題,多次重試。使用者點選多次等場景。這種場景下還是需要一個通用的冪等框架來處理,會讓業務開發更加簡單。

簡單冪等實現

冪等的實現其實並不複雜,方案也有很多種,首先介紹下基於資料庫記錄的方案來實現,後面再介紹通用方案。

資料庫記錄判斷

以文章開頭講的支付場景來舉例。業務場景是一個訂單隻能支付一次,所以我們在支付之前會判斷這個訂單有沒有支付過,如果沒有支付過則進行支付,如果支付過了,就反正支付成功,冪等。

這種方式需要有一個額外的表來儲存做過的動作,才能判斷之前有沒有做過這件事情。

就好比你年齡大了,然後還是單身的技術宅。這個時候你家裡著急了呀,你老媽天天給你介紹小姐姐。你每個週末都要打扮的非常帥氣,去見你老媽給你介紹的小姐姐。

去之前你得記錄下吧,8 月第一週我見的 XXX, 第二週我見的 YYY, 如果第三週又讓你去見 XXX, 如果這個時候你不喜歡 XXX, 你會翻出你的小本本看下,這個之前見過了,沒必要再見了,不然見了多尷尬啊。

併發問題解決

通過查詢支付記錄,判斷能否進行支付在業務邏輯上沒一點問題。但是在併發場景就會有問題。

1001 的訂單發起了兩次支付請求,當前兩個請求同時查詢支付記錄,都沒有查詢到,然後都開始走支付的邏輯,最後發現同一個訂單支付了兩次,這就是併發導致的冪等問題。

併發解決的方案也有很多種,簡單點的直接用資料庫的唯一索引解決,稍微麻煩點的都會用分散式鎖來對同一個資源進行加鎖。

比如我們對訂單 1001 進行加鎖,如果同時發起了兩次支付請求,那麼同一時間只能有一個請求可以獲取鎖,另一個請求獲取不到鎖可以直接失敗,也可以等待前面的請求執行完成。

如果等待前面的請求執行完成,接著往下處理,就能查到 1001 已經支付過了,直接返回支付成功了。

通用冪等實現

為了能夠讓大家更專注於業務功能的開發,簡單場景的冪等操作我認為可以進行統一封裝來處理,下面介紹一下通用冪等的實現。

設計方案

通用儲存

一般我們在程式內部做冪等的話都是先查詢,然後根據查詢的結果做對應的操作。同時會對相同的資源進行加鎖來避免併發問題。

加鎖是通用的,不通用的部分就是判斷這個操作之前有沒有操作過,所以我們需要有一個通用的儲存來記錄所有的操作。

使用簡單

提供通用的冪等元件,注入對應的類即可實現冪等,遮蔽加鎖,記錄判斷等邏輯。

支援註解

除了通過程式碼的方式來進行冪等的控制,同時為了讓使用更加簡單,還需要提供註解的方式來支援冪等,使用者只需要在對應的業務方法上增加對應的註解,即可實現冪等。

多級儲存

需要支援多級儲存,比如一級儲存可以用 Redis 來實現,優點是效能高,適用於 90%的場景。因為很多場景都是為了防止短時間內請求重複導致的問題,通過設定一定的失效時間,讓 Key 自動失效。

二級儲存可以支援 Mysql, Mongo 等資料庫,適用於時間長或者永久儲存的場景。

可以通過配置指定一級儲存用什麼,二級儲存用什麼。這個場景非常適合用策略模式來實現。

併發讀寫

引入多級儲存勢必會涉及到併發讀寫的場景,可以支援兩種方式,順序和併發。

順序就是先寫一級儲存,再寫二級儲存,讀也是一樣。這樣的問題在於效能會有點損耗。

併發就是多執行緒同時寫入,同時讀取,提高效能。

冪等執行流程

冪等介面

冪等介面定義

public interface DistributedIdempotent {
    /**
     * 冪等執行
     * @param key 冪等Key
     * @param lockExpireTime 鎖的過期時間
     * @param firstLevelExpireTime 一級儲存過期時間
     * @param secondLevelExpireTime 二級儲存過期時間
     * @param timeUnit 儲存時間單位
     * @param readWriteType 讀寫型別
     * @param execute 要執行的邏輯
     * @param fail Key已經存在,冪等攔截後的執行邏輯
     * @return
     */
    <T> T execute(String key, int lockExpireTime, int firstLevelExpireTime, int secondLevelExpireTime, TimeUnit timeUnit, ReadWriteTypeEnum readWriteType, Supplier<T> execute, Supplier<T> fail);
}

使用方式

/**
 * 程式碼方式冪等-有返回值
 * @param key
 * @return
 */
public String idempotentCode(String key) {
    return distributedIdempotent.execute(key, 10, 10, 50, TimeUnit.SECONDS, ReadWriteTypeEnum.ORDER, () -> {
        System.out.println("進來了。。。。");
        return "success";
    }, () -> {
        System.out.println("重複了。。。。");
        return "fail";
    });
}

冪等註解

使用註解,能夠讓使用更加簡單,比如我們的事務處理,快取等都使用了註解來簡化邏輯。

冪等的場景也可以定義通用的註解來簡化使用難度,在需要支援冪等的業務方法上增加註解,配置基本資訊。

idempotentHandler 是觸發冪等規則後執行的方法,也就是我們用程式碼實現冪等時候的 Supplier fail 引數。實現是用的阿里 Sentinel 限流,熔斷後的處理那套邏輯。

在冪等的場景下,如果是重複執行,通常返回跟正常執行一樣的結果即可。

/**
 * 註解方式冪等-指定冪等規則觸發後執行的方法
 * @param key
 */
@Idempotent(spelKey = "#key", idempotentHandler = "idempotentHandler", readWriteType = ReadWriteTypeEnum.PARALLEL, secondLevelExpireTime = 60)
public void idempotent(String key) {
    System.out.println("進來了。。。。");
}
public void idempotentHandler(String key, IdempotentException e) {
    System.out.println(key + ":idempotentHandler已經執行過了。。。。");
}

自動區分重複請求

程式碼方式處理冪等,需要傳入冪等的 Key,註解方式處理冪等,支援配置 Key,支援 SPEL 表示式。這兩種都是需要在使用的時候就確定好根據什麼來作為冪等的唯一性判斷。

還有一種冪等的場景是比較常見的,就是防止重複提交或者網路問題超時重試。同樣的操作會請求多次,這種場景下可以在操作之前先申請一個唯一的 ID,每次請求的時候帶給後端,這樣就能標識整個請求的唯一性。

我目前做了一個自動生成唯一標識的功能,簡單來說就是根據請求的資訊進行 MD5,如果 MD5 值沒有變化就認為是同一次請求。

需要進行 MD5 的內容有請求 URL 引數,請求體,請求頭資訊。請求頭的資訊在沒有指定使用者相關 Key 的場景下會進行全部拼接,如果配置了請求頭 userId 為使用者的標識,那麼只會用 userId。

會在請求的入口處進行冪等 Key 的自動生成,如果在使用冪等註解的時候沒有指定 spelKey, 就會使用自動生成的 Key。

儲存結構

Redis: 使用 String 型別儲存,Key 是冪等 Key, Value 預設為 1。

Mysql: 需要建立一張記錄表。(過期的資料需要定時清理,也可以永久儲存)

CREATE TABLE `idempotent_record` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
  `key` varchar(50) NULL DEFAULT '',
  `value` varchar(50) NOT NULL DEFAULT '',
  `expireTime` timestamp NOT NULL COMMENT '過期時間',
  `addTime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '建立時間',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='冪等記錄';

Mongo: 欄位跟 Mysql 一樣,轉換成 Json 格式即可。Mongo 會自動建立集合。

碼字不易,可以的話來個三連擊,感謝!

關於作者:尹吉歡,簡單的技術愛好者,《Spring Cloud 微服務-全棧技術與案例解析》, 《Spring Cloud 微服務 入門 實戰與進階》作者, 公眾號猿天地發起人。

微信搜尋 猿天地 回覆 kitty 獲取原始碼

相關文章