在实际应用中,由于网络不稳定、系统延迟等原因,客户端可能会重复发送相同的请求。如果这些重复请求都被服务器处理并执行,就可能导致意想不到的问题,比如重复扣款、多次下单或者数据不一致等。
这就是为什么我们需要接口幂等性。简单来说,接口幂等性意味着同一个操作不论被重复执行多少次,其最终结果都是一样的,不会因为多次调用而产生副作用或额外的影响。换句话说,对于一个幂等的接口,第一次调用它会按照预期完成任务;之后无论再怎么重复调用这个接口,都不会改变已经达成的结果状态。
举个例子来帮助理解:
-
非幂等的操作:想象一下你正在网上购买一件商品,并且不小心点击了两次“提交订单”按钮。如果没有幂等机制保护,这可能会导致你的账户被扣费两次,并生成两个独立的订单。
-
幂等的操作:现在假设同样的场景下,该电商平台实现了良好的幂等性设计。即使你误点了两次按钮,系统也能识别出这是同一笔交易的不同尝试,并只创建一个订单,避免了不必要的重复扣款。
接口幂等的解决思路主要有:前端防重、PRG模式、Token机制。
一、前端防重
通过前端防重保证幂等是最简单的实现方式,前端相关属性和JS代码即可完成设置。可靠性并不好,有经验的人员 可以通过工具跳过页面仍能重复提交。主要适用于表单重复提交或按钮重复点击。
二、PRG模式
PRG模式即POST-REDIRECT-GET。当用户进行表单提交时,会重定向到另外一个提交成功页面,而不是停留在原 先的表单页面。这样就避免了用户刷新导致重复提交。同时防止了通过浏览器按钮前进/后退导致表单重复提交。 是一种比较常见的前端防重策略。
三、Token机制
方案介绍
通过token机制来保证幂等是一种非常常见的解决方案,同时也适合绝大部分场景。该方案需要前后端进行一定程度的交互来完成。
方案1
对于该方案:
- 1)服务端提供获取token接口,供客户端进行使用。服务端生成token后,如果当前为分布式架构,将token存放 于redis中,如果是单体架构,可以保存在jvm缓存中。
- 2)当客户端获取到token后,会携带着token发起请求。
- 3)服务端接收到客户端请求后,首先会判断该token在redis中是否存在。如果存在,则完成进行业务处理,业务 处理完成后,再删除token。如果不存在,代表当前请求是重复请求,直接向客户端返回对应标识。
但是现在有一个问题,当前是先执行业务再删除token。在高并发下,很有可能出现第一次访问时token存在,完 成具体业务操作。但在还没有删除token时,客户端又携带token发起请求,此时,因为token还存在,第二次请求 也会验证通过,执行具体业务操作。
对于这个问题的解决方案的思想就是并行变串行。会造成一定性能损耗与吞吐量降低。
第一种方案:对于业务代码执行和删除token整体加线程锁。当后续线程再来访问时,则阻塞排队。
第二种方案:借助redis单线程和incr是原子性的特点。当第一次获取token时,以token作为key,对其进行自增。 然后将token进行返回,当客户端携带token访问执行业务代码时,对于判断token是否存在不用删除,而是对其继 续incr。如果incr后的返回值为2。则是一个合法请求允许执行,如果是其他值,则代表是非法请求,直接返回。如下图所示
方案2
那如果先删除token再执行业务呢?架构如下图
其实也会存在问题,假设具体业务代码执行超时或失败,没有向客户端返回明确结果,那客户端就很有可能会进行重试,但此时之前的token已经被删除了,则会被认为是重复请求,不再进行业务处理。
解决:这种方案无需进行额外处理,一个token只能代表一次请求。一旦业务执行出现异常,则让客户端重新获取令牌, 重新发起一次访问即可。推荐使用先删除token方案
总结:
无论先删token还是后删token,都会有一个相同的问题。每次业务请求都回产生一个额外的请求去获取 token。但是,业务失败或超时,在生产环境下,一万个里最多也就十个左右会失败,那为了这十来个请求,让其 他九千九百多个请求都产生额外请求,就有一些得不偿失了。虽然redis性能好,但是这也是一种资源的浪费。
方案实现
基于自定义业务流程实现
业务流程图
1)修改token_service_order工程中OrderController,新增生成令牌方法genToken
@Autowired
private IdWorker idWorker;
@Autowired
private RedisTemplate redisTemplate;
@GetMapping("/genToken")
public String genToken(){
String token = String.valueOf(idWorker.nextId());
redisTemplate.opsForValue().set(token,0,30, TimeUnit.MINUTES);
return token;
}
2) 修改token_service_api工程,新增OrderFeign接口。
@FeignClient(name = "order")
@RequestMapping("/order")
public interface OrderFeign {
@GetMapping("/genToken")
public String genToken();
}
3)修改token_web_order工程中WebOrderController,新增获取token方法
@RestController
@RequestMapping("worder")
public class WebOrderController {
@Autowired
private OrderFeign orderFeign;
/**
* 服务端生成token
* @return
*/
@GetMapping("/genToken")
public String genToken(){
String token = orderFeign.genToken();
return token;
}
}
4)修改token_common,新增feign拦截器
@Component
public class FeignInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate requestTemplate) {
//传递令牌
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
if (requestAttributes != null){
HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
if (request != null){
Enumeration<String> headerNames = request.getHeaderNames();
while (headerNames.hasMoreElements()){
String headerName = headerNames.nextElement();
if ("token".equals(headerName)){
String headerValue = request.getHeader(headerName);
//传递token
requestTemplate.header(headerName,headerValue);
}
}
}
}
}
}
5)修改token_web_order启动类
@Bean
public FeignInterceptor feignInterceptor(){
return new FeignInterceptor();
}
6)修改token_service_order中OrderController,新增添加订单方法
/**
* 生成订单
* @param order
* @return
*/
@PostMapping("/genOrder")
public String genOrder(@RequestBody Order order, HttpServletRequest request){
//获取令牌
String token = request.getHeader("token");
//校验令牌
try {
if (redisTemplate.delete(token)){
//令牌删除成功,代表不是重复请求,执行具体业务
order.setId(String.valueOf(idWorker.nextId()));
order.setCreateTime(new Date());
order.setUpdateTime(new Date());
int result = orderService.addOrder(order);
if (result == 1){
System.out.println("success");
return "success";
}else {
System.out.println("fail");
return "fail";
}
}else {
//删除令牌失败,重复请求
System.out.println("repeat request");
return "repeat request";
}
}catch (Exception e){
throw new RuntimeException("系统异常,请重试");
}
}
7)修改token_service_order_api中OrderFeign。
@FeignClient(name = "order")
@RequestMapping("/order")
public interface OrderFeign {
@PostMapping("/genOrder")
public String genOrder(@RequestBody Order order);
@GetMapping("/genToken")
public String genToken();
}
8)修改token_web_order中WebOrderController,新增添加订单方法
/**
* 新增订单
*/
@PostMapping("/addOrder")
public String addOrder(@RequestBody Order order){
String result = orderFeign.genOrder(order);
return result;
}
基于自定义注解实现
直接把token实现嵌入到方法中会造成大量重复代码的出现。因此可以通过自定义注解将上述代码进行改造。在需 要保证幂等的方法上,添加自定义注解即可。
1)在token_common中新建自定义注解Idemptent
/**
* 幂等性注解
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idemptent {
}
2)在token_common中新建拦截器
public class IdemptentInterceptor implements HandlerInterceptor {
@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();
Idemptent annotation = method.getAnnotation(Idemptent.class);
if (annotation != null){
//进行幂等性校验
checkToken(request);
}
return true;
}
@Autowired
private RedisTemplate redisTemplate;
//幂等性校验
private void checkToken(HttpServletRequest request) {
String token = request.getHeader("token");
if (StringUtils.isEmpty(token)){
throw new RuntimeException("非法参数");
}
boolean delResult = redisTemplate.delete(token);
if (!delResult){
//删除失败
throw new RuntimeException("重复请求");
}
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
}
}
3)修改token_service_order启动类,让其继承WebMvcConfigurerAdapter
@Bean
public IdemptentInterceptor idemptentInterceptor() {
return new IdemptentInterceptor();
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
//幂等拦截器
registry.addInterceptor(idemptentInterceptor());
super.addInterceptors(registry);
}
4)更新token_service_order与token_service_order_api,新增添加订单方法,并且方法添加自定义幂等注解
@Idemptent
@PostMapping("/genOrder2")
public String genOrder2(@RequestBody Order order){
order.setId(String.valueOf(idWorker.nextId()));
order.setCreateTime(new Date());
order.setUpdateTime(new Date());
int result = orderService.addOrder(order);
if (result == 1){
System.out.println("success");
return "success";
}else {
System.out.println("fail");
return "fail";
}
}