接口幂等性设计、场景与方案、防止接口重复提交

防止接口重复请求


一、解决方案与场景

1.1、防止请求重复提交,不得不说幂等性

幂等性:就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用

这边小编的采用的是AOP+Redis来处理的。

在前端处理,其实都是防君子不放小人。

1.2、幂等性应用场景

1. 前端重复提交
用户注册,新增信息等操作,前端都会提交一些数据给后台服务,后台需要根据用户提交的数据在数据库中创建记录。如果用户不小心多点了几次,后端收到了好几次提交,这时就会在数据库中重复创建了多条记录。这就是接口没有幂等性带来的 bug

2. 接口超时重试
对于给第三方调用的接口,有可能会因为网络原因而调用失败,这时,一般在设计的时候会对接口调用加上失败重试的机制。
如果第一次调用已经执行了一半时,发生了网络异常。这时再次调用时就会因为脏数据的存在而出现调用异常。

3. 消息重复消费
在使用消息中间件来处理消息队列,且手动 ack 确认消息被正常消费时。如果消费者突然断开连接,那么已经执行了一半的消息会重新放回队列。
当消息被其他消费者重新消费时,如果没有幂等性,就会导致消息重复消费时结果异常,如数据库重复数据,数据库数据冲突,资源重复等。

1.3、解决方案

1.3.1、唯一标识Token机制

如图:
在这里插入图片描述

  1. 客户端会先发送一个请求去获取 token,服务端会生成一个全局唯一的 ID 作为 token 保存在 redis 中,同时把这个 ID 返回给客户端
  2. 客户端第二次调用业务请求的时候必须携带这个 token
  3. 服务端会校验这个 token,如果校验成功,则执行业务,并删除 redis 中的 token
  4. 如果校验失败,说明 redis 中已经没有对应的 token,则表示重复操作,直接返回指定的结果给客户端

01、全局唯一 ID 可以用百度的 uid-generator、美团的 Leaf 去生成
02、还要注意redis的原子性问题

1.3.1.1、危险性分析

1、先删除 token 还是后删除 token

  1. 先删除可能导致,业务确实没有执行,重试还带上之前 token,由于防重设计导致,请求还是不能执行。
  2. 后删除可能导致,业务处理成功,但是服务闪断,出现超时,没有删除 token,别人继续重试,导致业务被执行两边
  3. 我们最好设计为先删除 token,如果业务调用失败,就重新获取 token 再次请求。

2、Token 获取、比较和删除必须是原子性

  1. redis.get(token) 、token.equals、redis.del(token)如果这两个操作不是原子,可能导致,高并发下,都 get 到同样的数据,判断都成功,继续业务并发执行
  2. 可以在 redis 使用 lua 脚本完成这个操作

1.3.2、防重表机制

如图:
在这里插入图片描述

  1. 建立一张去重表,其中某个字段需要建立唯一索引
  2. 客户端去请求服务端,服务端会将这次请求的一些信息插入这张去重表中
  3. 因为表中某个字段带有唯一索引,如果插入成功,证明表中没有这次请求的信息,则执行后续的业务逻辑
  4. 如果插入失败,则代表已经执行过当前请求,直接返回
  5. 这种方法用得少,不怎么推荐

1.3.3、AOP+redis(单体+分布式都可以)

如图:
在这里插入图片描述

  1. 自定义出注解
  2. 创建出切面,校验接口的访问,第一次访问存入生成唯一的key+时间存入redis
  3. 第二次访问,取出key的时间,做减法。看是否大于设定的时间(3秒),如果大于,允许请求。小于,直接驳回

1.3.4、DCL方案(Double Checked Locking,双重检测锁)

  1. 在方法执行之前,先判断此业务是否已经执行过,如果执行过则不再执行,否则就正常执行。

  2. 提及将数据存储在内存中,最简单的方法就是使用 HashMap 存储,HashMap 的防重(防止重复)版本也是最原始的 。

  3. 缺点是 HashMap 是无限增长的,因此它会占用越来越多的内存,并且随着 HashMap 数量的增加查找的速度也会降低,已不再推荐。

  4. 使用最新的单例中著名的 DCL(Double Checked Locking,双重检测锁)来防止重复提交。

  5. 简而言之就是将执行的接口,存入map中,来进行处理

Apache 提供了一个 commons-collections 的框架,里面有一个数据结构 LRUMap
可以保存指定数量的固定的数据,并且它就会按照 LRU 的算法,帮你清除最不常用的数据。

1.3.5、其他方案

  1. 状态机
  2. 悲观锁

悲观锁使用时一般伴随事务一起使用,数据锁定时间可能会很长,需要根据实际情况选用。
另外要注意的是,id 字段一定是主键或者唯一索引,不然可能造成锁表的结果,处理起来会非常麻烦。

  1. 乐观锁

这种方法适合在更新的场景中,版本号机制

  1. redis set 防重机制(结合MD5存储值)

1.4、小归纳

  1. 像feign的请求重试机制-推荐带上唯一值来防重
  2. 普通接口防重可以采用aop+redis或者拦截器
  3. 悲观锁、乐观锁、版本号…这种的视情况而定
  4. 像要求比较严格的使用token机制+redis脚本

附上lua脚本和使用方法

//脚本内容 -get/del
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end


String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
//原子验证token和删除token
Long result = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class), Arrays.asList('redis的key值'), '要校验的值');
if (result == 0L) {
	//失败
}eles{
	//校验成功-并且删除成功
}

二、、AOP+redis的方案示例

1.1、boo工程结构:

在这里插入图片描述

1.2、依赖和配置

分布式的话采用redis+redisson

依赖

<!--redis-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>


<!--aop依赖-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

配置

# redis
spring.redis.host=127.0.0.1
# Redis服务器连接端口
spring.redis.port=6379
# Redis服务器连接密码(默认为空)
#spring.redis.password=

1.3、响应实体类

package sqy.aop_test.vo;

/**
 * @author suqinyi
 * @Date 2022/1/7
 * API返回参数
 */
public class ApiResult {

    private Integer code;//响应码
    private String message;//消息内容
    private Object data;//响应中的数据


    private static Integer failCode =20000;
    private static Integer okCode =10000;


    //--------失败----------
    public static ApiResult fail(String message) {
        return new ApiResult(failCode, message, null);
    }

    public static ApiResult fail(Integer code, String message) {
        return new ApiResult(code, message, null);
    }


    //--------成功----------
    public static ApiResult ok(String message) {
        return new ApiResult(okCode, message, null);
    }

    public static ApiResult ok() {
        return new ApiResult(okCode, "成功", null);
    }

    public static ApiResult ok(String message, Object data) {
        return new ApiResult(okCode, message, data);
    }

    public static ApiResult ok(Integer code, String message, Object data) {
        return new ApiResult(code, message, data);
    }

    //--------构造/get/set----------

    public ApiResult() {
    }

    public ApiResult(Integer code, String msg, Object data) {
        this.code = code;
        this.message = msg;
        this.data = data;
    }


    public Integer getCode() {
        return code;
    }

    public void setCode(Integer code) {
        this.code = code;
    }
    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }
    public Object getData() {
        return data;
    }

    public void setData(Object data) {
        this.data = data;
    }
}

1.4、注解与切面

1.4.1、自定义注解

package sqy.aop_test.annotation;

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

/**
 * @author suqinyi
 * @Date 2022/1/7
 * 重复提交
 */
@Target(ElementType.METHOD)// 作用到方法上
@Retention(RetentionPolicy.RUNTIME)// 运行时有效
public @interface repeatApply {

    /**
     * 默认时间3秒
     */
    int applyTime() default 3 * 1000;

}

1.4.2、切面逻辑处理

存入redis,用时间来校验

这边获取ip的工具类就不贴出来了,自行百度一个,网上都有

package sqy.aop_test.aspect;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import sqy.aop_test.annotation.repeatApply;
import sqy.aop_test.utils.IpUtils;
import sqy.aop_test.vo.ApiResult;

import javax.servlet.http.HttpServletRequest;
import java.util.Objects;
import java.util.concurrent.TimeUnit;

/**
 * @author suqinyi
 * @Date 2021/12/27
 * 接口重复提交
 */
@Component
@Aspect
public class repeatApplyAspect {

    @Autowired
    StringRedisTemplate redisTemplate;//如果是分布式就用分布式锁 [Redisson 分布式锁]


    /**
     * @param joinPoint 切入点对象  ProceedingJoinPoint可以获取当前方法和参数等信息
     * @return 使用@Around环绕通知, 环绕通知=前置通知+目标方法执行+后置通知
     * 这边的返回值为controller的返回值
     */
    @Around("@annotation(repeatApply)")
    public Object doAround(ProceedingJoinPoint joinPoint, repeatApply repeatApply) {
        try {
            String redisKeyName = "apply:";//redis前缀
            long lapseTime = 5;//失效时间
            TimeUnit timeUnit = TimeUnit.SECONDS;//秒

            HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
            // 拿到ip地址、请求路径、token、方法名称
            String ip = IpUtils.getIpAdrress(request);
            String url = request.getRequestURL().toString();
            String token = request.getHeader("Token");
            String methodName = joinPoint.getSignature().getName();
            System.out.println("ip:" + ip + "  " + "url:" + url + "  " + "token:" + token + "  " + "methodName:" + methodName);

			//安全校验等。。。。

            // 现在时间
            long now = System.currentTimeMillis();

            // 自定义key值方式
            String key = redisKeyName + ip + methodName;

            //是否存在key值
            Boolean flag = redisTemplate.hasKey(key);
            if (flag) {
                // 上次提交时间
                long lastTime = Long.parseLong(redisTemplate.opsForValue().get(key));
                // 如果现在距离上次提交时间小于设置的默认时间 则 判断为重复提交  否则 正常提交 -> 进入业务处理
                if ((now - lastTime) > repeatApply.applyTime()) {
                    // 非重复提交操作 - 重新记录操作时间
                    redisTemplate.opsForValue().set(key, String.valueOf(now), lapseTime, timeUnit);

                    // 进入处理业务-接收返回的结果
                    ApiResult result = (ApiResult) joinPoint.proceed();
                    return result;
                } else {
                    return ApiResult.fail("请勿重复提交");
                }
            } else {
//                JedisConnectionFactory factory =(JedisConnectionFactory) redisTemplate.getConnectionFactory();
//                factory.setDatabase("2");//切换db
//                redisTemplate.setConnectionFactory(factory);
                // 这里是第一次请求
                redisTemplate.opsForValue().set(key, String.valueOf(now), lapseTime, timeUnit);
                ApiResult result = (ApiResult) joinPoint.proceed();
                return result;
            }
        } catch (Throwable e) {
            System.out.println("校验是否重复提交时异常: " + e.getMessage());
            return ApiResult.fail("校验是否重复提交时异常");
        }
    }

}

1.5、注解使用与测试

package sqy.aop_test.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import sqy.aop_test.annotation.repeatApply;
import sqy.aop_test.vo.ApiResult;

/**
 * @author suqinyi
 * @Date 2021/12/27
 * 重复提交的controller
 */
@RestController
public class RepeatApplyController {

    // http://localhost:8080/testApply3s
    @repeatApply//默认3秒
    @RequestMapping(value = "/testApply3s", produces = "application/json;charset=utf-8")
    public ApiResult testApply3s() throws Exception {
        System.out.println("执行了方法-3秒内");
        return ApiResult.ok("请求成功3秒的");
    }

    // http://localhost:8080/testApply3s
    @repeatApply(applyTime = 1 * 1000)//1秒
    @GetMapping(value = "/testApply1s")
    public ApiResult testApply1s() throws Exception {
        System.out.println("执行了方法-1秒内");
        return ApiResult.ok("请求成功1秒的");
    }

    // http://localhost:8080/testApply5s
    @repeatApply(applyTime = 5 * 1000)//5秒
    @GetMapping(value = "/testApply5s")
    public ApiResult testApply5s() throws Exception {
        System.out.println("执行了方法-5秒内");
        return ApiResult.ok("请求成功5秒的");
    }
}

如图:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

  • 1
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
分布式接口幂等性问题是指在分布式系统中,由于网络延迟、重试机制等原因,可能导致同一个请求被重复处理,从而产生重复的业务逻辑。为了解决这个问题,需要保证接口幂等性。 保证接口幂等性的方法有多种。一种常见的方法是使用唯一标识来标识每一次请求,比如订单id、支付流水号或者前端生成的唯一随机串。在每次请求之前,需要将唯一标识存放到数据库或者缓存中。后端服务在处理请求之前,需要先检查这个唯一标识是否存在,如果存在,则判定此次请求已经处理过,不需要进行重复处理。这样可以避免重复的业务逻辑。 在分布式场景中,由于负载均衡算法的原因,可能会导致同一个请求被多台机器处理。为了解决这个问题,可以使用分布式锁来保证只有一个机器能够处理该请求。另外,使用分布式事务也可以保证接口幂等性。 此外,还可以通过拦截器(AOP)和注解的方式实现一个通用的解决方案,避免每次请求都写重复的代码。在设计系统时,幂等性是一个需要首要考虑的问题,特别是在涉及到金融交易等关键业务的系统中。 综上所述,保证分布式接口幂等性可以通过使用唯一标识、分布式锁、分布式事务等方法来实现。这样可以避免重复的业务逻辑和数据不一致的问题。\[1\]\[2\]\[3\] #### 引用[.reference_title] - *1* *2* [分布式环境下接口幂等性浅析](https://blog.csdn.net/ice24for/article/details/86084613)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^insertT0,239^v3^insert_chatgpt"}} ] [.reference_item] - *3* [分布式开发(二)---接口幂等性(防止重复提交)](https://blog.csdn.net/icanlove/article/details/117652662)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^insertT0,239^v3^insert_chatgpt"}} ] [.reference_item] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

suqinyi

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值