分布式服務 API 的冪等設計方案 & Spring Boot + Redis 攔截器實現實例

分布式服務 API 的冪等設計方案 & Spring Boot + Redis 攔截器實現實例

什么是冪等?

簡單講,冪等性是指相同的參數調用同一個 API,執行一次或多次效果一樣。

在函數式編程里面,這叫“無副作用”,Pure Function。

用業務的語言將,就是:對于同一筆業務操作,不管調用多少次,得到的結果都是一樣的。

問題場景

假如你有個服務提供一個接口,結果這個服務部署在了5臺機器上,接著有個接口就是付款接口。

然后用戶在前端上操作的時候,不知道為啥,總之就是一個訂單不小心發起了兩次支付請求,然后這倆請求分散在了這個服務部署的不同的機器上,結果造成一個訂單扣款扣兩次。

所謂冪等性,就是說一個接口,多次發起同一個請求,你這個接口得保證結果是準確的,比如不能多扣款,不能多插入一條數據,不能將統計值多加了1等等。

例如一個用戶在一次購買中不能重復下單;

例如庫存剩下了1個商品,現在有10個人搶購,怎么保證不超賣;

例如在MQ的生產者是不需要保證冪等,很可能把同一條消息發送多次,需要保證MQ消費端去重,MQ消費者保證這批消息只會執行一個。

冪等的定義

服務的冪等可能劃分為2個層面,一個是從接口的請求層面,一個是從業務層面考慮。

請求層面:

從請求層面考慮,就是一個接口得保證請求一個和請求多次得到的效果是一致的。 如果用數據表達式是這樣的

f...f(f(x)) = f(x) 

x是參數
f是執行函數

把相同的參數傳給執行函數,不管執行了多少次,結果是一致的。

超時重試機制

場景:微服務A中調用微服務B中的接口,會有三種結果出現,即成功、失敗、超時。

成功和失敗兩種結果非常明確,如果是成功,那么表示此次調用是正常的。如果是失敗,那么表示此次調用是失敗的,可以由調用的發起方來根據失敗的結果決定接下來要做的事情。

但是超時就是一個非常不明確的事情了, 有可能是微服務B中的邏輯已經成功執行完成,但是返回成功的結果的網絡傳輸過程中產生了超時;也有可能是微服務B中的邏輯執行中超時,比如插入數據庫數據的過程中超時;也有可能是執行失敗了,但是返回失敗的結果的網絡傳輸過程中產生了超時。總之,業務執行過程中,產生了超時,如何處理超時是最讓開發人員頭疼的問題。

如果接口僅僅只是查詢數據,那么超時后重試即可。

如果接口是刪除數據,哪怕是第一次執行刪除成功了但是返回超時,那么第二次重試執行一次刪除操作也不會造成什么影響。但是刪除要注意ABA的問題,即上一次執行刪除成功了但是返回了超市,在第二次重試執行前,又插入了同樣的一條數據,那么第二次重試執行就會把本不應該刪除的數據給刪除了。當然這種場景其實在很多業務流程上不會出現,也可以避免,甚至是就算會出現,也可以針對性的去處理這種情況,消除對業務上的影響。

如果接口是簡單的更新操作,哪怕是上一次執行更新成功但是返回超時,那么第二次重試執行一次更新也是沒有關系的。當然,也會出現刪除的時候ABA的問題。

如果接口是增加數據,哪怕是第一次執行成功了但是返回超時,那么第二次重試執行就可能會出現同一筆數據被插入兩次,當然這種情況,也是可以規避的,可以用數據庫的UK來保證一條業務數據只會生成一條數據。

所以,超時重試就需要接口冪等來支持。

重復數據或數據不一致

產生重復數據或數據不一致(假定程序業務代碼沒問題),絕大部分就是發生了重復的請求,重復請求是指同一個請求因為某些原因被多次提交。導致這個情況會有幾種場景:

1)微服務場景,在我們傳統應用架構中調用接口,要么成功,要么失敗。但是在微服務架構下,會有第三個情況【未知】,也就是超時。如果超時了,微服務框架會進行重試。

2)用戶交互的時候多次點擊。如:快速點擊按鈕多次。

3)MQ消息中間件,消息重復消費

4)第三方平臺的接口(如:支付成功回調接口),因為異常也會導致多次異步回調

5)其他中間件/應用服務根據自身的特性,也有可能進行重試。

我們知道了發生的原因,本質就是多次請求了,那如何解決呢?

導致非冪等原因

先了解下為何會出現不冪等的原因,因為retry重試,如果取消retry機制,是否就能杜絕不冪等呢,答應應該是肯定的,但取消retry是否現實,我們來看看究竟在什么場合會出現retry。

用戶進行下訂單,調用下單接口超時,調用方又發起一次創建下單接口。

用戶下單進行扣減庫存,調用扣減庫存接口超時了,調用方又發起一次扣減庫存接口。

下單完畢后,生產者發送一條MQ,MQ超時沒有及時響應ACK,生產者又再發送一條MQ,消費者連續就收到了兩條MQ。

從整個系統或業務層面其實很難去做到去retry,所以在一些接口的冪等性還是需要我們自己來做。

冪等作用范圍

讀/寫請求層面范圍冪等

讀沒有造成數據的改變,只有寫請求才會造成數據改變。

架構層面范圍冪等

在哪些層會造成數據的改變:反向代理?網關?業務邏輯?數據訪問?

數據訪問層哪些操作需要冪等

從數據層面出發,數據訪問層 也就提供了 CRUD 四個請求層面,先站在數據層出發,看看是否可以對數據訪問層進行一定改造讓數據訪問層達到冪等性

業務層面的冪等

上面從數據層面對CRUD做冪等處理,不過冪等性更多是考慮到業務場景。

為什么需要服務的冪等?

在互聯網中由于網絡的不穩定和一些業務重復確認設計,對一個接口的調用存在重試的機制,為了確保執行同一個請求執行一次和執行多次的效果是一樣的,所以就存在了冪等的設計。

舉個例子,如果在轉賬的交易中,A給B進行一筆轉賬,如果沒有冪等性,很可能就因為各種原因導致了A給B進行了多筆轉賬,在銀行系統中,這個就是重大的災難。服務的冪等可能劃分為2個層面,一個是從接口的請求層面,一個是從業務層面考慮。從請求層面考慮,就是一個接口得保證請求一個和請求多次得到的效果是一致的。

怎樣保障冪等性?

保證冪等性主要是三點:

1、對于每個請求必須有一個唯一的標識,比如:訂單支付請求,肯定得包含訂單id,一個訂單id最多支付一次。

2、每次處理完請求之后,必須有一個記錄標識這個請求處理過了,比如說常見的方案是在mysql中記錄個狀態啥的,比如支付之前記錄一條這個訂單的支付流水,而且支付流水采

3、每次接收請求需要進行判斷之前是否處理過的邏輯處理,比如說,如果有一個訂單已經支付了,就已經有了一條支付流水,那么如果重復發送這個請求,則此時先插入支付流水,orderId已經存在了,唯一鍵約束生效,報錯插入不進去的。然后你就不用再扣款了。

還有一種方法,比如說使用 redis ,用 orderId 作為唯一鍵。只有成功插入這個支付流水,才可以執行實際的支付扣款。

實現冪等的方案

「如何設計」具備冪等性的服務? 從架構層面出發,哪些層會對數據造成改變,只有造成數據改變的層才需要做出冪等,很顯然,數據訪問層直接操作DB和Cache (業務邏輯層也可能訪問操作cache),從請求層面來看,我們需要對數據訪問層進行冪等操作。

關鍵點

根據定義中冪等的概念,關鍵點之一在于如何識別是同一個業務請求,所以冪等是脫離不開業務來單獨講的,并且冪等也是為了我們業務服務的。

舉個具體的例子,一個用戶可以發起多筆的售后退款申請,那么這筆退款申請的單號可以作為業務請求是不是同一個的區分憑證,也就是說為這個冪等增加這樣的一個冪等號,如果兩次請求都是這樣同一個售后單號,那么就說明這兩次是同一個業務請求,只需要執行一次即可。

但是這里會有一個問題,如果我們的冪等是設計給很多業務使用的,那么冪等號最好是脫離具體業務單號的生成規則,由自己來生成和分配冪等號。

基本結論:

1.實現冪等性常見的方式有:悲觀鎖(for update)、樂觀鎖(version)、唯一約束(uk)

2.幾種方式,按照最優排序:樂觀鎖 > 唯一約束 > 悲觀鎖

番外篇:消息中間件中的冪等設計

其實,在消息中間件中,消息的冪等性設計也是很重要的一部分。對每條消息,MQ系統內部必須生成一個inner-msg-id,作為去重和冪等的依據,這個內部消息ID的特性是:

(1)全局唯一

(2)MQ生成,具備業務無關性,對消息發送方和消息接收方屏蔽

有了這個inner-msg-id,就能保證,消息重復發送,也只有1條消息落到 MQ-server 的DB中,實現冪等。

為了保證業務冪等性,業務消息體中,必須有一個biz-id,作為去重和冪等的依據,這個業務ID的特性是:

(1)對于同一個業務場景,全局唯一

(2)由業務消息發送方生成,業務相關,對MQ透明

(3)由業務消息消費方負責判重,以保證冪等

最常見的業務ID有:支付ID,訂單ID,帖子ID等。

方案詳細設計

我們以對接支付寶充值為例,來分析支付回調接口如何設計?

如果我們系統中對接過支付寶充值功能的,我們需要給支付寶提供一個回調接口:支付寶回調信息中會攜帶

  • out_trade_no【商戶訂單號】
  • trade_no【支付寶交易號】)

其中,trade_no在支付寶中是唯一的,out_trade_no 在商戶系統中是唯一的。

回調接口實現有以下實現方式。

方式1(普通方式)

過程如下:

1.接收到支付寶支付成功請求
2.根據trade_no查詢當前訂單是否處理過
3.如果訂單已處理直接返回,若未處理,繼續向下執行
4.開啟本地事務
5.本地系統給用戶加錢
6.將訂單狀態置為成功
7.提交本地事務

上面的過程,對于同一筆訂單,如果支付寶同時通知多次,會出現什么問題?當多次通知同時到達第2步時候,查詢訂單都是未處理的,會繼續向下執行,最終本地會給用戶加兩次錢。

此方式適用于單機其,通知按順序執行的情況,只能用于自己寫著玩玩。

方式2(jvm加鎖方式)

方式1中由于并發出現了問題,此時我們使用java中的Lock加鎖,來防止并發操作,過程如下:

1.接收到支付寶支付成功請求
2.調用java中的Lock加鎖
3.根據trade_no查詢當前訂單是否處理過
4.如果訂單已處理直接返回,若未處理,繼續向下執行
5.開啟本地事務
6.本地系統給用戶加錢
7.將訂單狀態置為成功
8.提交本地事務
9.釋放Lock鎖

分析問題:
Lock只能在一個jvm中起效,如果多個請求都被同一套系統處理,上面這種使用Lock的方式是沒有問題的,不過互聯網系統中,多數是采用集群方式部署系統,同一套代碼后面會部署多套,如果支付寶同時發來多個通知經過負載均衡轉發到不同的機器,上面的鎖就不起效了。此時對于多個請求相當于無鎖處理了,又會出現方式1中的結果。此時我們需要分布式鎖來做處理。

方式3(悲觀鎖方式)

使用數據庫中悲觀鎖實現。悲觀鎖類似于方式二中的Lock,只不過是依靠數據庫來實現的。數據中悲觀鎖使用for update來實現,過程如下:

1.接收到支付寶支付成功請求
2.打開本地事物
3.查詢訂單信息并加悲觀鎖

select * from t_order where order_id = trade_no for update;

4.判斷訂單是已處理
5.如果訂單已處理直接返回,若未處理,繼續向下執行
6.給本地系統給用戶加錢
7.將訂單狀態置為成功
8.提交本地事物

重點在于for update,對for update,做一下說明:

1.當線程A執行for update,數據會對當前記錄加鎖,其他線程執行到此行代碼的時候,會等待線程A釋放鎖之后,才可以獲取鎖,繼續后續操作。
2.事物提交時,for update獲取的鎖會自動釋放。

方式3可以正常實現我們需要的效果,能保證接口的冪等性,不過存在一些缺點:
1.如果業務處理比較耗時,并發情況下,后面線程會長期處于等待狀態,占用了很多線程,讓這些線程處于無效等待狀態,我們的web服務中的線程數量一般都是有限的,如果大量線程由于獲取for update鎖處于等待狀態,不利于系統并發操作。

方式4(樂觀鎖方式)

依靠數據庫中的樂觀鎖來實現。

通常可以用一個 version 字段,每次更新加1,更新之前先查出來這個版本號。

update t_order set pay_status = 100,version=version+1 where order_id = trade_no where version = #{version};

也可以用一個狀態字段來 status 標識有沒有更新完成。

1.接收到支付寶支付成功請求
2.查詢訂單信息

select * from t_order where order_id = trade_no;

3.判斷訂單是已處理
4.如果訂單已處理直接返回,若未處理,繼續向下執行
5.打開本地事物
6.給本地系統給用戶加錢
7.將訂單狀態置為成功,注意這塊是重點,偽代碼:

update t_order set status = 1 where order_id = trade_no where status = 0;

注意:

update t_order set status = 1 where order_id = trade_no where status = 0; 

是依靠樂觀鎖來實現的,status=0作為條件去更新,類似于java中的cas操作;關于什么是cas操作,可以移步:什么是 CAS 機制?

執行這條sql的時候,如果有多個線程同時到達這條代碼,數據內部會保證update同一條記錄會排隊執行,最終最有一條update會執行成功,其他未成功的,他們的num為0,然后根據num來進行提交或者回滾操作。

方式4(唯一約束方式)

依賴數據庫中唯一約束來實現。

我們可以創建一個表:

CREATE TABLE `t_uq_dipose` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `ref_type` varchar(32) NOT NULL DEFAULT '' COMMENT '關聯對象類型',
  `ref_id` varchar(64) NOT NULL DEFAULT '' COMMENT '關聯對象id',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uq_1` (`ref_type`,`ref_id`) COMMENT '保證業務唯一性'
) ENGINE=InnoDB;

對于任何一個業務,有一個業務類型(ref_type),業務有一個全局唯一的訂單號,業務來的時候,先查詢t_uq_dipose表中是否存在相關記錄,若不存在,繼續放行。

過程如下:

1.接收到支付寶支付成功請求
2.查詢t_uq_dipose(條件ref_id,ref_type),可以判斷訂單是否已處理

select * from t_uq_dipose where ref_type = '充值訂單' and ref_id = trade_no;

3.判斷訂單是已處理
4.如果訂單已處理直接返回,若未處理,繼續向下執行
5.打開本地事物
6.給本地系統給用戶加錢
7.將訂單狀態置為成功
8.向t_uq_dipose插入數據,插入成功,提交本地事務,插入失敗,回滾本地事務,偽代碼:

try{
    insert into t_uq_dipose (ref_type,ref_id) values ('充值訂單',trade_no);
    提交本地事務:
}catch(Exception e){
    回滾本地事務;
}

說明:
對于同一個業務,ref_type是一樣的,當并發時,插入數據只會有一條成功,其他的會違法唯一約束,進入catch邏輯,當前事務會被回滾,最終最有一個操作會成功,從而保證了冪等性操作。
關于這種方式可以寫成通用的方式,不過業務量大的情況下,t_uq_dipose插入數據會成為系統的瓶頸,需要考慮分表操作,解決性能問題。
上面的過程中向t_uq_dipose插入記錄,最好放在最后執行,原因:插入操作會鎖表,放在最后能讓鎖表的時間降到最低,提升系統的并發性。

關于消息服務中,消費者如何保證消息處理的冪等性?
每條消息都有一個唯一的消息id,類似于上面業務中的trade_no,使用上面的方式即可實現消息消費的冪等性。

方式5 防重 Token 令牌

方案描述:

針對客戶端連續點擊或者調用方的超時重試等情況,例如提交訂單,此種操作就可以用 Token 的機制實現防止重復提交。

簡單的說就是:

1、調用方在調用接口的時候先向后端請求一個全局 ID(Token),
2、請求的時候攜帶這個全局 ID 一起請求(Token 最好將其放到 Headers 中),
3、后端需要對這個 Token 作為 Key,用戶信息作為 Value 到 Redis 中進行鍵值內容校驗:
如果 Key 存在且 Value 匹配就執行刪除命令,然后正常執行后面的業務邏輯;
如果不存在對應的 Key 或 Value 不匹配就返回重復執行的錯誤信息,這樣來保證冪等操作。

適用操作:

插入操作
更新操作
刪除操作

使用限制:

需要生成全局唯一 Token 串;
需要使用第三方組件 Redis 進行數據效驗;

主要流程:

① 服務端提供獲取 Token 的接口,該 Token 可以是一個序列號,也可以是一個分布式 ID 或者 UUID 串。

② 客戶端調用接口獲取 Token,這時候服務端會生成一個 Token 串。

③ 然后將該串存入 Redis 數據庫中,以該 Token 作為 Redis 的鍵(注意設置過期時間)。

④ 將 Token 返回到客戶端,客戶端拿到后應存到表單隱藏域中。

⑤ 客戶端在執行提交表單時,把 Token 存入到 Headers 中,執行業務請求帶上該 Headers。

⑥ 服務端接收到請求后從 Headers 中拿到 Token,然后根據 Token 到 Redis 中查找該 key 是否存在。

⑦ 服務端根據 Redis 中是否存該 key 進行判斷,如果存在就將該 key 刪除,然后正常執行業務邏輯。如果不存在就拋異常,返回重復提交的錯誤信息。
注意,在并發情況下,執行 Redis 查找數據與刪除需要保證原子性,否則很可能在并發下無法保證冪等性。其實現方法可以使用分布式鎖或者使用 Lua 表達式來注銷查詢與刪除操作。

項目實戰案例: 用token機制實現接口的冪等性

1、pom.xml:主要是引入了redis相關依賴

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- spring-boot-starter-data-redis -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>
<!-- jedis -->
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
</dependency>
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
<!-- commons-lang3 -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
</dependency>
<!-- org.json/json -->
<dependency>
    <groupId>org.json</groupId>
    <artifactId>json</artifactId>
    <version>20190722</version>
</dependency>

2、application.yml:主要是配置redis

server:
  port: 6666
spring:
  application:
    name: idempotent-api
  redis:
    host: 192.168.2.43
    port: 6379

3、業務代碼:

新建一個枚舉,列出常用返回信息,如下:

@Getter
@AllArgsConstructor
public enum ResultEnum {
    REPEATREQUEST(405, "重復請求"),
    OPERATEEXCEPTION(406, "操作異常"),
    HEADERNOTOKEN(407, "請求頭未攜帶token"),
    ERRORTOKEN(408, "token正確")
    ;
    private Integer code;
    private String msg;
}

新建一個JsonUtil,當請求異常時往頁面中輸出json:

public class JsonUtil {
    private JsonUtil() {}
    public static void writeJsonToPage(HttpServletResponse response, String msg) {
        PrintWriter writer = null;
        response.setCharacterEncoding("UTF-8");
        response.setContentType("text/html; charset=utf-8");
        try {
            writer = response.getWriter();
            writer.print(msg);
        } catch (IOException e) {
        } finally {
            if (writer != null)
                writer.close();
        }
    }
}

新建一個RedisUtil,用來操作redis:

@Component
public class RedisUtil {
    
    private RedisUtil() {}

    private static RedisTemplate redisTemplate;

    @Autowired
    public  void setRedisTemplate(@SuppressWarnings("rawtypes") RedisTemplate redisTemplate) {
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        //設置序列化Value的實例化對象
        redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        RedisUtil.redisTemplate = redisTemplate;
    }
    
    /**
     * 設置key-value,過期時間為timeout秒
     * @param key
     * @param value
     * @param timeout
     */
    public static void setString(String key, String value, Long timeout) {
        redisTemplate.opsForValue().set(key, value, timeout, TimeUnit.SECONDS);
    }
    
    /**
     * 設置key-value
     * @param key
     * @param value
     */
    public static void setString(String key, String value) {
        redisTemplate.opsForValue().set(key, value);
    }
    
    /**
     * 獲取key-value
     * @param key
     * @return
     */
    public static String getString(String key) {
        return (String) redisTemplate.opsForValue().get(key);
    }
    
    /**
     * 判斷key是否存在
     * @param key
     * @return
     */
    public static boolean isExist(String key) {
        return redisTemplate.hasKey(key);
    }
    
    /**
     * 刪除key
     * @param key
     * @return
     */
    public static boolean delKey(String key) {
        return redisTemplate.delete(key);
    }
}

新建一個TokenUtil,用來生成和校驗token:生成token沒什么好說的,這里為了簡單直接用uuid生成,然后放入redis中。校驗token,如果用戶沒有攜帶token,直接返回false;如果攜帶了token,但是redis中沒有這個token,說明已經被刪除了,即已經訪問了,返回false;如果redis中有,但是redis中的token和用戶攜帶的token不一致,也返回false;有且一致,說明是第一次訪問,就將redis中的token刪除,然后返回true。

public class TokenUtil {

    private TokenUtil() {}
    
    private static final String KEY = "token";
    private static final String CODE = "code";
    private static final String MSG = "msg";
    private static final String JSON = "json";
    private static final String RESULT = "result";
    
    /**
     * 生成token并放入redis中
     * @return
     */
    public static String createToken() {
        String token = UUID.randomUUID().toString();
        RedisUtil.setString(KEY, token, 60L);
        return RedisUtil.getString(KEY);
    }
    
    /**
     * 校驗token
     * @param request
     * @return
     * @throws JSONException 
     */
    public static Map<String, Object> checkToken(HttpServletRequest request) throws JSONException {
        String headerToken = request.getHeader(KEY);
        JSONObject json = new JSONObject();
        Map<String, Object> resultMap = new HashMap<>();
        // 請求頭中沒有攜帶token,直接返回false
        if (StringUtils.isEmpty(headerToken)) {
            json.put(CODE, ResultEnum.HEADERNOTOKEN.getCode());
            json.put(MSG, ResultEnum.HEADERNOTOKEN.getMsg());
            resultMap.put(RESULT, false);
            resultMap.put(JSON, json.toString());
            return resultMap;
        }
        
        if (StringUtils.isEmpty(RedisUtil.getString(KEY))) {
            // 如果redis中沒有token,說明已經訪問成功過了,直接返回false
            json.put(CODE, ResultEnum.REPEATREQUEST.getCode());
            json.put(MSG, ResultEnum.REPEATREQUEST.getMsg());
            resultMap.put(RESULT, false);
            resultMap.put(JSON, json.toString());
            return resultMap;
        } else {
            // 如果redis中有token,就刪除掉,刪除成功返回true,刪除失敗返回false
            String redisToken = RedisUtil.getString(KEY);
            boolean result = false;
            if (!redisToken.equals(headerToken)) {
                json.put(CODE, ResultEnum.ERRORTOKEN.getCode());
                json.put(MSG, ResultEnum.ERRORTOKEN.getMsg());
            } else {
                result = RedisUtil.delKey(KEY);
                String msg = result ? null : ResultEnum.OPERATEEXCEPTION.getMsg();
                json.put(CODE, 400);
                json.put(MSG, msg);
            }
            resultMap.put(RESULT, result);
            resultMap.put(JSON, json.toString());
            return resultMap;
        }
    }
}

新建一個注解,用來標注需要進行冪等的接口:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NeedIdempotent {
}

接著要新建一個攔截器,對有@NeedIdempotent注解的方法進行攔截,進行自動冪等。

public class IdempotentInterceptor implements HandlerInterceptor{

    @Override
    public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,Object object) throws JSONException {
        // 攔截的不是方法,直接放行
        if (!(object instanceof HandlerMethod)) {
            return true;
        }
        HandlerMethod handlerMethod = (HandlerMethod) object;
        Method method = handlerMethod.getMethod();
        // 如果是方法,并且有@NeedIdempotent注解,就自動冪等
        if (method.isAnnotationPresent(NeedIdempotent.class)) {
            Map<String, Object> resultMap = TokenUtil.checkToken(httpServletRequest);
            boolean result = (boolean) resultMap.get("result");
            String json = (String) resultMap.get("json");
            if (!result) {
                JsonUtil.writeJsonToPage(httpServletResponse, json);
            }
            return result;
        } else {
            return true;
        }
    }
    
    @Override
    public void postHandle(HttpServletRequest httpServletRequest,HttpServletResponse httpServletResponse, Object o,ModelAndView modelAndView) {
    }

    @Override
    public void afterCompletion(HttpServletRequest httpServletRequest,HttpServletResponse httpServletResponse,Object o, Exception e) {
    }
}

然后將這個攔截器配置到spring中去:

@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
    
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(idempotentInterceptor())
                .addPathPatterns("/**");   
    }
    @Bean
    public IdempotentInterceptor idempotentInterceptor() {
        return new IdempotentInterceptor();
    }

}

最后新建一個controller,就可以愉快地進行測試了。

@RestController
@RequestMapping("/idempotent")
public class IdempotentApiController {

    @NeedIdempotent
    @GetMapping("/hello")
    public String hello() {
        return "are you ok?";
    }
    
    @GetMapping("/token")
    public String token() {
        return TokenUtil.createToken();
    }
}

訪問/token,不需要什么校驗,訪問/hello,就會自動冪等,每一次訪問都要先獲取token,一個token不能用兩次。

本文小結

冪等性是開發當中很常見也很重要的一個需求,尤其是支付、訂單等與金錢掛鉤的服務,保證接口冪等性尤其重要。在實際開發中,我們需要針對不同的業務場景我們需要靈活的選擇冪等性的實現方式:

對于下單等存在唯一主鍵的,可以使用 “唯一主鍵方案” 的方式實現。
對于更新訂單狀態等相關的更新場景操作,使用 “樂觀鎖方案” 實現更為簡單。
對于上下游這種,下游請求上游,上游服務可以使用 “下游傳遞唯一序列號方案” 更為合理。
類似于前端重復提交、重復下單、沒有唯一 ID 號的場景,可以通過 Token 與 Redis 配合的 “防重 Token 方案” 實現更為快捷。

上面只是給與一些建議,再次強調一下,實現冪等性需要先理解自身業務需求,根據業務邏輯來實現這樣才合理,處理好其中的每一個結點細節,完善整體的業務流程設計,才能更好的保證系統的正常運行。

參考資料

https://www.pianshen.com/article/98952043194/
http://www.lxweimin.com/p/8b77d4583bab
https://www.codercto.com/a/84322.html
http://www.itsoku.com/article/77
https://blog.csdn.net/u010372981/article/details/107599657
https://blog.csdn.net/u014756827/article/details/95195648
http://www.lxweimin.com/p/d8b30b85d8a9

?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,533評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,055評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,365評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,561評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,346評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,889評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,978評論 3 439
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,118評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,637評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,558評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,739評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,246評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,980評論 3 346
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,362評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,619評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,347評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,702評論 2 370

推薦閱讀更多精彩內容