一. 背景
在实际的开发项目中,一个对外暴露的接口往往会面临,瞬间大量的重复的请求提交,如果想过滤掉重复请求造成对业务的伤害,那就需要实现幂等。
例如:
创建业务订单,一次业务请求只能创建一个,创建多个就会出大问题;
我们发起一笔付款请求,应该只扣用户账户一次钱,当遇到网络重发或系统bug重发,也应该只扣一次钱;
支付宝回调接口, 可能会多次回调, 必须处理重复回调;
普通表单提交接口, 因为网络超时等原因多次点击提交, 只能成功一次;
产生原因:
由于重复点击或者网络重发 eg:
1)点击提交按钮两次;
2)点击刷新按钮;
3)使用浏览器后退按钮重复之前的操作,导致重复提交表单;
4)使用浏览器历史记录重复提交表单;
5)浏览器重复的HTTP请求;
6)nginx重发等情况;
7)分布式RPC的try重发等;
二. 幂等性概念
幂等(idempotent、idempotence)是一个数学与计算机学概念,常见于抽象代数中。
在编程中.一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。通俗的说就是一个接口, 多次发起同一个请求, 必须保证操作只能执行一次,对应到数据库的影响只能是一次性的,不能重复处理。
更复杂的操作幂等保证是利用唯一交易号(流水号)实现。
三. 解决方案
# 注意:id字段一定是主键或者唯一索引,不然是锁表,会死人的
select * from table_xxx where id='xxx' for update;
update table_xxx set name={name},version=version+1 where version={version}
5) 分布式锁
redis(jedis、redisson)或zookeeper实现。
6) select + insert
并发不高的后台系统,或者一些任务JOB,为了支持幂等,支持重复执行,简单的处理方法是,先查询下一些关键数据,判断是否已经执行过,在进行业务处理,就可以了。
注意:核心高并发流程不要用这种方法
7) 状态机幂等
在设计单据相关的业务,或者是任务相关的业务,肯定会涉及到状态机(状态变更图),就是业务单据上面有个状态,状态在不同的情况下会发生变更,一般情况下存在有限状态机,这时候,如果状态机已经处于下一个状态,这时候来了一个上一个状态的变更,理论上是不能够变更的,这样的话,保证了有限状态机的幂等。
注意:订单等单据类业务,存在很长的状态流转,一定要深刻理解状态机,对业务系统设计能力提高有很大帮助
四. 基于Spring Boot + Redis 实现接口幂等性
Redis实现自动幂等的原理图:
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiIdempotent {}
package com.hong.service;
import com.hong.exception.BusinessCode;
import com.hong.exception.BusinessException;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.servlet.http.HttpServletRequest;
import java.util.UUID;
/**
* @Description:
* @Author wanghong
* @Date 2020/5/26 16:44
* @Version V1.0
**/
@Service
public class TokenService {
@Autowired
private RedisService redisService;
public String createToken() {
String token = UUID.randomUUID().toString();
redisService.set(token, token, 5*60L);
return token;
}
public boolean checkToken(HttpServletRequest request) throws Exception {
String token = request.getHeader("token");
if (StringUtils.isEmpty(token)) {
token = request.getParameter("token");
if (StringUtils.isEmpty(token)) {
throw new BusinessException(BusinessCode.REQUEST_REPEAT);
}
}
if (!redisService.exists(token)) {
throw new BusinessException(BusinessCode.REQUEST_REPEAT);
}
boolean remove = redisService.del(token);
/**
* 这里要注意:不能单纯的直接删除token而不校验token是否删除成功,会出现并发安全问题
* 在多线程并发走到41行,此时token还未被删除,继续向下执行
*/
if (!remove) {
throw new Exception("token delete fail");
}
System.out.println(Thread.currentThread().getName() + "checkToken success");
return true;
}
}
package com.hong.interceptor;
import com.hong.annotation.ApiIdempotent;
import com.hong.service.TokenService;
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 javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
/**
* @Description:
* @Author wanghong
* @Date 2020/5/26 17:53
* @Version V1.0
**/
@Component
public class ApiIdempotentInterceptor implements HandlerInterceptor {
@Autowired
private TokenService tokenService;
@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 apiIdempotent = method.getAnnotation(ApiIdempotent.class);
if (apiIdempotent != null) {
// 幂等性校验,通过则放行;失败抛出异常,统一异常处理返回友好提示
tokenService.checkToken(request);
}
// 这里必须返回true,否则会拦截一切请求
return true;
}
}
package com.hong.config;
import com.hong.interceptor.ApiIdempotentInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;
import javax.annotation.Resource;
/**
* @Description:
* @Author wanghong
* @Date 2020/5/26 17:49
* @Version V1.0
**/
@Configuration
public class WebConfig extends WebMvcConfigurationSupport {
@Resource
private ApiIdempotentInterceptor apiIdempotentInterceptor;
@Override
protected void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(apiIdempotentInterceptor);
super.addInterceptors(registry);
}
}
package com.hong.controller;
import com.hong.annotation.ApiIdempotent;
import com.hong.service.TokenService;
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;
/**
* @Description:
* @Author wanghong
* @Date 2020/5/26 18:09
* @Version V1.0
**/
@RestController
public class BusinessController {
@Resource
private TokenService tokenService;
@GetMapping("/getToken")
public String getToken() {
String token = tokenService.createToken();
return token;
}
@ApiIdempotent
@PostMapping("/testIdempotence")
public String testIdempotence() {
try {
// 模拟业务执行耗时
Thread.sleep(500);
} catch (Exception e) {
e.printStackTrace();
}
return "SUCCESS";
}
}
五. 测试验证
第一次请求:
继续点击:
测试接口安全性: 利用jmeter测试工具模拟10个并发请求。
完整代码传送门
参考文章:
https://mp.weixin.qq.com/s/2vycQljbC-DZYZgUtAQiXQ
https://mp.weixin.qq.com/s/v_iyZVd5ldixnhaxkdSArA
https://mp.weixin.qq.com/s/xy4Jg3LrK0dpYy5q4rAAaw
https://mp.weixin.qq.com/s/8t8eNRSMLBgjeQBfnfxZIQ