一、什么是冪等性
本文一至五部分是關于冪等性的概念介紹,實現方案在第六部分,基于防重Token令牌方案代碼在第七部分。
冪等是一個數學與計算機學概念,在數學中某一元運算為冪等時,其作用在任一元素兩次后會和其作用一次的結果相同。在計算機中編程中,一個冪等操作的特點是其任意多次執行所產生的影響均與一次執行的影響相同。
冪等函數或冪等方法是指可以使用相同參數重復執行,并能獲得相同結果的函數。這些函數不會影響系統狀態,也不用擔心重復執行會對系統造成改變。
二、什么是接口冪等性
在HTTP/1.1中,對冪等性進行了定義。它描述了一次和多次請求某一個資源對于資源本身應該具有同樣的結果(網絡超時等問題除外),即第一次請求的時候對資源產生了副作用,但是以后的多次請求都不會再對資源產生副作用。
這里的副作用是不會對結果產生破壞或者產生不可預料的結果。也就是說,其任意多次執行對資源本身所產生的影響均與一次執行的影響相同。
三、為什么要實現冪等性
在接口調用時一般情況下都能正常返回信息不會重復提交,不過在遇見以下情況時可以就會出現問題,如:
1.前端重復提交表單: 在填寫一些表格時候,用戶填寫完成提交,很多時候會因網絡波動沒有及時對用戶做出提交成功響應,致使用戶認為沒有成功提交,然后一直點提交按鈕,這時就會發生重復提交表單請求。
2.用戶惡意進行刷單: 例如在實現用戶投票這種功能時,如果用戶針對一個用戶進行重復提交投票,這樣會導致接口接收到用戶重復提交的投票信息,這樣會使投票結果與事實嚴重不符。
3.接口超時重復提交: 很多時候 HTTP 客戶端工具都默認開啟超時重試的機制,尤其是第三方調用接口時候,為了防止網絡波動超時等造成的請求失敗,都會添加重試機制,導致一個請求提交多次。
4.消息進行重復消費: 當使用 MQ 消息中間件時候,如果發生消息中間件出現錯誤未及時提交消費信息,導致發生重復消費。
使用冪等性最大的優勢在于使接口保證任何冪等性操作,免去因重試等造成系統產生的未知的問題。
四、引入接口冪等性對系統的影響
冪等性是為了簡化客戶端邏輯處理,能放置重復提交等操作,但卻增加了服務端的邏輯復雜性和成本,其主要是:
把并行執行的功能改為串行執行,降低了執行效率。
增加了額外控制冪等的業務邏輯,復雜化了業務功能;
所以在使用時候需要考慮是否引入冪等性的必要性,根據實際業務場景具體分析,除了業務上的特殊要求外,一般情況下不需要引入的接口冪等性。
五、Restful API 接口的冪等性
現在流行的 [Restful 推薦的幾種HTTP 接口方法中,分別存在冪等行與不能保證冪等的方法,如下:
√ 滿足冪等
x 不滿足冪等
-
可能滿足也可能不滿足冪等,根據實際業務邏輯有關
接口冪等性.png
-
六、如何實現冪等性
方案一:數據庫唯一主鍵
方案描述:
數據庫唯一主鍵的實現主要是利用數據庫中主鍵唯一約束的特性,一般來說唯一主鍵比較適用于“插入”時的冪等性,其能保證一張表中只能存在一條帶該唯一主鍵的記錄。
使用數據庫唯一主鍵完成冪等性時需要注意的是,該主鍵一般來說并不是使用數據庫中自增主鍵,而是使用分布式 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 刪除,然后正常執行業務邏輯。如果不存在就拋異常,返回重復提交的錯誤信息。
七、防重Token令牌代碼實現
首先構建一個SpringBoot項目,maven依賴如下:
父工程:
<?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>
<groupId>com.cube</groupId>
<artifactId>share</artifactId>
<version>0.0.1-SNAPSHOT</version>
<modules>
<module>dynamic-proxy</module>
<module>idempotence</module>
</modules>
<name>share</name>
<description>share project</description>
<packaging>pom</packaging>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.4.3</version>
</parent>
<properties>
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<boot.version>2.4.3</boot.version>
<druid.version>1.1.10</druid.version>
<mysql.version>8.0.18</mysql.version>
<mybatis.boot.version>2.0.1</mybatis.boot.version>
</properties>
<dependencyManagement>
<dependencies>
<!--spring-boot-web-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>${boot.version}</version>
</dependency>
<!--mysql + druid -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>${druid.version}</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>${mysql.version}</version>
</dependency>
<!--mybatis-->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>${mybatis.boot.version}</version>
</dependency>
<!--spring aop-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
<version>${boot.version}</version>
</dependency>
<!--springboot data redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>${boot.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
</dependency>
<!--spring-boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
</dependencies>
</project>
當前工程:
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>share</artifactId>
<groupId>com.cube</groupId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>idempotence</artifactId>
<dependencies>
<!--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>
<!--spring-boot-web-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--spring aop-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
我這里因為是在父工程下建立的子工程,所以把兩份pom文件都貼出來了,如果只是建立一個單獨的工程在當前工程pom文件引入全部依賴即可。
Redis配置:
連接池采用了lettuce,沒有采用jedis,兩種連接池的區別大家可自行百度這里就不展開了。
spring:
redis:
host: 127.0.0.1
ssl: false
port: 6379
database: 1
connect-timeout: 1000
lettuce:
pool:
max-active: 10
max-wait: -1
min-idle: 0
max-idle: 20
package com.cube.share.idempotence.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* @author litb
* @date 2021/3/11 13:44
* @description Redis配置
*/
@Configuration
public class RedisConfig {
@Bean
public GenericJackson2JsonRedisSerializer jackson2JsonRedisSerializer() {
return new GenericJackson2JsonRedisSerializer();
}
@Bean
public StringRedisSerializer stringRedisSerializer() {
return new StringRedisSerializer();
}
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(factory);
redisTemplate.setKeySerializer(stringRedisSerializer());
redisTemplate.setHashKeySerializer(stringRedisSerializer());
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer());
redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer());
return redisTemplate;
}
}
自定義注解和切面對冪等方法的冪等性Token進行校驗
自定義注解:
package com.cube.share.idempotence.annotations;
import com.cube.share.idempotence.constants.Constant;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @author litb
* @date 2021/3/11 14:17
* @description 支持冪等性注解
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface SupportIdempotence {
/**
* 冪等性校驗的token前綴
*
* @return
*/
String prefix() default Constant.IDEMPOTENCE_TOKEN_PREFIX;
/**
* 是否需要在客戶端彈出警告,如果為true則彈出警告例如[請勿重復提交]
*
* @return
*/
boolean alert() default false;
}
自定義注解切面
package com.cube.share.idempotence.annotations.aspects;
import com.cube.share.idempotence.annotations.SupportIdempotence;
import com.cube.share.idempotence.constants.Constant;
import com.cube.share.idempotence.templates.ApiResult;
import com.cube.share.idempotence.templates.CustomException;
import com.cube.share.idempotence.utils.IpUtil;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
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.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.Objects;
/**
* @author litb
* @date 2021/3/11 14:22
* @description 冪等性切面
* eval "return redis.call('get',KEYS[1])" 1 key1
*/
@Component
@Slf4j
@Aspect
public class IdempotenceAspect {
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 確保get和del兩部操作原子性的lua腳本
*/
private static final String LUA_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
private static final RedisScript<Long> REDIS_GET_DEL_SCRIPT = new DefaultRedisScript<>(LUA_SCRIPT, Long.class);
@Pointcut("@annotation(com.cube.share.idempotence.annotations.SupportIdempotence)")
public void pointCut() {
}
@Around("pointCut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
//獲取注解屬性
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Method method = methodSignature.getMethod();
SupportIdempotence idempotence = method.getAnnotation(SupportIdempotence.class);
String keyPrefix = idempotence.prefix();
boolean alert = idempotence.alert();
//從方法中獲取請求頭
HttpServletRequest request = Objects.requireNonNull(((ServletRequestAttributes) RequestContextHolder.getRequestAttributes())).getRequest();
String ip = IpUtil.getIpAddress(request);
//前端傳入的token
String token = request.getHeader(Constant.IDEMPOTENCE_TOKEN_HEADER);
//token校驗
if (token == null) {
log.error("請求頭缺少冪等性token,url_path = {}", request.getServletPath());
throw new CustomException("冪等性校驗token缺失");
}
//拼接成存放在redis中的key
String redisKey = keyPrefix + token;
//執行lua腳本
Long result = stringRedisTemplate.execute(REDIS_GET_DEL_SCRIPT, Collections.singletonList(redisKey), ip);
if (result == null || result == 0L) {
if (alert) {
throw new CustomException("請勿重復提交");
} else {
return ApiResult.success();
}
}
return joinPoint.proceed();
}
}
獲取用于冪等性校驗的Token
這里采用UUID作為Token,并將指定前綴與token拼接作為key存入Redis,當前用戶信息作為value(我這里采用的是ip),也可以采用用戶的其他信息作為value,后面冪等性校驗時會同時對key和value進行校驗,在確保key能夠唯一的情況下,僅對key進行校驗也可。
package com.cube.share.idempotence.service;
import com.cube.share.idempotence.constants.Constant;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* @author litb
* @date 2021/3/11 15:30
* @description 冪等性token Service
*/
@Service
@Slf4j
public class IdempotenceTokenService {
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 獲取冪等性token,執行成功后會在redis中存入 prefix+token:value一條記錄
*
* @param value 存入redis中的value
* @return
*/
public String generateIdempotenceToken(String value) {
String token = UUID.randomUUID().toString();
String key = Constant.IDEMPOTENCE_TOKEN_PREFIX + token;
stringRedisTemplate.opsForValue().set(key, value, 3, TimeUnit.MINUTES);
return token;
}
}
全局異常處理器
package com.cube.share.idempotence.config;
import com.cube.share.idempotence.templates.ApiResult;
import com.cube.share.idempotence.templates.CustomException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
/**
* @author litb
* @date 2021/3/11 15:21
* @description 全局異常處理器
*/
@ResponseBody
@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(CustomException.class)
public ApiResult customExceptionHandler(CustomException ce) {
return ApiResult.error(ce.getMessage());
}
@ExceptionHandler(Exception.class)
public ApiResult exceptionHandler(Exception e) {
log.error("系統異常,error_msg = {}", e.getMessage());
return ApiResult.error("系統異常!");
}
}
Controller
package com.cube.share.idempotence.controller;
import com.cube.share.idempotence.annotations.SupportIdempotence;
import com.cube.share.idempotence.service.IdempotenceTokenService;
import com.cube.share.idempotence.templates.ApiResult;
import com.cube.share.idempotence.utils.IpUtil;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
/**
* @author litb
* @date 2021/3/11 15:37
* @description
*/
@RestController
public class IdempotenceController {
@Resource
private IdempotenceTokenService idempotenceTokenService;
@GetMapping("/token")
public ApiResult getToken(HttpServletRequest request) {
return ApiResult.success(idempotenceTokenService.generateIdempotenceToken(IpUtil.getIpAddress(request)));
}
@DeleteMapping("/delete")
@SupportIdempotence
public ApiResult delete() {
return ApiResult.success("刪除成功");
}
@PostMapping("/submit")
@SupportIdempotence(alert = true)
public ApiResult submit() {
return ApiResult.success("提交成功");
}
}
測試結果:
1、獲取Token
2、請求頭中無冪等性Token
3、成功請求
4、alert為false,不需要進行重復提交提示,響應體msg為null
5、alert為true,需要進行重復提交提示,前端拿到提示信息后可將其彈出
完整代碼:https://gitee.com/li-cube/share/tree/master/idempotence