保证接口的幂等性
前言
接口调用存在的问题
- 现如今我们的系统大多拆分为分布式SOA,或者微服务,一套系统中包含了多个子系统服务,而一个子系统服务往往会去调用另一个服务,而服务调用服务无非就是使用RPC通信或者restful,遇到以下情况就会出现问题。
- 前端重复提交
- 用户恶意刷单:如投票系统
- 接口超时重复提交
- 消息进行重复消费:使用mq中间件的时候,可能出现重复消费的情况
接口幂等性
- 接口等幂性通俗的来说就是同一时间内,发起多次请求只有一次请求成功;
- 其目的是防止多次提交,数据重复入库,表单验证网络延迟重复提交等问题;
- 举个最简单的例子,那就是支付,用户购买商品后支付,支付扣款成功,但是返回结果的时候网络异常,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户查询余额返发现多扣钱了,流水记录也变成了两条,这就没有保证接口的幂等性。
保证幂等的情况
在增删改查4个操作中,尤为注意就是增加或者修改,
查询操作
- 查询对于结果是不会有改变的,查询一次和查询多次,在数据不变的情况下,查询结果是一样的。select是天然的幂等操作
删除操作
- 删除一次和多次删除都是把数据删除, 在不考虑返回结果的情况下,删除操作也是具有幂等性的
- 注意:可能返回结果不一样,删除的数据不存在,返回0,删除的数据多条,返回结果多个
更新操作
- 修改在大多场景下结果一样,但是如果是增量修改是需要保证幂等性的
- 如下例子:
- 把表中id为XXX的记录的A字段值设置为1,这种操作不管执行多少次都是幂等的
- 把表中id为XXX的记录的A字段值增加1,这种操作就不是幂等的
新增操作
- 增加在重复提交的场景下会出现幂等性问题,如以上的支付问题
Restful API幂等性
- 满足幂等性:
- Get:一般用于请求,不会对系统资源进行改变
- 不满足幂等性:
- POST: 一般用于创建新的资源,每次执行都会创建新的资源
- 业务逻辑相关
- Put: 一般用于修改资源,直接更新是幂等的,增量更新不幂等。
- Delete: 根据唯一值删除是幂等的,带查询条件不一定幂等(删除和新增(满足删除条件)同时进行,可能出现新增数据被删除)
解决方案
数据库唯一约束
数据库唯一主键的实现主要是利用数据库中主键唯一约束的特性,一般来说唯一主键比较适用于“插入”时的幂等性,其能保证一张表中只能存在一条带该唯一主键的记录。
使用数据库唯一主键完成幂等性时需要注意的是,该主键一般来说并不是使用数据库中自增主键,而是使用分布式 ID 充当主键,这样才能能保证在分布式环境下 ID 的全局唯一性。
适用操作
- 插入操作
- 删除操作
使用限制
- 需要生成全局唯一主键 ID;
- 分布式ID的服务,可以使用 snowflake算法,数据库号段模式 ,redis自增等方式生产分布式唯一id
主要流程
客户端执行创建请求,调用服务端接口。
服务端执行业务逻辑,生成一个分布式
ID
,将该 ID 充当待插入数据的主键,然后执数据插入操作,运行对应的SQL
语句。服务端将该条数据插入数据库中,如果插入成功则表示没有重复调用接口。如果抛出主键重复异常,则表示数据库中已经存在该条记录,返回错误信息到客户端。
1
2# order_id唯一
insert into order(order_id, name, price) values(100001,"iphone12",9876);
数据库乐观锁
- 数据库乐观锁方案一般只能适用于执行更新操作的过程
- 我们可以提前在对应的数据表中多添加一个字段,充当当前数据的版本标识。
- 这样每次对该数据库该表的这条数据执行更新时,都会将该版本标识作为一个条件,值为上次待更新数据中的版本标识的值。
适用操作
- 更新操作
使用限制
- 需要数据库对应业务表中添加额外字段
使用描述
外部请求到服务端,进行更新操作
1
2
3-- version 字段记录当前的记录版本,这样在更新时候将该值带上
-- 那么只要执行更新操作就能确定一定更新的是某个对应版本下的信息。
updae order set price = price-199 ,version = version+1 where id = 100001 and version = 4;执行成功后version更新,重复执行该条sql将不生效, 保证了幂等性。
防重 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 不能正常工作。
代码逻辑判断示例
通过代码逻辑判断实现接口幂等性,只能针对一些满足判断的逻辑实现,具有一定局限性
用户购买商品的订单系统与支付系统;
订单系统负责记录用户的购买记录已经订单的流转状态(orderStatus),
支付系统用于付款,提供如下接口,订单系统与支付系统通过分布式网络交互。
1
boolean pay(int accountid,BigDecimal amount) //用于付款,扣除用户的
这种情况下,支付系统已经扣款,但是订单系统因为网络原因,没有获取到确切的结果,因此订单系统需要重试。
由上图可见,支付系统并没有做到接口的幂等性,订单系统第一次调用和第二次调用,用户分别被扣了两次钱,不符合幂等性原则。(同一个订单,无论是调用了多少次,用户都只会扣款一次)
如果需要支持幂等性,付款接口需要修改为以下接口:添加订单信息作为唯一标识
1
boolean pay(int orderId,int accountId,BigDecimal amount)
说明
针对同一个订单保证支付的幂等性,一旦订单的支付状态确定之后,以后的操作都会返回相同的结果,对用户的扣款也只会有一次。
这种接口的幂等性,简化到数据层面的操作:
1
2
3
4
5# 其中value是用户要减少的订单,paystatus代表支付状态,paid代表已经支付,unpay代表未支付,orderid是订单号。
update userAmount set
amount = amount - 'value' ,
paystatus = 'paid'
where orderId= 'orderid' and paystatus = 'unpay'订单具有自己的状态(orderStatus),订单状态存在一定的流转。订单首先有提交(0),付款中(1),付款成功(2),付款失败(3),简化之后其流转路径如图:
代码中设计
- 当orderStatus = 1 时,其前置状态只能是0,也就是说将orderStatus由 0->1 是需要幂等性的。
更新操作
0-> 1 的过程中,在执行update操作之前检测orderStatus是否已经=1,如果已经=1则直接返回true即可。
如果此时orderStatus = 2, 再进行订单状态0->1 时操作就无法成功, 这时候再执行
1
2# 接口会返回失败,系统没有产生修改,如果再发一次,requestid是相同的,对系统同样没有产生修改。
update Order set orderStatus = 1 where OrderId = 'orderid' and orderStatus = 0
新增操作根据orderid 做唯一约束
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18# mysql重复插入时insert更改为update更新操作
select count(player_id) from player_count where player_id = 1;//查询统计表中是否有记录
insert into player_count(player_id,count,name) value(1,2,”张三”);//判读如果没有记录就执行insert 操作
//如果存在,执行录入操作
update from player_count set count=1 ,name=‘张三’ where player_id=1;
insert into player_count(player_id,count,name) value(1,1,”张三”)
on duplicate key update
count= 2,
name=”张三”;
#多条数据插入
INSERT INTO user_admin_t (_id,password)
VALUES
('1','多条插入1') ,
('UpId','多条插入2')
ON DUPLICATE KEY UPDATE
password = VALUES(password);
防重 Token 令牌方案
依赖引入
引入
SpringBoot
、Redis
、lombok
相关依赖。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21<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>
配置 Redis 的连接参数
配置参数
1
2
3
4
5
6
7
8
9
10
11
12
13spring:
redis:
host: 192.168.56.101
port: 6379
password: 654321
database: 0 # 数据库索引,默认0
timeout: 5000 # 连接超时,单位ms
lettuce:
pool:
max-active: 8
max-wait: -1
min-idle: 0
max-idle: 8
创建与验证 Token 工具类
- 创建用于操作 Token 相关的 Service 类,里面存在 Token 创建与验证方法,其中:
Token
创建方法:使用UUID
工具创建Token
串,设置以“idempotent_token:“+“Token串”
作为Key
,以用户信息当成Value
,将信息存入 Redis 中。Token
验证方法:接收 Token 串参数,加上 Key 前缀形成Key
,再传入value
值,执行Lua
表达式(Lua
表达式能保证命令执行的原子性)进行查找对应Key
与删除操作。执行完成后验证命令的返回结果,如果结果不为空且非0,则验证成功,否则失败。1
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
72package tk.fulsun.service;
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.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Service;
import tk.fulsun.utils.RedisUtil;
/**
* @author fulsun
* @description: Token 工具类: 创建与验证
* @date 6/10/2021 10:56 AM
*/
public class TokenUtilService {
private RedisUtil redisUtil;
private RedisTemplate redisTemplate;
/** 存入 Redis 的 Token 键的前缀 */
private static final String IDEMPOTENT_TOKEN_PREFIX = "token:";
/**
* 创建 Token 存入 Redis,并返回该 Token
*
* @param value 用于辅助验证的 value 值
* @return 生成的 Token 串
*/
public String generateToken(String value) {
// 实例化生成 ID 工具对象
String token = UUID.randomUUID().toString().replaceAll("-", "");
// 设置存入 Redis 的 Key
String key = IDEMPOTENT_TOKEN_PREFIX + token;
// 存储 Token 到 Redis,且设置过期时间为5分钟
redisUtil.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 = (Long) redisTemplate.execute(redisScript, Arrays.asList(key, value));
// 直接判断删除
// if (redisTemplate.delete(key)) {
// 根据返回结果判断是否成功成功匹配并删除 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;
}
}
注解方式
- 定义此注解的主要目的是把它添加在需要实现幂等的方法上,凡是某个方法注解了它,都会实现自动幂等。
- 后台利用反射如果扫描到这个注解,就会处理这个方法实现自动幂等
添加注解
代码如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16package tk.fulsun.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @author fulsun
* @description: 检查方法幂等
* @date 6/10/2021 1:52 PM
*/
// 表示它只能放在方法上
// etentionPolicy.RUNTIME表示它在运行时
public AutoICheckToken {}
创建拦截器
代码如下
1
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
85package tk.fulsun.interceptor;
import com.alibaba.fastjson.JSON;
import java.io.OutputStream;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import tk.fulsun.annotation.AutoICheckToken;
import tk.fulsun.common.CodeMsg;
import tk.fulsun.service.TokenUtilService;
/**
* @author fulsun
* @description: 检查token的拦截器
* @date 6/10/2021 1:54 PM
*/
public class AutoCheckTokenInterceptor implements HandlerInterceptor {
private TokenUtilService tokenService;
/**
* 预处理
*
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
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();
// 被AutoICheckToken标记的扫描
AutoICheckToken methodAnnotation = method.getAnnotation(AutoICheckToken.class);
if (methodAnnotation != null) {
try {
// 幂等性校验, 校验通过则放行, 校验失败则抛出异常, 并通过统一异常处理返回友好提示
return tokenService.checkToken(request);
} catch (Exception ex) {
render(response, CodeMsg.ILLEGAL_REQUEST);
}
}
// 必须返回true,否则会被拦截一切请求
return true;
}
public void postHandle(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
ModelAndView modelAndView)
throws Exception {}
public void afterCompletion(
HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
throws Exception {}
private void render(HttpServletResponse response, CodeMsg cm) throws Exception {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(cm.getCode());
OutputStream out = response.getOutputStream();
Map result = new HashMap<>(3);
result.put("status", "1");
result.put("message", "");
result.put("error", cm.getMessage());
String str = JSON.toJSONString(result);
out.write(str.getBytes("UTF-8"));
out.flush();
out.close();
}
}
添加拦截器
代码如下
1
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
32package tk.fulsun.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import tk.fulsun.interceptor.ApiAccessInterceptor;
import tk.fulsun.interceptor.AutoCheckTokenInterceptor;
/**
* @author fulsun
* @description: 自定义web的配置
* @date 6/9/2021 3:17 PM
*/
public class WebConfig implements WebMvcConfigurer {
private ApiAccessInterceptor apiAccessInteaceptor;
private AutoCheckTokenInterceptor autoCheckTokenInterceptor;
public void addInterceptors(InterceptorRegistry registry) {
registry
.addInterceptor(apiAccessInteaceptor)
.addPathPatterns("/**")
.excludePathPatterns("/css/**", "/images/**", "/js/**", "/login.html");
registry
.addInterceptor(autoCheckTokenInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/css/**", "/images/**", "/js/**", "/login.html");
}
}
接口测试
代码如下
1
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
33package tk.fulsun.controller;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import tk.fulsun.annotation.AutoICheckToken;
import tk.fulsun.service.TokenUtilService;
/**
* @author fulsun
* @description: 测试token的 Controller 类
* @date 6/10/2021 11:10 AM
*/
public class TokenController {
/**
* 访问该接口,如果Head 中不带 token测试是否会进入该方法
*
* @return
*/
public String testIdempotence() {
return "成功";
}
}
总结
- 幂等性是开发当中很常见也很重要的一个需求,尤其是支付、订单等与金钱挂钩的服务,保证接口幂等性尤其重要。在实际开发中,我们需要针对不同的业务场景我们需要灵活的选择幂等性的实现方式:
- 对于下单等存在唯一主键的,可以使用“唯一主键方案”的方式实现。
- 对于更新订单状态等相关的更新场景操作,使用“乐观锁方案”实现更为简单。
- 对于上下游这种,下游请求上游,上游服务可以使用“下游传递唯一序列号方案”更为合理。
- 类似于前端重复提交、重复下单、没有唯一ID号的场景,可以通过
Token
与Redis
配合的“防重 Token 方案”实现更为快捷。
- 上面只是给与一些建议,再次强调一下,实现幂等性需要先理解自身业务需求,根据业务逻辑来实现这样才合理,处理好其中的每一个结点细节,完善整体的业务流程设计,才能更好的保证系统的正常运行。