知识 - 接口幂等性
# 什么是幂等性
幂等是一个数学与计算机学概念,在数学中某一元运算为幂等时,其作用在任一元素两次后会和其作用一次的结果相同。
在计算机中编程中,一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。幂等函数或幂等方法是指可以使用相同参数重复执行,并能获得相同结果的函数。这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。
# 什么是接口幂等性
在 HTTP/1.1 中,对幂等性进行了定义。它描述了一次和多次请求某一个资源对于资源本身应该具有同样的结果(网络超时等问题除外),即第一次请求的时候对资源产生了副作用,但是以后的多次请求都不会再对资源产生副作用。
这里的副作用是不会对结果产生破坏或者产生不可预料的结果。也就是说,其任意多次执行对资源本身所产生的影响均与一次执行的影响相同。
# 为什么需要实现幂等性
在接口调用时一般情况下都能正常返回信息不会重复提交,不过在遇见以下情况时可以就会出现问题,如:
- 前端重复提交表单: 在填写一些表格时候,用户填写完成提交,很多时候会因网络波动没有及时对用户做出提交成功响应,致使用户认为没有成功提交,然后一直点提交按钮,这时就会发生重复提交表单请求
- 用户恶意进行刷单**:** 例如在实现用户投票这种功能时,如果用户针对一个用户进行重复提交投票,这样会导致接口接收到用户重复提交的投票信息,这样会使投票结果与事实严重不符
- 接口超时重复提交**:** 很多时候 HTTP 客户端工具都默认开启超时重试的机制,尤其是第三方调用接口时候,为了防止网络波动超时等造成的请求失败,都会添加重试机制,导致一个请求提交多次
- 消息进行重复消费: 当使用 MQ 消息中间件时候,如果发生消息中间件出现错误未及时提交消费信息,导致发生重复消费
使用幂等性最大的优势在于使接口保证任何幂等性操作,免去因重试等造成系统产生的未知的问题。
# 引入幂等性后对系统的影响
幂等性是为了简化客户端逻辑处理,能放置重复提交等操作,但却增加了服务端的逻辑复杂性和成本,其主要是:
- 把并行执行的功能改为串行执行,降低了执行效率
- 增加了额外控制幂等的业务逻辑,复杂化了业务功能
所以在使用时候需要考虑是否引入幂等性的必要性,根据实际业务场景具体分析,除了业务上的特殊要求外,一般情况下不需要引入的接口幂等性。
# Restful API 接口的幂等性
现在流行的 Restful 推荐的几种 HTTP 接口方法中,分别存在幂等行与不能保证幂等的方法,如下:
- √ 满足幂等
- x 不满足幂等
- - 可能满足也可能不满足幂等,根据实际业务逻辑有关
方法类型 | 是否幂等 | 描述 |
---|---|---|
Get | √ | Get 方法用于获取资源。其一般不会也不应当对系统资源进行改变,所以是幂等的。 |
Post | × | Post 方法一般用于创建新的资源。其每次执行都会新增数据,所以不是幂等的。 |
Put | - | Put 方法一般用于修改资源。该操作则分情况来判断是不是满足幂等,更新操作中直接根据某个值进行更新,也能保持幂等。不过执行累加操作的更新是非幂等。 |
Delete | - | Delete 方法一般用于删除资源。该操作则分情况来判断是不是满足幂等,当根据唯一值进行删除时,删除同一个数据多次执行效果一样。不过需要注意,带查询条件的删除则就不一定满足幂等了。例如在根据条件删除一批数据后,这时候新增加了一条数据也满足条件,然后又执行了一次删除,那么将会导致新增加的这条满足条件数据也被删除。 |
# 如何实现幂等性
# 数据库唯一主键
数据库唯一主键的实现主要是利用数据库中主键唯一约束的特性,一般来说唯一主键比较适用于「插入」时的幂等性,其能保证一张表中只能存在一条带该唯一主键的记录。
使用数据库唯一主键完成幂等性时需要注意的是,该主键一般来说并不是使用数据库中自增主键,而是使用分布式 ID 充当主键(可以参考 Java 中分布式 ID 的设计方案 这篇文章),这样才能能保证在分布式环境下 ID 的全局唯一性。
适用操作
- 插入操作
- 删除操作
使用限制
需要生成全局唯一主键 ID。
主要流程
- 客户端执行创建请求,调用服务端接口
- 服务端执行业务逻辑,生成一个分布式 ID,将该 ID 充当待插入数据的主键,然后执数据插入操作,运行对应的 SQL 语句
- 服务端将该条数据插入数据库中,如果插入成功则表示没有重复调用接口。如果抛出主键重复异常,则表示数据库中已经存在该条记录,返回错误信息到客户端
# 数据库乐观锁
数据库乐观锁方案一般只能适用于执行「更新操作」的过程,我们可以提前在对应的数据表中多添加一个字段,充当当前数据的版本标识。这样每次对该数据库该表的这条数据执行更新时,都会将该版本标识作为一个条件,值为上次待更新数据中的版本标识的值。
适用操作
更新操作
使用限制
需要数据库对应业务表中添加额外字段
示例
例如,存在如下的数据表中:
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 进行数据效验
主要流程
- 服务端提供获取 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 不能正常工作。
# 实现接口幂等
# 依赖
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.32</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-crypto</artifactId>
<version>5.8.22</version>
</dependency>
</dependencies>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 步骤
- 提供 PreventRepeatSubmit 注解,在接口上添加该注解,代表开启接口幂等性功能。幂等性有效时间可在注解里配置
- 利用 AOP 切面对 PreventRepeatSubmit 注解的方法进行切入,在 before 方法中获取 Token 和请求参数,并与 URL 拼接成 key 存入 Redis,如果存入成功,代表第一次请求,如果存入失败,代表存在第一次请求,本次为重复请求,返回错误提示信息,不继续往下走
# 注解
@Inherited
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface PreventRepeatSubmit {
/**
* 设置请求锁定时间,小于此时间视为重复提交
*/
int lockTime() default 5000;
/**
* 时间单位
*/
TimeUnit timeUnit() default TimeUnit.MILLISECONDS;
/**
* 提示消息 支持国际化 格式为 {code}
*/
String message() default "不允许重复提交,请稍后再试";
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# AOP 切面
@Aspect
@RequiredArgsConstructor
public class PreventRepeatSubmitAspect {
private static final ThreadLocal<String> KEY_CACHE = new ThreadLocal<>();
private static final String NO_REPEAT_SUBMIT_KEY = "uac:repeat_submit:";
private static RedisTemplate<String, Object> redisTemplate;
/**
* 拦截请求
*/
@Before("@annotation(preventRepeatSubmit)")
public void doBefore(JoinPoint point, PreventRepeatSubmit preventRepeatSubmit) {
long lockTime = preventRepeatSubmit.timeUnit().toMillis(preventRepeatSubmit.lockTime());
if (lockTime < 1000) {
throw new ServiceException("重复提交间隔时间不能小于'1'秒");
}
String requestParams = argsArrayToString(point.getArgs());
HttpServletRequest request = ServletUtil.getRequest();
if (Objects.isNull(request)) {
return;
}
String url = request.getRequestURI();
// 唯一值
String token = JwtTokenUtils.getToken(request);
// 缓存的 key 由 url + MD5(token + ":" + requestParams) 组合
String cacheKey = NO_REPEAT_SUBMIT_KEY + url + ":" + SecureUtil.md5(token + ":" + requestParams);
if (redisTemplate.opsForValue().setIfAbsent(cacheKey, "", Duration.ofMillis(lockTime))) {
KEY_CACHE.set(cacheKey);
} else {
String message = preventRepeatSubmit.message();
throw new ServiceException(message);
}
}
/**
* 处理完请求后执行
*
* @param joinPoint 切点
*/
@AfterReturning(pointcut = "@annotation(preventRepeatSubmit)", returning = "result")
public void doAfterReturning(JoinPoint joinPoint, PreventRepeatSubmit preventRepeatSubmit, Object result) {
if (result instanceof Response<?> r) {
try {
// 成功则不删除 redis 数据,保证在有效时间内无法重复提交
if (Objects.equals(r.getCode(), ResponseStatusEnum.SUCCESS.getCode())) {
return;
}
// 接口返回报错信息则删除 redis 数据。可再次请求
redisTemplate.delete(KEY_CACHE.get());
} finally {
KEY_CACHE.remove();
}
}
}
/**
* 参数拼装
*/
private String argsArrayToString(Object[] paramsArray) {
StringJoiner params = new StringJoiner(" ");
if (Objects.isNull(paramsArray)) {
return params.toString();
}
for (Object o : paramsArray) {
if (Objects.nonNull(o) && !isFilterObject(o)) {
params.add(JacksonUtil.toJsonStr(o));
}
}
return params.toString();
}
/**
* 判断是否需要过滤的对象。
*
* @param o 对象信息
* @return 如果是需要过滤的对象,则返回true;否则返回false。
*/
public boolean isFilterObject(final Object o) {
Class<?> clazz = o.getClass();
if (clazz.isArray()) {
return MultipartFile.class.isAssignableFrom(clazz.getComponentType());
} else if (Collection.class.isAssignableFrom(clazz)) {
Collection collection = (Collection) o;
for (Object value : collection) {
return value instanceof MultipartFile;
}
} else if (Map.class.isAssignableFrom(clazz)) {
Map map = (Map) o;
for (Object value : map.values()) {
return value instanceof MultipartFile;
}
}
return o instanceof MultipartFile || o instanceof HttpServletRequest || o instanceof HttpServletResponse
|| o instanceof BindingResult;
}
public static String getToken(HttpServletRequest request) {
String token = request.getHeader("Authorization");
String tokenHead = "Bearer ";
if (Objects.isNull(token)) {
token = request.getHeader("token");
if (Objects.isNull(token)) {
token = request.getParameter("token");
}
} else if (token.contains(tokenHead)) {
// 把 Bearer 去掉,只要后面的 token 值
token = token.substring(tokenHead.length());
}
if ("".equals(token)) {
token = null;
}
return token;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
# 容器装配
将 AOP 切面类注入到 Spring 容器里
@AutoConfiguration(after = RedisConfiguration.class)
public class IdempotenceConfiguration {
@Bean
public PreventRepeatSubmitAspect repeatSubmitAspect() {
return new PreventRepeatSubmitAspect();
}
}
2
3
4
5
6
7
Spring Boot 3.x 需要在 resource 下建立 META-INF/spring
路径,然后创建 org.springframework.boot.autoconfigure.AutoConfiguration.imports
文件,内容为
cn.youngkbt.idempotent.config.IdempotenceConfiguration
这样 Spring 会自动扫描该文件的容器装配类,将里面涉及的类注入到 Spring 容器。
# 使用案例
@RestController
@RequestMapping("/demo/idempotence")
public class IdempotenceController {
@PostMapping
@PreventRepeatSubmit
public String insertOne(@Validated(RestGroup.AddGroup.class) @RequestBody SysRoleDTO sysRoleDTO) {
return "";
}
}
2
3
4
5
6
7
8
9
10
# 最后总结
幂等性是开发当中很常见也很重要的一个需求,尤其是支付、订单等与金钱挂钩的服务,保证接口幂等性尤其重要。在实际开发中,我们需要针对不同的业务场景我们需要灵活的选择幂等性的实现方式:
- 对于下单等存在唯一主键的,可以使用「唯一主键方案」的方式实现
- 对于更新订单状态等相关的更新场景操作,使用「乐观锁方案」实现更为简单
- 对于上下游这种,下游请求上游,上游服务可以使用「下游传递唯一序列号方案」更为合理
- 类似于前端重复提交、重复下单、没有唯一ID号的场景,可以通过 Token 与 Redis 配合的「防重 Token 方案」实现更为快捷
上面只是给与一些建议,再次强调一下,实现幂等性需要先理解自身业务需求,根据业务逻辑来实现这样才合理,处理好其中的每一个结点细节,完善整体的业务流程设计,才能更好的保证系统的正常运行。最后做一个简单总结,然后本博文到此结束,如下:
方案名称 | 适用方法 | 实现复杂度 | 方案缺点 |
---|---|---|---|
数据库唯一主键 | 插入操作 删除操作 | 简单 | 只能用于插入操作;只能用于存在唯一主键场景 |
数据库乐观锁 | 更新操作 | 简单 | 只能用于更新操作;表中需要额外添加字段 |
请求序列号 | 插入操作 更新操作 删除操作 | 简单 | 需要保证下游生成唯一序列号;需要 Redis 第三方存储已经请求的序列号 |
防重 Token 令牌 | 插入操作 更新操作 删除操作 | 适中 | 需要 Redis 第三方存储生成的 Token 串 |