SpringBoot 接口冪等性實現的4種方案!這個我真的服氣了!

點擊藍色字免費訂閱,天天收到這樣的好信息


私活接單qq羣:716817407
前端

目錄

  • 什麼是冪等性
  • 什麼是接口冪等性
  • 爲何須要實現冪等性
  • 引入冪等性後對系統的影響
  • Restful API 接口的冪等性
  • 如何實現冪等性
    • 方案一:數據庫惟一主鍵
    • 方案二:數據庫樂觀鎖
    • 方案三:防重 Token 令牌
    • 方案4、下游傳遞惟一序列號
  • 實現接口冪等示例
    • Maven 引入相關依賴
    • 配置鏈接 Redis 的參數
    • 建立與驗證 Token 工具類
    • 建立測試的 Controller 類
    • 建立 SpringBoot 啓動類
    • 寫測試類進行測試
  • 最後總結

系統環境:java

  • Java JDK 版本:1.8git

  • SpringBoot 版本:2.3.4.RELEASEgithub

示例地址:web

https://github.com/my-dlq/blog-example/tree/master/springboot/springboot-idempotent-token/面試

1、什麼是冪等性

冪等是一個數學與計算機學概念,在數學中某一元運算爲冪等時,其做用在任一元素兩次後會和其做用一次的結果相同。在計算機中編程中,一個冪等操做的特色是其任意屢次執行所產生的影響均與一次執行的影響相同。redis

冪等函數或冪等方法是指可使用相同參數重複執行,並能得到相同結果的函數。這些函數不會影響系統狀態,也不用擔憂重複執行會對系統形成改變。spring

2、什麼是接口冪等性

在HTTP/1.1中,對冪等性進行了定義。它描述了一次和屢次請求某一個資源對於資源自己應該具備一樣的結果(網絡超時等問題除外),即第一次請求的時候對資源產生了反作用,可是之後的屢次請求都不會再對資源產生反作用。數據庫

這裏的反作用是不會對結果產生破壞或者產生不可預料的結果。也就是說,其任意屢次執行對資源自己所產生的影響均與一次執行的影響相同。apache

3、爲何須要實現冪等性

在接口調用時通常狀況下都能正常返回信息不會重複提交,不過在碰見如下狀況時能夠就會出現問題,如:

  • 前端重複提交表單: 在填寫一些表格時候,用戶填寫完成提交,不少時候會因網絡波動沒有及時對用戶作出提交成功響應,導致用戶認爲沒有成功提交,而後一直點提交按鈕,這時就會發生重複提交表單請求。

  • 用戶惡意進行刷單: 例如在實現用戶投票這種功能時,若是用戶針對一個用戶進行重複提交投票,這樣會致使接口接收到用戶重複提交的投票信息,這樣會使投票結果與事實嚴重不符。

  • 接口超時重複提交: 不少時候 HTTP 客戶端工具都默認開啓超時重試的機制,尤爲是第三方調用接口時候,爲了防止網絡波動超時等形成的請求失敗,都會添加劇試機制,致使一個請求提交屢次。

  • 消息進行重複消費: 當使用 MQ 消息中間件時候,若是發生消息中間件出現錯誤未及時提交消費信息,致使發生重複消費。

使用冪等性最大的優點在於使接口保證任何冪等性操做,免去因重試等形成系統產生的未知的問題。

4、引入冪等性後對系統的影響

冪等性是爲了簡化客戶端邏輯處理,能放置重複提交等操做,但卻增長了服務端的邏輯複雜性和成本,其主要是:

  • 把並行執行的功能改成串行執行,下降了執行效率。

  • 增長了額外控制冪等的業務邏輯,複雜化了業務功能;

因此在使用時候須要考慮是否引入冪等性的必要性,根據實際業務場景具體分析,除了業務上的特殊要求外,通常狀況下不須要引入的接口冪等性。

5、Restful API 接口的冪等性

如今流行的 Restful 推薦的幾種 HTTP 接口方法中,分別存在冪等行與不能保證冪等的方法,以下:

  • √ 知足冪等

  • x 不知足冪等

  • - 可能知足也可能不知足冪等,根據實際業務邏輯有關

6、如何實現冪等性

方案一:數據庫惟一主鍵

方案描述

數據庫惟一主鍵的實現主要是利用數據庫中主鍵惟一約束的特性,通常來講惟一主鍵比較適用於「插入」時的冪等性,其能保證一張表中只能存在一條帶該惟一主鍵的記錄。

使用數據庫惟一主鍵完成冪等性時須要注意的是,該主鍵通常來講並非使用數據庫中自增主鍵,而是使用分佈式 ID 充當主鍵,這樣才能能保證在分佈式環境下 ID 的全局惟一性。

適用操做:

  • 插入操做

  • 刪除操做

使用限制:

  • 須要生成全局惟一主鍵 ID;

主要流程:

主要流程:

  • ① 客戶端執行建立請求,調用服務端接口。

  • ② 服務端執行業務邏輯,生成一個分佈式 ID,將該 ID 充當待插入數據的主鍵,而後執數據插入操做,運行對應的 SQL 語句。

  • ③ 服務端將該條數據插入數據庫中,若是插入成功則表示沒有重複調用接口。若是拋出主鍵重複異常,則表示數據庫中已經存在該條記錄,返回錯誤信息到客戶端。

方案二:數據庫樂觀鎖

方案描述:

數據庫樂觀鎖方案通常只能適用於執行「更新操做」的過程,咱們能夠提早在對應的數據表中多添加一個字段,充當當前數據的版本標識。這樣每次對該數據庫該表的這條數據執行更新時,都會將該版本標識做爲一個條件,值爲上次待更新數據中的版本標識的值。

適用操做:

  • 更新操做

使用限制:

  • 須要數據庫對應業務表中添加額外字段;

描述示例:

例如,存在以下的數據表中:

爲了每次執行更新時防止重複更新,肯定更新的必定是要更新的內容,咱們一般都會添加一個 version 字段記錄當前的記錄版本,這樣在更新時候將該值帶上,那麼只要執行更新操做就能肯定必定更新的是某個對應版本下的信息。這樣每次執行更新時候,都要指定要更新的版本號,以下操做就能準確更新 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 進行數據效驗;

主要流程:

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

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

  • ③ 而後將該串存入 Redis 數據庫中,以該 Token 做爲 Redis 的鍵(注意設置過時時間)。

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

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

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

  • ⑦ 服務端根據 Redis 中是否存該 key 進行判斷,若是存在就將該 key 刪除,而後正常執行業務邏輯。若是不存在就拋異常,返回重複提交的錯誤信息。

注意,在併發狀況下,執行 Redis 查找數據與刪除須要保證原子性,不然極可能在併發下沒法保證冪等性。其實現方法可使用分佈式鎖或者使用 Lua 表達式來註銷查詢與刪除操做。

方案4、下游傳遞惟一序列號

方案描述:

所謂請求序列號,其實就是每次向服務端請求時候附帶一個短期內惟一不重複的序列號,該序列號能夠是一個有序 ID,也能夠是一個訂單號,通常由下游生成,在調用上游服務端接口時附加該序列號和用於認證的 ID。

當上遊服務器收到請求信息後拿取該 序列號 和下游 認證ID 進行組合,造成用於操做 Redis 的 Key,而後到 Redis 中查詢是否存在對應的 Key 的鍵值對,根據其結果:

  • 若是存在,就說明已經對該下游的該序列號的請求進行了業務處理,這時能夠直接響應重複請求的錯誤信息。

  • 若是不存在,就以該 Key 做爲 Redis 的鍵,如下游關鍵信息做爲存儲的值(例以下游商傳遞的一些業務邏輯信息),將該鍵值對存儲到 Redis 中 ,而後再正常執行對應的業務邏輯便可。

適用操做:

  • 插入操做

  • 更新操做

  • 刪除操做

使用限制:

  • 要求第三方傳遞惟一序列號;

  • 須要使用第三方組件 Redis 進行數據效驗;

主要流程:

主要步驟:

  • ① 下游服務生成分佈式 ID 做爲序列號,而後執行請求調用上游接口,並附帶「惟一序列號」與請求的「認證憑據ID」。

  • ② 上游服務進行安全效驗,檢測下游傳遞的參數中是否存在「序列號」和「憑據ID」。

  • ③ 上游服務到 Redis 中檢測是否存在對應的「序列號」與「認證ID」組成的 Key,若是存在就拋出重複執行的異常信息,而後響應下游對應的錯誤信息。若是不存在就以該「序列號」和「認證ID」組合做爲 Key,如下游關鍵信息做爲 Value,進而存儲到 Redis 中,而後正常執行接來來的業務邏輯。

上面步驟中插入數據到 Redis 必定要設置過時時間。這樣能保證在這個時間範圍內,若是重複調用接口,則可以進行判斷識別。若是不設置過時時間,極可能致使數據無限量的存入 Redis,導致 Redis 不能正常工做。

7、實現接口冪等示例

這裏使用防重 Token 令牌方案,該方案能保證在不一樣請求動做下的冪等性,實現邏輯能夠看上面寫的」防重 Token 令牌」方案,接下來寫下實現這個邏輯的代碼。

一、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>

二、配置鏈接 Redis 的參數

在 application 配置文件中配置鏈接 Redis 的參數,以下:

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

三、建立與驗證 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;
    }

}

四、建立測試的 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 ? "正常調用" : "重複調用";
    }

}

五、建立 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.classargs);
    }

}

六、寫測試類進行測試

寫個測試類進行測試,屢次訪問同一個接口,測試是否只有第一次可否執行成功。

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:  重複調用

8、最後總結

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

  • 對於下單等存在惟一主鍵的,可使用「惟一主鍵方案」的方式實現。

  • 對於更新訂單狀態等相關的更新場景操做,使用「樂觀鎖方案」實現更爲簡單。

  • 對於上下游這種,下游請求上游,上游服務可使用「下游傳遞惟一序列號方案」更爲合理。

  • 相似於前端重複提交、重複下單、沒有惟一ID號的場景,能夠經過 Token 與 Redis 配合的「防重 Token 方案」實現更爲快捷。

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


打油詩

我不在意個人做品文章是被如今的人讀仍是由子孫後代來讀。既然上帝花了六千年來等一位觀察者,我能夠花上一個世紀來等待讀者。
私活接單qq羣:716817407
 

永久激活方案~

2020-07-29

spring 狀態機

2020-05-12

mybatis用到的設計模式

2020-07-02

jvm高級面試題(必須看)

2020-07-23

MySQL索引實現原理分析

2020-05-19

Spring中的用到的設計模式

2020-04-23

Spring 和 SpringBoot 之間到底有啥區別?

2020-05-29

如何快速搭建一個免費的 鑑黃 平臺

2020-08-15

美國也就那麼回事吧

2020-08-15

5T的Java視頻教程所有免費獲取

2020-08-14


本文分享自微信公衆號 - Java小白學心理(gh_9a909fa2fb55)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索