天天看點

Spring Boot Spring Cloud 微服務 分布式項目 實作接口幂等性的 4 種方案

Spring Boot Spring Cloud 微服務 分布式項目 實作接口幂等性的 4 種方案

文章目錄

  • ​​Spring Boot Spring Cloud 微服務 分布式項目 實作接口幂等性的 4 種方案​​
  • ​​一、什麼是幂等性​​
  • ​​二、什麼是接口幂等性​​
  • ​​三、為什麼需要實作幂等性​​
  • ​​四、引入幂等性後對系統的影響​​
  • ​​五、Restful API 接口的幂等性​​
  • ​​六、如何實作幂等性​​
  • ​​**方案二:資料庫樂觀鎖**​​
  • ​​**方案三:防重 Token 令牌**​​
  • ​​**方案四、下遊傳遞唯一序列号**​​
  • ​​七、實作接口幂等示例​​
  • ​​**1、Maven 引入相關依賴**​​
  • ​​**2、配置連接配接 Redis 的參數**​​
  • ​​**3、建立與驗證 Token 工具類**​​
  • ​​**4、建立測試的 Controller 類**​​
  • ​​**5、建立 SpringBoot 啟動類**​​
  • ​​**6、寫測試類進行測試**​​
  • ​​**八、最後總結**​​
  • ​​結語​​

一、什麼是幂等性

幂等是一個數學與計算機學概念,在數學中某一進制運算為幂等時,其作用在任一進制素兩次後會和其作用一次的結果相同。

[在計算機中程式設計中,一個幂等操作的特點是其任意多次執行所産生的影響均與一次執行的影響相同。幂等函數或幂等方法是指可以使用相同參數重複執行,并能獲得相同結果的函數。這些函數不會影響系統狀态,也不用擔心重複執行會對系統造成改變。]

二、什麼是接口幂等性

在HTTP/1.1中,對幂等性進行了定義。它描述了一次和多次請求某一個資源對于資源本身應該具有同樣的結果(網絡逾時等問題除外),即第一次請求的時候對資源産生了副作用,但是以後的多次請求都不會再對資源産生副作用。

這裡的副作用是不會對結果産生破壞或者産生不可預料的結果。也就是說,其任意多次執行對資源本身所産生的影響均與一次執行的影響相同。

三、為什麼需要實作幂等性

在接口調用時一般情況下都能正常傳回資訊不會重複送出,不過在遇見以下情況時可以就會出現問題,如:

  • 前端重複送出表單:在填寫一些表格時候,使用者填寫完成送出,很多時候會因網絡波動沒有及時對使用者做出送出成功響應,緻使使用者認為沒有成功送出,然後一直點送出按鈕,這時就會發生重複送出表單請求。
  • 使用者惡意進行刷單:例如在實作使用者投票這種功能時,如果使用者針對一個使用者進行重複送出投票,這樣會導緻接口接收到使用者重複送出的投票資訊,這樣會使投票結果與事實嚴重不符。
  • 接口逾時重複送出:很多時候 HTTP 用戶端工具都預設開啟逾時重試的機制,尤其是第三方調用接口時候,為了防止網絡波動逾時等造成的請求失敗,都會添加重試機制,導緻一個請求送出多次。
  • 消息進行重複消費:當使用 MQ 消息中間件時候,如果發生消息中間件出現錯誤未及時送出消費資訊,導緻發生重複消費。

使用幂等性最大的優勢在于使接口保證任何幂等性操作,免去因重試等造成系統産生的未知的問題。

四、引入幂等性後對系統的影響

幂等性是為了簡化用戶端邏輯處理,能放置重複送出等操作,但卻增加了服務端的邏輯複雜性和成本,其主要是:

  • 把并行執行的功能改為串行執行,降低了執行效率。
  • 增加了額外控制幂等的業務邏輯,複雜化了業務功能;

是以在使用時候需要考慮是否引入幂等性的必要性,根據實際業務場景具體分析,除了業務上的特殊要求外,一般情況下不需要引入的接口幂等性。

五、Restful API 接口的幂等性

現在流行的 Restful 推薦的幾種 HTTP 接口方法中,分别存在幂等行與不能保證幂等的方法,如下:

  • √ 滿足幂等
  • x 不滿足幂等
  • - 可能滿足也可能不滿足幂等,根據實際業務邏輯有關
方法類型 是否幂等 描述
Get Get 方法用于擷取資源。其一般不會也不應當對系統資源進行改變,是以是幂等的。
Post × Post 方法一般用于建立新的資源。其每次執行都會新增資料,是以不是幂等的。
Put - Put 方法一般用于修改資源。該操作則分情況來判斷是不是滿足幂等,更新操作中直接根據某個值進行更新,也能保持幂等。不過執行累加操作的更新是非幂等。
Delete - Delete 方法一般用于删除資源。該操作則分情況來判斷是不是滿足幂等,當根據唯一值進行删除時,删除同一個資料多次執行效果一樣。不過需要注意,帶查詢條件的删除則就不一定滿足幂等了。例如在根據條件删除一批資料後,這時候新增加了一條資料也滿足條件,然後又執行了一次删除,那麼将會導緻新增加的這條滿足條件資料也被删除。

六、如何實作幂等性

**方案一:**資料庫唯一主鍵

方案描述

資料庫唯一主鍵的實作主要是利用資料庫中主鍵唯一限制的特性,一般來說唯一主鍵比較适用于“插入”時的幂等性,其能保證一張表中隻能存在一條帶該唯一主鍵的記錄。

使用資料庫唯一主鍵完成幂等性時需要注意的是,該主鍵一般來說并不是使用資料庫中自增主鍵,而是使用分布式 ID 充當主鍵(可以參考 Java 中分布式 ID 的設計方案 這篇文章),這樣才能能保證在分布式環境下 ID 的全局唯一性。

适用操作:

  • 插入操作
  • 删除操作

使用限制:

  • 需要生成全局唯一主鍵 ID;

主要流程:

Spring Boot Spring Cloud 微服務 分布式項目 實作接口幂等性的 4 種方案

主要流程:

  • ① 用戶端執行建立請求,調用服務端接口。
  • ② 服務端執行業務邏輯,生成一個分布式 ID,将該 ID 充當待插入資料的主鍵,然後執資料插入操作,運作對應的 SQL 語句。
  • ③ 服務端将該條資料插入資料庫中,如果插入成功則表示沒有重複調用接口。如果抛出主鍵重複異常,則表示資料庫中已經存在該條記錄,傳回錯誤資訊到用戶端。

方案二:資料庫樂觀鎖

方案描述:

[資料庫樂觀鎖方案一般隻能适用于執行“更新操作”的過程,我們可以提前在對應的資料表中多添加一個字段,充當目前資料的版本辨別。這樣每次對該資料庫該表的這條資料執行更新時,都會将該版本辨別作為一個條件,值為上次待更新資料中的版本辨別的值。]

适用操作:

  • 更新操作

使用限制:

  • 需要資料庫對應業務表中添加額外字段;

描述示例:

Spring Boot Spring Cloud 微服務 分布式項目 實作接口幂等性的 4 種方案

例如,存在如下的資料表中:

id name price
1 小米手機 1000
2 蘋果手機 2500
3 華為手機 1600

為了每次執行更新時防止重複更新,确定更新的一定是要更新的内容,我們通常都會添加一個 version 字段記錄目前的記錄版本,這樣在更新時候将該值帶上,那麼隻要執行更新操作就能确定一定更新的是某個對應版本下的資訊。

id name price version
1 小米手機 1000 10
2 蘋果手機 2500 21
3 華為手機 1600 5

這樣每次執行更新時候,都要指定要更新的版本号,如下操作就能準确更新 version=5 的資訊:

UPDATE my_table SET price=price+50,version=version+1 WHERE id=1 AND version=5      

上面 WHERE 後面跟着條件 id=1 AND version=5 被執行後,id=1 的 version 被更新為 6,是以如果重複執行該條 SQL 語句将不生效,因為 id=1 AND version=5 的資料已經不存在,這樣就能保住更新的幂等,多次更新對結果不會産生影響。

方案三:防重 Token 令牌

方案描述:

[針對用戶端連續點選或者調用方的逾時重試等情況,例如送出訂單,此種操作就可以用 Token 的機制實作防止重複送出。簡單的說就是調用方在調用接口的時候先向後端請求一個全局 ID(Token),請求的時候攜帶這個全局 ID 一起請求(Token 最好将其放到 Headers 中),後端需要對這個 Token 作為 Key,使用者資訊作為 Value 到 Redis 中進行鍵值内容校驗,如果 Key 存在且 Value 比對就執行删除指令,然後正常執行後面的業務邏輯。如果不存在對應的 Key 或 Value 不比對就傳回重複執行的錯誤資訊,這樣來保證幂等操作。]

适用操作:

  • 插入操作
  • 更新操作
  • 删除操作

使用限制:

  • 需要生成全局唯一 Token 串;
  • 需要使用第三方元件 Redis 進行資料效驗;

主要流程:

Spring Boot Spring Cloud 微服務 分布式項目 實作接口幂等性的 4 種方案
  • ① 服務端提供擷取 Token 的接口,該 Token 可以是一個序列号,也可以是一個分布式 ID 或者 UUID 串。
  • ② 用戶端調用接口擷取 Token,這時候服務端會生成一個 Token 串。
  • ③ 然後将該串存入 Redis 資料庫中,以該 Token 作為 Redis 的鍵(注意設定過期時間)。
  • ④ 将 Token 傳回到用戶端,用戶端拿到後應存到表單隐藏域中。
  • ⑤ 用戶端在執行送出表單時,把 Token 存入到 Headers 中,執行業務請求帶上該 Headers。
  • ⑥ 服務端接收到請求後從 Headers 中拿到 Token,然後根據 Token 到 Redis 中查找該 key 是否存在。
  • ⑦ 服務端根據 Redis 中是否存該 key 進行判斷,如果存在就将該 key 删除,然後正常執行業務邏輯。如果不存在就抛異常,傳回重複送出的錯誤資訊。
注意,在并發情況下,執行 Redis 查找資料與删除需要保證原子性,否則很可能在并發下無法保證幂等性。其實作方法可以使用分布式鎖或者使用 Lua 表達式來登出查詢與删除操作。

方案四、下遊傳遞唯一序列号

方案描述:

所謂請求序列号,其實就是每次向服務端請求時候附帶一個短時間内唯一不重複的序列号,該序列号可以是一個有序 ID,也可以是一個訂單号,一般由下遊生成,在調用上遊服務端接口時附加該序列号和用于認證的 ID。

當上遊伺服器收到請求資訊後拿取該 序列号 和下遊 認證ID 進行組合,形成用于操作 Redis 的 Key,然後到 Redis 中查詢是否存在對應的 Key 的鍵值對,根據其結果:

  • 如果存在,就說明已經對該下遊的該序列号的請求進行了業務處理,這時可以直接響應重複請求的錯誤資訊。
  • 如果不存在,就以該 Key 作為 Redis 的鍵,以下遊關鍵資訊作為存儲的值(例如下遊商傳遞的一些業務邏輯資訊),将該鍵值對存儲到 Redis 中 ,然後再正常執行對應的業務邏輯即可。

适用操作:

  • 插入操作
  • 更新操作
  • 删除操作

使用限制:

  • 要求第三方傳遞唯一序列号;
  • 需要使用第三方元件 Redis 進行資料效驗;

主要流程:

Spring Boot Spring Cloud 微服務 分布式項目 實作接口幂等性的 4 種方案

主要步驟:

  • ① 下遊服務生成分布式 ID 作為序列号,然後執行請求調用上遊接口,并附帶“唯一序列号”與請求的“認證憑據ID”。
  • ② 上遊服務進行安全效驗,檢測下遊傳遞的參數中是否存在“序列号”和“憑據ID”。
  • ③ 上遊服務到 Redis 中檢測是否存在對應的“序列号”與“認證ID”組成的 Key,如果存在就抛出重複執行的異常資訊,然後響應下遊對應的錯誤資訊。如果不存在就以該“序列号”和“認證ID”組合作為 Key,以下遊關鍵資訊作為 Value,進而存儲到 Redis 中,然後正常執行接來來的業務邏輯。
上面步驟中插入資料到 Redis 一定要設定過期時間。這樣能保證在這個時間範圍内,如果重複調用接口,則能夠進行判斷識别。如果不設定過期時間,很可能導緻資料無限量的存入 Redis,緻使 Redis 不能正常工作。

七、實作接口幂等示例

這裡使用防重 Token 令牌方案,該方案能保證在不同請求動作下的幂等性,實作邏輯可以看上面寫的”防重 Token 令牌”方案,接下來寫下實作這個邏輯的代碼。

1、Maven 引入相關依賴

這裡使用 Maven 工具管理依賴,這裡在 pom.xml 中引入 SpringBoot、Redis、lombok 相關依賴。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.4.RELEASE</version>
    </parent>

    <groupId>mydlq.club</groupId>
    <artifactId>springboot-idempotent-token</artifactId>
    <version>0.0.1</version>
    <name>springboot-idempotent-token</name>
    <description>Idempotent Demo</description>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <!--springboot web-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!--springboot 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>
        <!--lombok-->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>      

2、配置連接配接 Redis 的參數

在 application 配置檔案中配置連接配接 Redis 的參數。Spring Boot 基礎就不介紹了,最新教程推薦看下面的教程。

如下:

spring:
  redis:
    ssl: false
    host: 127.0.0.1
    port: 6379
    database: 0
    timeout: 1000
    password:
    lettuce:
      pool:
        max-active: 100
        max-wait: -1
        min-idle: 0
        max-idle: 20      

3、建立與驗證 Token 工具類

建立用于操作 Token 相關的 Service 類,裡面存在 Token 建立與驗證方法,其中:

  • Token 建立方法:使用 UUID 工具建立 Token 串,設定以 “idempotent_token:“+“Token串” 作為 Key,以使用者資訊當成 Value,将資訊存入 Redis 中。
  • Token 驗證方法:接收 Token 串參數,加上 Key 字首形成 Key,再傳入 value 值,執行 Lua 表達式(Lua 表達式能保證指令執行的原子性)進行查找對應 Key 與删除操作。執行完成後驗證指令的傳回結果,如果結果不為空且非0,則驗證成功,否則失敗。
import java.util.Arrays;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Service;

@Slf4j
@Service
public class TokenUtilService {

    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
     * 存入 Redis 的 Token 鍵的字首
     */
    private static final String IDEMPOTENT_TOKEN_PREFIX = "idempotent_token:";

    /**
     * 建立 Token 存入 Redis,并傳回該 Token
     *
     * @param value 用于輔助驗證的 value 值
     * @return 生成的 Token 串
     */
    public String generateToken(String value) {
        // 執行個體化生成 ID 工具對象
        String token = UUID.randomUUID().toString();
        // 設定存入 Redis 的 Key
        String key = IDEMPOTENT_TOKEN_PREFIX + token;
        // 存儲 Token 到 Redis,且設定過期時間為5分鐘
        redisTemplate.opsForValue().set(key, value, 5, TimeUnit.MINUTES);
        // 傳回 Token
        return token;
    }

    /**
     * 驗證 Token 正确性
     *
     * @param token token 字元串
     * @param value value 存儲在Redis中的輔助驗證資訊
     * @return 驗證結果
     */
    public boolean validToken(String token, String value) {
        // 設定 Lua 腳本,其中 KEYS[1] 是 key,KEYS[2] 是 value
        String script = "if redis.call('get', KEYS[1]) == KEYS[2] then return redis.call('del', KEYS[1]) else return 0 end";
        RedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
        // 根據 Key 字首拼接 Key
        String key = IDEMPOTENT_TOKEN_PREFIX + token;
        // 執行 Lua 腳本
        Long result = redisTemplate.execute(redisScript, Arrays.asList(key, value));
        // 根據傳回結果判斷是否成功成功比對并删除 Redis 鍵值對,若果結果不為空和0,則驗證通過
        if (result != null && result != 0L) {
            log.info("驗證 token={},key={},value={} 成功", token, key, value);
            return true;
        }
        log.info("驗證 token={},key={},value={} 失敗", token, key, value);
        return false;
    }

}      

4、建立測試的 Controller 類

建立用于測試的 Controller 類,裡面有擷取 Token 與測試接口幂等性的接口,内容如下:

import lombok.extern.slf4j.Slf4j;
import mydlq.club.example.service.TokenUtilService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

@Slf4j
@RestController
public class TokenController {

    @Autowired
    private TokenUtilService tokenService;

    /**
     * 擷取 Token 接口
     *
     * @return Token 串
     */
    @GetMapping("/token")
    public String getToken() {
        // 擷取使用者資訊(這裡使用模拟資料)
        // 注:這裡存儲該内容隻是舉例,其作用為輔助驗證,使其驗證邏輯更安全,如這裡存儲使用者資訊,其目的為:
        // - 1)、使用"token"驗證 Redis 中是否存在對應的 Key
        // - 2)、使用"使用者資訊"驗證 Redis 的 Value 是否比對。
        String userInfo = "mydlq";
        // 擷取 Token 字元串,并傳回
        return tokenService.generateToken(userInfo);
    }

    /**
     * 接口幂等性測試接口
     *
     * @param token 幂等 Token 串
     * @return 執行結果
     */
    @PostMapping("/test")
    public String test(@RequestHeader(value = "token") String token) {
        // 擷取使用者資訊(這裡使用模拟資料)
        String userInfo = "mydlq";
        // 根據 Token 和與使用者相關的資訊到 Redis 驗證是否存在對應的資訊
        boolean result = tokenService.validToken(token, userInfo);
        // 根據驗證結果響應不同資訊
        return result ? "正常調用" : "重複調用";
    }

}      

5、建立 SpringBoot 啟動類

建立啟動類,用于啟動 SpringBoot 應用。基礎教程就不介紹了,建議看下下面的教程,很全了。

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

}      

6、寫測試類進行測試

寫個測試類進行測試,多次通路同一個接口,測試是否隻有第一次能否執行成功。

import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

@Slf4j
@SpringBootTest
@RunWith(SpringRunner.class)
public class IdempotenceTest {

    @Autowired
    private WebApplicationContext webApplicationContext;

    @Test
    public void interfaceIdempotenceTest() throws Exception {
        // 初始化 MockMvc
        MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
        // 調用擷取 Token 接口
        String token = mockMvc.perform(MockMvcRequestBuilders.get("/token")
                .accept(MediaType.TEXT_HTML))
                .andReturn()
                .getResponse().getContentAsString();
        log.info("擷取的 Token 串:{}", token);
        // 循環調用 5 次進行測試
        for (int i = 1; i <= 5; i++) {
            log.info("第{}次調用測試接口", i);
            // 調用驗證接口并列印結果
            String result = mockMvc.perform(MockMvcRequestBuilders.post("/test")
                    .header("token", token)
                    .accept(MediaType.TEXT_HTML))
                    .andReturn().getResponse().getContentAsString();
            log.info(result);
            // 結果斷言
            if (i == 0) {
                Assert.assertEquals(result, "正常調用");
            } else {
                Assert.assertEquals(result, "重複調用");
            }
        }
    }

}      

顯示如下:

[main] IdempotenceTest:  擷取的 Token 串:980ea707-ce2e-456e-a059-0a03332110b4
[main] IdempotenceTest:  第1次調用測試接口
[main] IdempotenceTest:  正常調用
[main] IdempotenceTest:  第2次調用測試接口
[main] IdempotenceTest:  重複調用
[main] IdempotenceTest:  第3次調用測試接口
[main] IdempotenceTest:  重複調用
[main] IdempotenceTest:  第4次調用測試接口
[main] IdempotenceTest:  重複調用
[main] IdempotenceTest:  第5次調用測試接口
[main] IdempotenceTest:  重複調用      

八、最後總結

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

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

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

方案名稱 适用方法 實作複雜度 方案缺點
資料庫唯一主鍵 插入操作 删除操作 簡單 - 隻能用于插入操作;- 隻能用于存在唯一主鍵場景;
資料庫樂觀鎖 更新操作 簡單 - 隻能用于更新操作;- 表中需要額外添加字段;
請求序列号 插入操作 更新操作 删除操作 簡單 - 需要保證下遊生成唯一序列号;- 需要 Redis 第三方存儲已經請求的序列号;
防重 Token 令牌 插入操作 更新操作 删除操作 适中
來源:mydlq.club/article/94      

結語

繼續閱讀