Spring Boot 實現接口冪等性的 4 種方案
一、什麼是冪等性
冪等是一個數學與計算機學概念,在數學中某一元運算爲冪等時,其作用在任一元素兩次後會和其作用一次的結果相同。
在計算機中編程中,一個冪等操作的特點是其任意多次執行所產生的影響均與一次執行的影響相同。冪等函數或冪等方法是指可以使用相同參數重複執行,並能獲得相同結果的函數。這些函數不會影響系統狀態,也不用擔心重複執行會對系統造成改變。
二、什麼是接口冪等性
在 HTTP/1.1 中,對冪等性進行了定義。它描述了一次和多次請求某一個資源對於資源本身應該具有同樣的結果(網絡超時等問題除外),即第一次請求的時候對資源產生了副作用,但是以後的多次請求都不會再對資源產生副作用。
這裏的副作用是不會對結果產生破壞或者產生不可預料的結果。也就是說,其任意多次執行對資源本身所產生的影響均與一次執行的影響相同。
三、爲什麼需要實現冪等性
在接口調用時一般情況下都能正常返回信息不會重複提交,不過在遇見以下情況時可以就會出現問題,如:
-
前端重複提交表單: 在填寫一些表格時候,用戶填寫完成提交,很多時候會因網絡波動沒有及時對用戶做出提交成功響應,致使用戶認爲沒有成功提交,然後一直點提交按鈕,這時就會發生重複提交表單請求。
-
用戶惡意進行刷單: 例如在實現用戶投票這種功能時,如果用戶針對一個用戶進行重複提交投票,這樣會導致接口接收到用戶重複提交的投票信息,這樣會使投票結果與事實嚴重不符。
-
接口超時重複提交: 很多時候 HTTP 客戶端工具都默認開啓超時重試的機制,尤其是第三方調用接口時候,爲了防止網絡波動超時等造成的請求失敗,都會添加重試機制,導致一個請求提交多次。
-
消息進行重複消費: 當使用 MQ 消息中間件時候,如果發生消息中間件出現錯誤未及時提交消費信息,導致發生重複消費。
使用冪等性最大的優勢在於使接口保證任何冪等性操作,免去因重試等造成系統產生的未知的問題。
四、引入冪等性後對系統的影響
冪等性是爲了簡化客戶端邏輯處理,能放置重複提交等操作,但卻增加了服務端的邏輯複雜性和成本,其主要是:
-
把並行執行的功能改爲串行執行,降低了執行效率。
-
增加了額外控制冪等的業務邏輯,複雜化了業務功能;
所以在使用時候需要考慮是否引入冪等性的必要性,根據實際業務場景具體分析,除了業務上的特殊要求外,一般情況下不需要引入的接口冪等性。
五、Restful API 接口的冪等性
現在流行的 Restful 推薦的幾種 HTTP 接口方法中,分別存在冪等行與不能保證冪等的方法,如下:
-
√ 滿足冪等
-
x 不滿足冪等
-
- 可能滿足也可能不滿足冪等,根據實際業務邏輯有關
六、如何實現冪等性
方案一:數據庫唯一主鍵
方案描述
數據庫唯一主鍵的實現主要是利用數據庫中主鍵唯一約束的特性,一般來說唯一主鍵比較適用於 “插入” 時的冪等性,其能保證一張表中只能存在一條帶該唯一主鍵的記錄。
使用數據庫唯一主鍵完成冪等性時需要注意的是,該主鍵一般來說並不是使用數據庫中自增主鍵,而是使用分佈式 ID 充當主鍵(可以參考 Java 中分佈式 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 表達式來註銷查詢與刪除操作。
方案四、下游傳遞唯一序列號
方案描述:
所謂請求序列號,其實就是每次向服務端請求時候附帶一個短時間內唯一不重複的序列號,該序列號可以是一個有序 ID,也可以是一個訂單號,一般由下游生成,在調用上游服務端接口時附加該序列號和用於認證的 ID。
當上遊服務器收到請求信息後拿取該 序列號 和下游 認證 ID 進行組合,形成用於操作 Redis 的 Key,然後到 Redis 中查詢是否存在對應的 Key 的鍵值對,根據其結果:
-
如果存在,就說明已經對該下游的該序列號的請求進行了業務處理,這時可以直接響應重複請求的錯誤信息。
-
如果不存在,就以該 Key 作爲 Redis 的鍵,以下游關鍵信息作爲存儲的值(例如下游商傳遞的一些業務邏輯信息),將該鍵值對存儲到 Redis 中 ,然後再正常執行對應的業務邏輯即可。
適用操作:
-
插入操作
-
更新操作
-
刪除操作
使用限制:
-
要求第三方傳遞唯一序列號;
-
需要使用第三方組件 Redis 進行數據效驗;
主要流程:
主要步驟:
-
① 下游服務生成分佈式 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 方案” 實現更爲快捷。
上面只是給與一些建議,再次強調一下,實現冪等性需要先理解自身業務需求,根據業務邏輯來實現這樣才合理,處理好其中的每一個結點細節,完善整體的業務流程設計,才能更好的保證系統的正常運行。最後做一個簡單總結
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/dVnXdsmgbP2P5JPYVtWbUg