接口幂等性校验

一、概念

幂等(idempotent、idempotence)是一个数学与计算机学概念,常见于抽象代数中。

在编程中.一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。

这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。例如,“getUsername()和setTrue()”函数就是一个幂等函数.

幂等性, 通俗的说就是一个接口, 多次发起同一个请求, 必须保证操作只能执行一次,比如:

  • 订单接口, 不能多次创建订单

  • 支付接口, 重复支付同一笔订单只能扣一次钱

  • 支付宝回调接口, 可能会多次回调, 必须处理重复回调

  • 普通表单提交接口, 因为网络超时等原因多次点击提交, 只能成功一次 等等

 

二、常见解决方案

  • 唯一索引 – 防止新增脏数据

  • token机制 – 防止页面重复提交

  • 悲观锁 – 获取数据的时候加锁(锁表或锁行)

  • 乐观锁 – 基于版本号version实现, 在更新数据那一刻校验数据

  • 分布式锁 – redis(jedis、redisson)或zookeeper实现

  • 状态机 – 状态变更, 更新数据时判断状态

 

三、本文实现方案

本文采用第2种方式实现, 即通过redis + token机制实现接口幂等性校验

为需要保证幂等性的每一次请求创建一个唯一标识token, 先获取token, 并将此token存入redis, 请求接口时, 将此token放到header或者作为请求参数请求接口, 后端接口判断redis中是否存在此token,如果存在, 正常处理业务逻辑, 并从redis中删除此token, 那么, 如果是重复请求, 由于token已被删除, 则不能通过校验, 返回请勿重复操作提示,如果不存在, 说明参数不合法或者是重复请求, 返回提示即可

 

四、核心代码

依赖库
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>3.1.3</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.28</version>
<scope>compile</scope>
</dependency>
自定义注解
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
/**
* token业务处理,提供token创建、token验证接口
* Created by double on 2019/7/11.
*/
@Service
public class TokenServiceImpl implements TokenService {

private static final String TOKEN_NAME = "token";

@Autowired
StringRedisTemplate stringRedisTemplate;

@Override
public ServerResponse createToken() {
//通过UUID来生成token
String tokenValue = "idempotent:token:" + UUID.randomUUID().toString();
//将token放入redis中,设置有效期为60S
stringRedisTemplate.opsForValue().set(tokenValue, "0", 60, TimeUnit.SECONDS);
return ServerResponse.success(tokenValue);
}

/**
* @param request
*/
@Override
public void checkToken(HttpServletRequest request) {
String token = request.getHeader(TOKEN_NAME);
if (StringUtils.isBlank(token)) {
token = request.getParameter(TOKEN_NAME);
if (StringUtils.isBlank(token)) {
//没有携带token,抛异常,这里的异常需要全局捕获
throw new ServiceException(ResponseCode.ILLEGAL_ARGUMENT.getMsg());
}
}
/*先生成token,拿到token去访问需要幂等性的接口,因为第一次访问,里面有token,不执行下面方法,直接走删除token
若重复访问,之前token删除掉了,redis里面没有token返回false,!false为true,走下面方法,或者访问的是不是生成的token
也为true,走下面逻辑
*/
//token不存在,说明token已经被其他请求删除或者是非法的token
if (!stringRedisTemplate.hasKey(token)) {
throw new ServiceException(ResponseCode.REPETITIVE_OPERATION.getMsg());
}
boolean del = stringRedisTemplate.delete(token);
if (!del) {
//token删除失败,说明token已经被其他请求删除
throw new ServiceException(ResponseCode.REPETITIVE_OPERATION.getMsg());
}
}

}
IdempotentTokenInterceptor
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
/**
* 接口幂等性校验拦截器
* Created by double on 2019/7/11.
*/
public class IdempotentTokenInterceptor implements HandlerInterceptor {

@Autowired
private TokenService tokenService;

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
//幂等性校验, 校验通过则放行, 校验失败则抛出异常, 并通过统一异常处理返回友好提示
ApiIdempotent apiIdempotent = handlerMethod.getMethod().getAnnotation(ApiIdempotent.class);
if (apiIdempotent != null) {
tokenService.checkToken(request);
}

return true;
}

@Override
public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception {
}

@Override
public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception {
}
}
测试
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 幂等性测试接口
* Created by double on 2019/7/11.
*/
@RestController
@RequestMapping("/test")
public class TestController {

@Autowired
private TestService testService;

@ApiIdempotent
@PostMapping("testIdempotent")
public ServerResponse testIdempotent() {
return testService.testIdempotence();
}

}
获取token
http://localhost:8081/token

{
    "status": 0,
    "msg": "idempotent:token:80dd47ed-fa63-4b30-82fa-f3ee4fd64a50",
    "data": null
}

 

验证接口安全性
http://localhost:8081/test/testIdempotent?token=idempotent:token:b9ae797d-ed1a-4dbc-a94f-b7e45897f0f5

第一次请求

{
    "status": 0,
    "msg": "test idempotent success",
    "data": null
}

 

重复请求

{
    "status": 1,
    "msg": "请勿重复操作",
    "data": null
}

 

利用jmeter测试工具模拟50个并发请求, 获取一个新的token作为参数

设置50个线程请求一次

image-20200415170757239

设置请求IP、Path、参数等信息image-20200415170807474

查看执行结果,可以看到只有一个请求成功,其他的请求都返回错误

image-20200415170713970

image-20200415170741939