spring boot + redis实现接口防重

参考

springboot + redis + 注解 + 拦截器 实现接口幂等性校验

背景

在业务开发中,我们经常会遇到由于网络抖动或者用户误操作引起同一份请求数据多次请求后端接口的问题,这种问题可以综合多方面进行解决:

  • 数据库:增加唯一索引
  • 前端:按钮防止重复点击
  • 后端:判断请求数据是否重复

本文从后端层面考虑,使用Spring boot + redis + 自定义注解 + 拦截器来降低同一份请求数据被重复处理的可能性

思路

前端在请求需要做防重校验接口之前,先请求token获取接口得到防重token,之后在header中携带token再去请求具体接口,时序图如下:


Spring boot + redis实现接口防重-思路图

这里可能会有疑问:前端在什么时候去获取token比较合适呢?以保存数据为例,如果当点击了保存按钮再去获取token,则依旧可能存在重复点击保存按钮,此时获取的token是不一样的,这时候防重就没起到作用,因此,如果保存数据是在模态框上进行的,那么,可以在弹框的时候,获取token,除非重启弹框,否则,token都是同一个,从而避免上述问题。不过要注意的是,当保存数据异常的时候,这个时候token已经校验完毕被删除了,而出于用户体验的考虑,往往是允许用户无需重新弹框直接修正数据之后再次保存数据的,所以,一旦保存失败,需要重置token,但这里有一种例外,就是token校验失败的异常,这时候需要抛出特定错误码,方便前端不重置token。至于没有模态框的情景,则需要具体场景具体分析了

Demo

ApiIdempotent

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ApiIdempotent {
}

用自定义注解来标识哪些接口需要做幂等处理

TokenService

public interface TokenService {

    String createToken();

    void checkToken(String token);
}

TokenServiceImpl

@Service
public class TokenServiceImpl implements TokenService {

    @Autowired
    private RedisUtil redisUtil;

    @Override
    public String createToken() {
        String token = UuidUtil.getId();
        StringBuilder tokenBuilder = new StringBuilder();
        tokenBuilder.append(CommonConstant.IDEMPOTENT_TOKEN).append(tokenBuilder);
        redisUtil.set(tokenBuilder.toString(), tokenBuilder.toString(), 5, TimeUnit.MINUTES);
        return token;
    }

    @Override
    public void checkToken(String token) {
        if (StringUtils.isBlank(token)) {
            throw new ApiIdempotentException(ExceptionConstant.ILLEGAL_ARGUMENT);
        }

        StringBuilder tokenBuilder = new StringBuilder();
        tokenBuilder.append(CommonConstant.IDEMPOTENT_TOKEN).append(tokenBuilder);

        // 获取token,如果redis中不存在,说明已经被删除了,此时便是重复请求
        Object value = redisUtil.get(tokenBuilder.toString());
        if (value == null) {
            throw new ApiIdempotentException(ExceptionConstant.DUPLICATE_REQUEST);
        }

        // 校验通过之后,将token从redis中删除
        boolean deleteResult = redisUtil.delete(tokenBuilder.toString());
        // 必须校验删除结果,多线程环境下,依旧有可能多个线程走到delete这一步
        if (!deleteResult) {
            throw new ApiIdempotentException(ExceptionConstant.DUPLICATE_REQUEST);
        }
    }
}

RedisUtil

@Component
@Slf4j
public class RedisUtil {
    public boolean set(Object key, Object value, long timeout, TimeUnit timeUnit) {
        try {
            redisTemplate.opsForValue().set(key, value, timeout, timeUnit);
        } catch (Exception e) {
            log.error("set with expire异常",e);
            return false;
        }
        return true;
    }
}    

ApiIdempotentInterceptor

@Component
public class ApiIdempotentInterceptor implements HandlerInterceptor {

    @Autowired
    private TokenService tokenService;

    @Autowired
    private RedisUtil redisUtil;

    @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) {
            request.getRequestURL();
            request.getParameterMap();
            String token = request.getHeader(CommonConstant.IDEMPOTENT_TOKEN);
            tokenService.checkToken(token);
        }
        return true;
    }
}

InterceptorConfiguration

@Configuration
public class InterceptorConfiguration implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(apiIdempotentInterceptor()).addPathPatterns("/**");
    }

    @Bean
    public ApiIdempotentInterceptor apiIdempotentInterceptor() {
        return new ApiIdempotentInterceptor();
    }
}

注册拦截器

博主在项目中实际使用时,在配置完拦截器之后,发现始终未能进入拦截器逻辑,且启动的时候debug,也不会进入该段代码,百度发现在Spring boot中,WebMvcConfigurer和WebMvcConfigurationSupport如果同时存在,只会生效先扫描到的配置类,不会同时生效,感觉离真相近了一步,一波操作,去掉原本实现WebMvcConfigurationSupport的配置,but,事情并没有那么简单,依旧没生效。

挠头一想,项目还引入了一个common包,会不会里面已经配置了WebMvcConfigurer呢?一看,果然如此,一把梭把InterceptorConfiguration改成继承common包中的拦截器配置类,这回总可以了吧?依旧没生效

难道是扫描顺序问题?查看启动类的注解,发现common包的扫描配置在业务之前:@ComponentScan(basePackages = {"com.common","com.biz"}),调整一下顺序,哎,成了

测试

被测试接口

    @PostMapping(value = "/saveProduct")
    @ApiIdempotent
    public Response<String> saveProduct() {
        Product product = new Product();
        product.setCreateTime(new Date());
        productService.save(product);
        return Response.success("success");
    }

测试结果

这里使用postman runner进行测试,测试并发量为100/s:


测试接口

并发配置

测试结果

总结

使用token可以有效降低接口重复请求的概率,但并不能保证100%不重复,取决于前端获取token的时机,且前端请求业务接口之前需要先获取token,对存量接口尤其不友好(可能前端大神有统一处理的方案,反正我这个前端渣渣并不是很清楚)。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,133评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,682评论 3 390
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,784评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,508评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,603评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,607评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,604评论 3 415
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,359评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,805评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,121评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,280评论 1 344
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,959评论 5 339
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,588评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,206评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,442评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,193评论 2 367
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,144评论 2 352