幂等方案设计一接口幂等

551 阅读5分钟

幂等本身是一个数学概念。即 f(n) = 1^n ,无论n为多少,f(n)的值永远为1。在编程开发中,对于幂等的定义

为:无论对某一个资源操作了多少次,其影响都应是相同的。

针对我司的项目设计了接口幂等方案,不能说能够适用所用公司吧,只能说具体业务场景具体分析。

对于幂等的考虑,主要解决两点前后端交互与服务间交互。这两点有时都要考虑幂等性的实现。 从前端的思路解决的话,主要有三种: 前端防重、PRG模式、Token机制。

前端防重: 通过前端防重保证幂等是最简单的实现方式,前端相关属性和JS代码即可完成设置。可靠性并不好,有经验的人员

可以通过工具跳过页面仍能重复提交。主要适用于表单重复提交或按钮重复点击。

PRG模式: PRG模式即POST-REDIRECT-GET。当用户进行表单提交时,会重定向到另外一个提交成功页面,而不是停留在原

先的表单页面。这样就避免了用户刷新导致重复提交。同时防止了通过浏览器按钮前进/后退导致表单重复提交。

是一种比较常见的前端防重策略。

token机制:

通过token机制来保证幂等是一种非常常见的解决方案,同时也适合绝大部分场景。该方案需要前后端进行一定程

度的交互来完成

image.png

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。则是一个合法请求允许执行,如果是其他值,则代表是非法请求,直接返回

image.png

那如果先删除token再执行业务呢?其实也会存在问题,假设具体业务代码执行超时或失败,没有向客户端返回

明确结果,那客户端就很有可能会进行重试,但此时之前的token已经被删除了,则会被认为是重复请求,不再进

行业务处理。

image.png

这种方案无需进行额外处理,一个token只能代表一次请求。一旦业务执行出现异常,则让客户端重新获取令牌,

重新发起一次访问即可。推荐使用先删除token方案

但是无论先删token还是后删token,都会有一个相同的问题。每次业务请求都回产生一个额外的请求去获取

token。但是,业务失败或超时,在生产环境下,一万个里最多也就十个左右会失败,那为了这十来个请求,让其

他九千九百多个请求都产生额外请求,就有一些得不偿失了。虽然redis性能好,但是这也是一种资源的浪费。

代码实现:

基于自定义注解:

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 幂等性注解
 */
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
}

定义拦截器:


import com.itheima.annotation.Idempotent;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
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;

public class IdempotentInterceptor 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();
        Idempotent annotation = method.getAnnotation(Idempotent.class);
        if (annotation != null){

            //幂等性校验
            checkToken(request);
        }
        return true;
    }

    @Autowired
    private RedisTemplate redisTemplate;

    private void checkToken(HttpServletRequest request) {

        //获取token
        String token = request.getHeader("token");

        if (StringUtils.isEmpty(token)){
            throw new RuntimeException("非法参数");
        }

        Boolean deleteResult = redisTemplate.delete(token);
        if (!deleteResult){
            //重复请求
            throw new RuntimeException("重复请求");
        }
    }
}

添加拦截器:

@Bean
public IdempotentInterceptor idempotentInterceptor(){
    return new IdempotentInterceptor();
}

@Override
public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(idempotentInterceptor());
    super.addInterceptors(registry);
}

定义feign接口token透传:


@Component
public class FeignInterceptor implements RequestInterceptor {

    @Override
    public void apply(RequestTemplate template) {

        //传递令牌

        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();

        if (requestAttributes != null){

            HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();

            if (request != null){

                Enumeration<String> headerNames = request.getHeaderNames();

                while (headerNames.hasMoreElements()){

                    String hearName = headerNames.nextElement();

                    if ("token".equals(hearName)){

                        String headerValue = request.getHeader(hearName);

                        //传递token
                        template.header(hearName,headerValue);
                    }
                }
            }
        }
    }
}

添加方法测试:

@Idempotent
@PostMapping("/addOrder2")
public String addOrder2(@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";
    }
}

4)测试

获取令牌后,在jemeter中模拟高并发访问

设置50个并发访问

测试执行,可以发现,只有一个请求是成功的,其他全部被判定为重复请求。