【技术应用】java接口幂等性实现方案

news2024/11/18 1:26:32

【技术应用】java接口幂等性实现方案

    • 一、前言
    • 二、幂等性
    • 三、幂等设计思路
    • 四、实现代码
    • 五、总结

一、前言

最近在做一个线上的项目,与之前内网项目还是有很多差别的,尤其在安全性并发性的处理上,要多做一些措施,第一步就是接口的幂等性上,这也是接口并发请求安全的兜底保护,针对实际的业务场景,总结实现了一个接口幂等性的demo,在此分享一下;

二、幂等性

1、概念:

  • 幂等性原本是数学上的概念,即使公式:f(x)=f(f(x)) 能够成立的数学性质。用在编程领域,则意为对同一个系统,使用同样的条件,一次请求和重复的多次请求对系统资源的影响是一致的
  • 幂等性是分布式系统设计中十分重要的概念,具有这一性质的接口在设计时总是秉持这样的一种理念:调用接口发生异常并且重复尝试时,总是会造成系统所无法承受的损失,所以必须阻止这种现象的发生
  • 实现幂等的方式很多,目前基于请求令牌机制适用范围较广。其核心思想是为每一次操作生成一个唯一性的凭证,也就是 token。一个 token在操作的每一个阶段只有一次执行权,一旦执行成功则保存执行结果。对重复的请求,返回同一个结果(报错)等。

2、接口幂等性设计原因

1)前端重复提交表单
在填写一些表格时候,用户填写完成提交,很多时候会因网络波动没有及时对用户做出提交成功响应,致使用户认为没有成功提交,然后一直点提交按钮,这时就会发生重复提交表单请求。

2)黑客恶意攻击
例如在实现用户投票这种功能时,如果黑客针对一个用户进行重复提交投票,这样会导致接口接收到用户重复提交的投票信息,这样会使投票结果与事实严重不符。

3)接口超时重复提交
大部分RPC框架[比如Dubbo],为了防止网络波动超时等造成的请求失败,都会添加重试机制,导致一个请求提交多次。

4)消息重复消费
当使用 MQ 消息中间件时候,如果Consumer消费超时或者producer发送了消息但由于网络原因未收到ACK导致消息重发,都会导致重复消费。

3、哪些接口需要幂等?

幂等性的实现与判断需要消耗一定的资源,因此不应该给每个接口都增加幂等性判断,要根据实际的业务情况和操作类型来进行区分。例如,我们在进行查询操作和删除操作时就无须进行幂等性判断。

查询操作查一次和查多次的结果都是一致的,因此我们无须进行幂等性判断。删除操作也是一样,删除一次和删除多次都是把相关的数据进行删除(这里的删除指的是条件删除而不是删除所有数据),因此也无须进行幂等性判断。

所以到底哪些接口需要幂等?关于这个问题需要从具体业务出发,但是也有规律可循如下表:
在这里插入图片描述

三、幂等设计思路

  1. 请求开始前,根据 key 查询 查到结果:报错 未查到结果:存入 key-value-expireTime
    key=ip+url+args
  2. 请求结束后,直接删除 key 不管 key 是否存在,直接删除 是否删除,可配置
  3. expireTime 过期时间,防止一个请求卡死,会一直阻塞,超过过期时间,自动删除 过期时间要大于业务执行时间,需要大概评估下;
  4. 此方案直接切的是接口请求层面。
  5. 过期时间需要大于业务执行时间,否则业务请求 1 进来还在执行中,前端未做遮罩,或者用户跳转页面后再回来做重复请求
    2,在业务层面上看,结果依旧是不符合预期的。
  6. 建议 delKey = false。即使业务执行完,也不删除 key,强制锁 expireTime 的时间。预防 5 的情况发生。
  7. 实现思路同一个请求 ip 和接口,相同参数的请求,在 expireTime 内多次请求,只允许成功一次。
  8. 页面做遮罩,数据库层面的唯一索引,先查询再添加,等处理方式应该都处理下。
  9. 此设计只用于幂等,不用于锁,100 个并发这种压测,会出现问题,在这种场景下也没有意义,实际中用户也不会出现 1s 或者 3s内手动发送了 50 个或者 100 个重复请求,或者弱网下有 100 个重复请求;

四、实现代码

1、自定义注解

自定义注解Idempotent

package com.sk.idempotenttest.annotation;

import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;

/**
 * @description: Idempotent annotation
 *
 * @author dylan
 *
 */
@Inherited
@Target(ElementType.METHOD)
@Retention(value = RetentionPolicy.RUNTIME)
public @interface Idempotent {


    /**
     * 是否做幂等处理
     * false:非幂等
     * true:幂等
     * @return
     */
    boolean isIdempotent() default false;

    /**
     * 有效期
     * 默认:1
     * 有效期要大于程序执行时间,否则请求还是可能会进来
     * @return
     */
    int expireTime() default 1;

    /**
     * 时间单位
     * 默认:s
     * @return
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;

    /**
     * 提示信息,可自定义
     * @return
     */
    String info() default "重复请求,请稍后重试";

    /**
     * 是否在业务完成后删除key
     * true:删除
     * false:不删除
     * @return
     */
    boolean delKey() default false;
}

注解使用:

@Idempotent(isIdempotent = true,expireTime = 10,timeUnit = TimeUnit.SECONDS,info = "请勿重复请求",delKey = false)
@GetMapping("/test")
public String test(){
  return "哈哈哈";
}

注解属性说明:

  • key: 幂等操作的唯一标识,使用 spring el 表达式 用#来引用方法参数 。 可为空则取当前 url + args
    做请求的唯一标识
  • expireTime: 有效期 默认:1 有效期要大于程序执行时间,否则请求还是可能会进来
  • timeUnit: 时间单位 默认:s (秒)
  • info: 幂等失败提示信息,可自定义
  • delKey: 是否在业务完成后删除 key true:删除 false:不删除

2、切面实现

package com.sk.idempotenttest.aspect;

import com.sk.idempotenttest.annotation.Idempotent;
import com.sk.idempotenttest.exception.IdempotentException;
import com.sk.idempotenttest.utils.ServerTool;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.redisson.Redisson;
import org.redisson.api.RMapCache;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;

/**
 * @description: The Idempotent Aspect
 *
 * @author dylan
 *
 */
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class IdempotentAspect {

    private ThreadLocal<Map<String,Object>> threadLocal = new ThreadLocal();
    private static final String RMAPCACHE_KEY = "idempotent";
    private static final String KEY = "key";
    private static final String DELKEY = "delKey";

    private final Redisson redisson;


    @Pointcut("@annotation(com.sk.idempotenttest.annotation.Idempotent)")
    public void pointCut(){}

    @Before("pointCut()")
    public void beforePointCut(JoinPoint joinPoint)throws Exception{
        ServletRequestAttributes requestAttributes =
                (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = requestAttributes.getRequest();

        MethodSignature signature = (MethodSignature)joinPoint.getSignature();
        Method method = signature.getMethod();
        if(!method.isAnnotationPresent(Idempotent.class)){
            return;
        }
        Idempotent idempotent = method.getAnnotation(Idempotent.class);
        boolean isIdempotent = idempotent.isIdempotent();
        if(!isIdempotent){
            return;
        }

        String ip = ServerTool.getIpAddress(request);
        String url = request.getRequestURL().toString();
        String argString  = Arrays.asList(joinPoint.getArgs()).toString();
        String key = ip+ url + argString;  //key主要用于对请求者,请求客户端的请求频次做限制,也可以使用token作为key

        long expireTime = idempotent.expireTime();
        String info = idempotent.info();
        TimeUnit timeUnit = idempotent.timeUnit();
        boolean delKey = idempotent.delKey();

        //do not need check null
        RMapCache<String, Object> rMapCache = redisson.getMapCache(RMAPCACHE_KEY);
        String value = LocalDateTime.now().toString().replace("T", " ");
        Object v1;
        if (null != rMapCache.get(key)){
            //had stored
            throw new IdempotentException(info);
        }
        synchronized (this){
            v1 = rMapCache.putIfAbsent(key, value, expireTime, TimeUnit.SECONDS);
            if(null != v1){
                throw new IdempotentException(info);
            }else {
                log.info("[idempotent]:has stored key={},value={},expireTime={}{},now={}",key,value,expireTime,timeUnit,LocalDateTime.now().toString());
            }
        }

        Map<String, Object> map =
                CollectionUtils.isEmpty(threadLocal.get()) ? new HashMap<>(4):threadLocal.get();
        map.put(KEY,key);
        map.put(DELKEY,delKey);
        threadLocal.set(map);

    }

    @After("pointCut()")
    public void afterPointCut(JoinPoint joinPoint){
        Map<String,Object> map = threadLocal.get();
        if(CollectionUtils.isEmpty(map)){
            return;
        }

        RMapCache<Object, Object> mapCache = redisson.getMapCache(RMAPCACHE_KEY);
        if(mapCache.size() == 0){
            return;
        }

        String key = map.get(KEY).toString();
        boolean delKey = (boolean)map.get(DELKEY);

        if(delKey){
            mapCache.fastRemove(key);
            log.info("[idempotent]:has removed key={}",key);
        }
        threadLocal.remove();
    }
}

3、全局异常处理

GlobalExceptionHandler

package com.sk.idempotenttest.exception;

import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;

@Slf4j
@ControllerAdvice
@ResponseBody
public class GlobalExceptionHandler {

    @ExceptionHandler(IdempotentException.class)
    @ResponseStatus(value= HttpStatus.BAD_REQUEST)
    public JsonResult handleHttpMessageNotReadableException(IdempotentException ex){
        log.error("请求异常,{}",ex.getMessage());
        return new JsonResult("400",ex.getMessage());
    }

}

返回值JsonResult

package com.sk.idempotenttest.exception;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class JsonResult {

    private String code;//状态码
    private String msg;//请求信息

}

4、redis存储请求信息

RedissonConfig

package com.sk.idempotenttest.config;

import org.redisson.Redisson;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * @description: Redisson配置类
 *
 * @author ITyunqing
 * @since 1.0.0
 */
@Configuration
public class RedissonConfig {

    @Value("${singleServerConfig.address}")
    private String address;

    @Value("${singleServerConfig.password}")
    private String password;

    @Value("${singleServerConfig.pingTimeout}")
    private int pingTimeout;

    @Value("${singleServerConfig.connectTimeout}")
    private int connectTimeout;

    @Value("${singleServerConfig.timeout}")
    private int timeout;

    @Value("${singleServerConfig.idleConnectionTimeout}")
    private int idleConnectionTimeout;

    @Value("${singleServerConfig.retryAttempts}")
    private int retryAttempts;

    @Value("${singleServerConfig.retryInterval}")
    private int retryInterval;

    @Value("${singleServerConfig.reconnectionTimeout}")
    private int reconnectionTimeout;

    @Value("${singleServerConfig.failedAttempts}")
    private int failedAttempts;

    @Value("${singleServerConfig.subscriptionsPerConnection}")
    private int subscriptionsPerConnection;

    @Value("${singleServerConfig.subscriptionConnectionMinimumIdleSize}")
    private int subscriptionConnectionMinimumIdleSize;

    @Value("${singleServerConfig.subscriptionConnectionPoolSize}")
    private int subscriptionConnectionPoolSize;

    @Value("${singleServerConfig.connectionMinimumIdleSize}")
    private int connectionMinimumIdleSize;

    @Value("${singleServerConfig.connectionPoolSize}")
    private int connectionPoolSize;


    @Bean(destroyMethod = "shutdown")
    public Redisson redisson() {
        Config config = new Config();
        config.useSingleServer().setAddress(address)
                .setPassword(password)
                .setIdleConnectionTimeout(idleConnectionTimeout)
                .setConnectTimeout(connectTimeout)
                .setTimeout(timeout)
                .setRetryAttempts(retryAttempts)
                .setRetryInterval(retryInterval)
                .setReconnectionTimeout(reconnectionTimeout)
                .setPingTimeout(pingTimeout)
                .setFailedAttempts(failedAttempts)
                .setSubscriptionsPerConnection(subscriptionsPerConnection)
                .setSubscriptionConnectionMinimumIdleSize(subscriptionConnectionMinimumIdleSize)
                .setSubscriptionConnectionPoolSize(subscriptionConnectionPoolSize)
                .setConnectionMinimumIdleSize(connectionMinimumIdleSize)
                .setConnectionPoolSize(connectionPoolSize);
        return (Redisson) Redisson.create(config);
    }

}

5、pom.xml

<!--aop-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>
        <!--redisson-->
        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson</artifactId>
            <version>3.5.4</version>
        </dependency>

五、总结

幂等性不但可以保证程序正常执行,还可以杜绝一些垃圾数据以及无效请求对系统资源的消耗。推荐使用分布式锁来实现,这样的解决方案更加通用。关于分布式锁的总结,后续再做介绍

建议:

  • 对 redis 中是否存在 token 以及删除的代码逻辑建议用 Lua 脚本实现,保证原子性
  • 全局唯一 ID 可以用百度的 uid-generator美团的 Leaf 去生成

==如果文章对你有帮助,请帮忙点赞、收藏和给个评价=

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/107403.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Java HashSet

HashSet 基于 HashMap 来实现的&#xff0c;是一个不允许有重复元素的集合。 HashSet 允许有 null 值。 HashSet 是无序的&#xff0c;即不会记录插入的顺序。 HashSet 不是线程安全的&#xff0c; 如果多个线程尝试同时修改 HashSet&#xff0c;则最终结果是不确定的。 您必…

流量与技术双重加持,小游戏迎来高速增长周期

2017 年 12 月 28 日&#xff0c;微信小游戏正式上线。“跳一跳”刷爆了微信朋友圈&#xff0c;随后欢乐斗地主、坦克大战、纪念碑谷、拳皇等经典游戏纷纷出现在小游戏平台上。在过去的5年间&#xff0c;各大平台纷纷紧跟微信的步伐&#xff0c;纷纷入局小游戏&#xff0c;当前…

【CSS】速查复习background相关所有属性(上)

前言 background是一种 CSS 简写属性&#xff0c;用于一次性集中定义一个或多个背景属性&#xff0c;其中的属性有以下这些&#xff1a; background-clip background-color background-image background-origin background-size background-attachment background-blend…

window.location.href跳转页面后拿不到cookie

场景 最近在改其他同事写的系统时&#xff0c;我只改了个文案&#xff0c;但是打包部署上去发现其他地方出现了问题。原因可能是因为这个问题被同事修复过但是没有把代码提交&#xff0c;我拉取的时候这个问题还是存在的。最终拿同事之前打的包与我打的最新包对比&#xff0c;…

Java项目:SpringBoot+Mybatis+layui的学生成绩管理系统

作者主页&#xff1a;源码空间站2022 简介&#xff1a;Java领域优质创作者、Java项目、学习资料、技术互助 文末获取源码 功能介绍 SpringBoot学生成绩管理系统。主要分老师与学生两个角色。 其中&#xff0c;教师角色包含以下功能&#xff1a; 教师登录,学生信息管理,成绩管…

ETHERCAT从站设计与FOC伺服马达电流环控制

ETHERCAT从站开发方案介绍-含ET9300对比 EtherCAT一般设计要求&#xff08;针对uCESC的方案&#xff09;&#xff1a; 硬件上&#xff1a;主控制器uCESC&#xff08;可选各供应商的EtherCAT从站控制器&#xff09; 操作系统&#xff1a;无特殊要求&#xff0c;根据产品性能决…

利用python在网上接单赚钱,兼职也能月入过万,还不赶紧学起来

我觉得python接单我是最有发言权的&#xff0c;从2013年进入大学&#xff0c;我就是一个不安分的学生&#xff0c;总是想着通过自己的技术来实现财富自由。 我崇拜雷军&#xff0c;我觉得雷布斯不仅技术强&#xff0c;而且很有商业头脑&#xff0c;可是我是个呆呆的瓜皮&#…

小白到底如何学 Python?

小白&#xff1a;我为什么要学习Python, 它能为我带来什么&#xff1f;我能学会吗&#xff1f;…… 为什么学习 Python? 计算机编程语言有很多&#xff0c;在我接触到的语言里面&#xff0c;比如Java, C, C等&#xff0c;Python是最容易上手的一门语言。 只要你会一点英语&…

GitHub 又一可视化低代码神器,诞生了!速度!手慢无!

在此之前&#xff0c;我曾多次与您交谈&#xff0c;在现阶段互联网业务疯狂增长的推动下&#xff0c;低代码编程被赋予了新的使命和义务&#xff0c;即帮助开发人员快速构建一个可以在早期以较低成本投入市场的应用程序。 那么&#xff0c;有没有一个成熟的低代码工具是开源的、…

使用docker快速部署ferry开源工单系统

大家好&#xff0c;我是早九晚十二&#xff0c;目前是做运维相关的工作。写博客是为了积累&#xff0c;希望大家一起进步&#xff01; 我的主页&#xff1a;早九晚十二 开源软件ferry是集工单统计、任务钩子、权限管理、灵活配置流程与模版等等于一身的开源工单系统&#xff0c…

【蓝桥杯】第11届Scratch国赛中级组第6题 -- 3D打印小猫

[导读]&#xff1a;蓝桥杯大赛是工业和信息化部人才交流中心举办的全国性专业信息技术赛事。蓝桥杯大赛首席专家倪光南院士说&#xff1a;“蓝桥杯以考促学&#xff0c;塑造了领跑全国的人才培养选拨模式&#xff0c;并获得了行业的深度认可。” 春雷课堂计划推出Scratch蓝桥杯…

Linux网络协议之TCP协议(传输层)

Linux网络协议之TCP协议(传输层) 文章目录Linux网络协议之TCP协议(传输层)1.理解TCP协议2.谈谈可靠性问题3.TCP协议格式4.关于TCP的两个问题5.TCP序号与确认序号6.TCP缓冲区7.TCP窗口大小8.TCP的六个标志位9.确认应答机制(ACK)10.超时重传机制11.连接管理机制11.1 三次握手和四…

LEADTOOLS 入门教程: 使用 H264 视频创建 DICOM 文件 - 控制台 C#

LEADTOOLS是一个综合工具包的集合&#xff0c;用于将识别、文档、医疗、成像和多媒体技术整合到桌面、服务器、平板电脑、网络和移动解决方案中&#xff0c;是一项企业级文档自动化解决方案&#xff0c;有捕捉&#xff0c;OCR&#xff0c;OMR&#xff0c;表单识别和处理&#x…

Android系统之路(初识MTK) ------Android11.0添加Recents一键清除最近任务按钮

Android11.0添加Recents一键清除最近任务按钮 今天因为在复测昨天的一个monkey压测并且还没测完,所以打算记录最近做系统开发的一些心得和经验,也记录一下自己的系统开发历程 修改前效果: 修改后的效果: 后期补上… 需要修改的文件列表(注意:各个版本或平台可能要修改…

Git使用

一、Git介绍 1.1、版本控制 在我们日常生活中&#xff0c;使用微信6.5.3版本&#xff0c;QQ7.4版本&#xff0c;Chrome 43.0.2357.65 版本&#xff0c;表示的都是某些软件使用的版本号。这些软件在开发过程中&#xff0c;版本都是由1不断的变化而来。对于软件公司来说&#x…

用魔法打败魔法!AI识别名人造假视频;OpenAI开源Point-E进军3D打印市场;谷歌CALM算法加速文本生成… | ShowMeAI资讯日报

&#x1f440;日报合辑 | &#x1f3a1;AI应用与工具大全 | &#x1f514;公众号资料下载 | &#x1f369;韩信子 &#x1f4e2; 用魔法打败魔法&#xff01;基于面部、手势和声音识别名人 deepfake 视频 https://www.pnas.org/doi/pdf/10.1073/pnas.2216035119 Deepfake 是 …

模型评估指标

模型评估指标【准度、精度、召回率、F1-score及ROC曲线】总结 参考于李沐的机器学习课程。 通常要使用多个模型综合评价一个模型的好坏。 Accuracy 预测正确的个数 / 样本总个数 sum(y_pred y_label)/y_label.size()Precision 正确地预测为类别 i 的个数 / 预测为 i 的总…

【信管5.1】进度管理规划与活动

进度管理规划与活动进度这个东西&#xff0c;相信在不少老板眼里就是你加班的基础。进度赶不上了怎么办&#xff1f;加班呀&#xff0c;进度赶上了呢&#xff1f;再多做点东西呀&#xff01;反正加班这件事是少不了的&#xff0c;当你学习完我们的项目管理知识之后&#xff0c;…

求词频与逆词频SnowNLP.tf与SnowNLP.idf

【小白从小学Python、C、Java】 【计算机等级考试500强双证书】 【Python-数据分析】 求词频与逆词频 SnowNLP.tf与SnowNLP.idf 选择题 以下关于python代码表述有误的一项是? from snownlp import SnowNLP myText ([ [python, python], [python, 编程, 编程], [django, py…

Java项目:springboot+vue教室图书馆预约管理系统

作者主页&#xff1a;源码空间站2022 简介&#xff1a;Java领域优质创作者、Java项目、学习资料、技术互助 文末获取源码 智慧物联网教室预约系统-后台系统 项目简介&#xff1a; 这是一个前后端分离的教室预约和查看系统项目&#xff0c;能够实现以教室为单位活动的预约和取…