基于Spring Boot + Redis实现接口幂等性

一. 背景
    在实际的开发项目中,一个对外暴露的接口往往会面临,瞬间大量的重复的请求提交,如果想过滤掉重复请求造成对业务的伤害,那就需要实现幂等。
例如:

  • 创建业务订单,一次业务请求只能创建一个,创建多个就会出大问题;

  • 我们发起一笔付款请求,应该只扣用户账户一次钱,当遇到网络重发或系统bug重发,也应该只扣一次钱;

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

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

产生原因
由于重复点击或者网络重发 eg:

1)点击提交按钮两次;

2)点击刷新按钮;

3)使用浏览器后退按钮重复之前的操作,导致重复提交表单;

4)使用浏览器历史记录重复提交表单;

5)浏览器重复的HTTP请求;

6)nginx重发等情况;

7)分布式RPC的try重发等;

二. 幂等性概念
幂等(idempotent、idempotence)是一个数学与计算机学概念,常见于抽象代数中。
在这里插入图片描述
在编程中.一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。通俗的说就是一个接口, 多次发起同一个请求, 必须保证操作只能执行一次,对应到数据库的影响只能是一次性的,不能重复处理。

更复杂的操作幂等保证是利用唯一交易号(流水号)实现。

  • 编程中常见幂等 :
  1. 查询操作
    查询一次和查询多次,在数据不变的情况下,查询结果是一样的。select是天然的幂等操作。
  2. 删除操作
    删除操作也是幂等的,删除一次和多次删除都是把数据删除。(注意可能返回结果不一样,删除的数据不存在,返回0,删除的数据多条,返回结果多个)
  3. update直接更新某个值的,幂等
  4. update更新累加操作的,非幂等
  5. insert非幂等操作,每次新增一条

三. 解决方案

  1. 唯一索引,防止新增脏数据
    唯一索引或唯一组合索引来防止新增数据存在脏数据 (当表存在唯一索引,并发h会新增报错,此时再查询一次就可以了,数据应该已经存在了,返回结果即可)
  2. token机制 – 防止页面重复提交
    业务要求: 页面的数据只能被点击提交一次
    发生原因: 由于重复点击或者网络重发,或者nginx重发等情况会导致数据被重复提交
    解决办法:
    集群环境:采用token加redis(redis单线程的,处理需要排队)
    单JVM环境:采用token加redis或token加jvm内存
  3. 悲观锁
    获取数据的时候加锁获取,悲观锁使用时一般伴随事务一起使用,数据锁定时间可能会很长,根据实际情况选用。
# 注意:id字段一定是主键或者唯一索引,不然是锁表,会死人的 
select * from table_xxx where id='xxx' for update; 
  1. 乐观锁
    乐观锁只是在更新数据那一刻锁表,其他时间不锁表,所以相对于悲观锁,效率更高。
    乐观锁的实现方式多种多样可以通过version或者其他状态条件:
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个并发请求。

  1. header或参数均不传token, 或者token值为空, 或者token值乱填, 均无法通过校验
  2. 传入正确的token,10此并发请求中只有一次会成功
    在这里插入图片描述
    在这里插入图片描述
  3. 特别注意:不能单纯的直接删除token而不校验是否删除成功, 会出现并发安全性问题。
    模拟问题:将上面的48,49,50三行判断删除成功的代码注释
    在这里插入图片描述
    在这里插入图片描述
    并发请求会发现有三个重复请求都成功了。

完整代码传送门

参考文章:
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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值