文章目錄
- 前言
- 一、幂等性的概念
- 1.1、認識幂等
- 1.2、Restful API中的幂等性(通俗約定)
- 二、幂等性的解決方案
- 三、幂等性實戰
- 3.1、SpringBoot內建Redis實作—(防重token令牌)
- 實作思路
- 實操代碼
- 測試接口(jemter測試)
- 參考資料
前言
本章主要介紹幂等性以及幂等性實戰案例。
所有部落格檔案目錄索引:部落格目錄索引(持續更新)
本章案例版本:``SpringBoot 2.3.4.RELEASE`
配套代碼位址:Gitee倉庫、Github倉庫
一、幂等性的概念
1.1、認識幂等
百度百科這麼解釋:在程式設計中一個幂等操作的特點是其任意多次執行所産生的影響均與一次執行的影響相同。
簡而言之就是:在某個場景下,同一個使用者去請求某個接口多次也隻有一次效果。
實際生産中的例子:
1、對于訂單接口,在你送出的時候不可能建立多個吧。
2、支付接口,轉賬操作肯定也是隻能操作一次。
3、支付寶的回調接口,有些時候會産生多次回調,這種情況也要避免。
4、一些普通的表單送出接口。
1.2、Restful API中的幂等性(通俗約定)
一般分為Get、POST、DELETE、PUT,一半各自定義為查、增、删、修改。
- GET:一般擷取資訊是本身天然就滿足幂等的。
- POST、DELETE、PUT:其中POST一般都是新增,肯定是不滿足幂等的,另外兩個根據實際業務來決定。
二、幂等性的解決方案
學習:黑馬程式員-玩轉微服務接口幂等性與安全設計
下面介紹幾種實作方案:
方案1、資料庫唯一主鍵實作幂等性
限制:分布式ID。
流程:用戶端從服務端擷取到分布式ID,接着去調用請求時攜帶分布式ID,此時訂單使用這個分布式ID作為唯一主鍵來進行insert,一旦出現重複送出情況,自然不會成功。
方案2、資料庫悲觀鎖實作
場景:例如我們要查詢某個訂單的狀态然後進行更新,此時就可以使用資料庫的悲觀鎖實作。
對于如下正常邏輯在高并發場景下會出現問題:
select * from where id = 'xxx'
if (查詢狀态 == xxx) {
//業務邏輯
}
update x
使用悲觀鎖,需要開啟事務:
begin # 事務開始
# 添加for update關鍵字,即可對這行記錄上鎖(上行鎖還是表鎖取決于where的這個字段是否為索引)
select * from where id = 'xxx' for update
update xx
commit # 事務結束
- 若是線程A執行了select … for update,此時就會對這行記錄上鎖,直到整個線程A送出事務之前,其他線程來走這個查詢操作都會進入到阻塞狀态。
- 示例視訊示範:基于for update實作悲觀鎖
方案3、資料庫樂觀鎖
限制:表中需要多增加一個字段
version
來進行樂觀鎖判斷
# 在更新的時候會拿上版本号來進行字段校驗以及進行update更新
# 一條sql語句是原子性的,就不會産生線程安全問題
UPDATE my_table SET price=price+50,version=version+1 WHERE id=1 AND version=5
方案4:防重token令牌
限制:①唯一token。②內建redis校驗
流程:①在執行某個重要業務前用戶端請求伺服器擷取到防重token令牌(伺服器端生成時會在redis中插入與該令牌相關的一條記錄)。
②拿到token後來進行消費,也就是向伺服器端進行重要業務請求。(該令牌token隻能夠被消耗一次,進入到伺服器端時會将是否删除redis那條記錄結果來作為限制條件)
方案5:狀态機實作
限制:根據資料庫表記錄中的狀态來作為條件(跟之前樂觀鎖使用版本号類似)。
# 在日常業務中對于類似訂單都有不同的狀态,我們可以來定義status狀态字段來進行表示,在進行更新操作時就可以使用對應的狀态字段來實作幂等性
update transfr_flow set status=2 where biz_seq=‘666’ and status=1;
方案6:分布式鎖
限制:redis或者zookeeper。
流程:業務來臨的時候在redis中插入一條記錄,若是成功說明可以進行,失敗就表示業務執行處理結束。(具體實際憑證條件參數根據業務來決定)
三、幂等性實戰
3.1、SpringBoot內建Redis實作—(防重token令牌)
實作思路
流程:在進行請求重要業務接口前,首先擷取到防重令牌,之後再去發送請求。
原理:在生成令牌的同時,會向redis中去插入一條記錄,之後在進行消費時使用一條lua腳本(查詢校驗+删除操作),lua腳本可以保證在redis裡進行原子性操作。
實操代碼
依賴配置xml:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</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>
</dependencies>
yaml配置檔案:
spring:
redis:
ssl: false
host: 127.0.0.1
port: 6379
database: 0
timeout: 1000
password: 123456
lettuce:
pool:
max-active: 100
max-wait: -1
min-idle: 0
max-idle: 20
server:
port: 8081
①TokenUtilService:token生成以及token校驗(內建redis)
package com.changlu.utils;
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;
}
}
②防重注解:ApiIdempotent.java
package com.changlu.annontions;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD) //使用于方法
@Retention(RetentionPolicy.RUNTIME) //運作時
public @interface ApiIdempotent {
}
③防重注解攔截器,會攔截所有标注自定義防重注解的controller方法:
package com.changlu.interceptors;
import com.changlu.annontions.ApiIdempotent;
import com.changlu.utils.ServletUtils;
import com.changlu.utils.TokenUtilService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
/**
* @Description: aoi幂等性接口攔截器
* @Author: changlu
* @Date: 10:07 AM
*/
@Component
public class ApiIdempotentInterceptor implements HandlerInterceptor {
@Autowired
private TokenUtilService tokenUtilService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//校驗是否有執行方法
if (!(handler instanceof HandlerMethod)) {
return true;//若沒有對應的方法執行器,就直接放行
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
ApiIdempotent annotation = method.getAnnotation(ApiIdempotent.class);
//若是沒有幂等性注解直接放行
if (annotation != null) {
//解析對應的請求頭
String token = request.getHeader("token");
if (ObjectUtils.isEmpty(token)) {
ServletUtils.renderString(response, "請攜帶token令牌");
return false;
}
//若是校驗失敗直接進行響應
if (!tokenUtilService.validToken(token, "changlu")) {
ServletUtils.renderString(response, "重複送出失敗!");
return false;
}
}
return true;
}
}
④webmvc配置,來注冊攔截器:WebConfiguration.java
package com.changlu.config;
import com.changlu.interceptors.ApiIdempotentInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;
import java.nio.charset.Charset;
import java.util.List;
/**
* @Description:
* @Author: changlu
* @Date: 10:05 AM
*/
@Configuration
public class WebConfiguration extends WebMvcConfigurationSupport {
@Autowired
private ApiIdempotentInterceptor apiIdempotentInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(apiIdempotentInterceptor);
super.addInterceptors(registry);
}
// http請求時編碼
@Bean
public HttpMessageConverter<String> responseBodyConverter() {
StringHttpMessageConverter converter = new StringHttpMessageConverter(
Charset.forName("UTF-8"));
return converter;
}
/**
* 系統配置參數編碼
* @param converters
*/
@Override
protected void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
super.configureMessageConverters(converters);
converters.add(responseBodyConverter());
}
}
⑤工具類向response進行寫資料:ServletUtils.java
package com.changlu.utils;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class ServletUtils
{
/**
* 将字元串渲染到用戶端
*
* @param response 渲染對象
* @param string 待渲染的字元串
* @return null
*/
public static String renderString(HttpServletResponse response, String string) {
try
{
response.setStatus(200);
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().print(string);
}
catch (IOException e)
{
e.printStackTrace();
}
return null;
}
}
⑥兩個控制器接口:
TokenController.java:
package com.changlu.controller;
import com.changlu.utils.TokenUtilService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
@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 = "changlu";
// 擷取 Token 字元串,并傳回
return tokenService.generateToken(userInfo);
}
/**
* 接口幂等性測試接口
*
* @param token 幂等 Token 串
* @return 執行結果
*/
@PostMapping("/test")
public Object test(@RequestHeader(value = "token") String token) {
// 擷取使用者資訊(這裡使用模拟資料)
String userInfo = "changlu";
// 根據 Token 和與使用者相關的資訊到 Redis 驗證是否存在對應的資訊
boolean result = tokenService.validToken(token, userInfo);
// 根據驗證結果響應不同資訊
return result ? "正常調用" : "重複調用";
}
}
OrderController.java:
package com.changlu.controller;
import com.changlu.annontions.ApiIdempotent;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @Description: 訂單控制器
* @Author: changlu
* @Date: 10:03 AM
*/
@RestController
@RequestMapping("/order")
public class OrderController {
//幂等性注解
@ApiIdempotent
@PostMapping
public Object createOrder() {
return "建立訂單成功!";
}
}
測試接口(jemter測試)
根據控制器類提供的接口進行測試:
1、TokenController主要來測試生成token以及校驗token接口
生成接口測試如下:
接着我們将這個token添加到post的測試請求header中:
測試一下:
調用成功!
此時再調用一次,就調用失敗了,因為在redis中的那條記錄被删除了:
2、OrderController來測試(jmeter高并發測試)
在對應的訂單接口上我們添加了防重接口,之後發起請求時就會走我們的攔截器,在攔截器中會取出token并進行校驗,若是通過則會執行後面的業務方法:
HTTP請求配置資訊如下:
還是老樣子先擷取到token,然後我們将token存入到header頭中,這裡我們在jmeter中配置一個http管理器:
我們也設定了同步定時器,能夠在同一時間有100個請求:
線程屬性如下:
接着我們啟動jmeter來進行測試:防重接口成功!
- 注:為了能夠在jmeter中顯示x錯誤的效果,我在攔截器中進行錯誤響應内容渲染改成了抛出異常,僅僅隻是為了顯示明顯,如下:
對應jemter測試檔案,我放在項目目錄下:
參考資料
[1]. 面試官:你能聊聊高并發下的接口幂等性如何實作嗎?
[2]. 如何通過springboot + redis + 注解 + 攔截器,實作接口幂等性校驗?