幂等问题解决方案

news2024/11/16 6:20:39

一、什么是幂等

数学中幂等就是多次运算结果一致,对应到实际工作的软件或者网络环境中就是同一个操作不管你操作多少次结果是一样的。
我们在编程过程中会看到一些幂等是天然存在的,比如:

  1. select查询操作
  2. delete删除操作其中的根据某个key值删除
  3. update更新某个字段值

二、为什么会产生幂等问题

幂等问题之所以产生无外乎重复点击或者网络重发,比如:
1)点击提交按钮两次
2)操作进行的时候点击了刷新按钮
3)在浏览器中后退后重复之前的操作,导致重复提交表单
4)Nginx重发
5)分布式RPC环境的try重发
6)消息重复消费,使用MQ消息中间件的时候,消息中间件错误没及时提交,导致重复消费。

三、保证幂等解决方案

为了保证幂等性,主要有以下一些方法:

1)防重的标识符 (Token令牌)实现

这个方法是调用方在调用接口时候先向后端请求一个全局ID(Token),请求的时候携带全局ID一起请求,后端需要用这个Token作为Key,用户信息作为Value到Redis中进行键值内容校验,如果Key存在且Value匹配就执行删除命令,然后执行后面的业务逻辑。如果不存在对应的Key或者Value不匹配就返回执行错误的信息。
使用流程如下图所示:

①服务端提供一个接口,用于获取Token,这个Token可以是一个序列号、分布式ID或者UUID。客户端调用接口获取Token,这时服务端会生成一个Token串。
②将这个Token串存入到Redis中,以该Token作为Redis的键(需要设置过期时间)。
③将Token返回到客户端,客户端拿到后存储到表单隐藏域中。
④客户端在执行提交表单时,在Header中带上Token。
⑤服务端接收请求,从Header中拿到Token,然后在Redis中查找是否存在对应的Key,如果存在就将Key删除,如果不存在抛出重复提交的异常。这里要注意查找和删除操作都要保证原子性,否则在并发情况下可能无法保证幂等。至于原子性可以通过分布式锁或者Lua脚本来注销查询与删除操作。
⑥返回结果,执行正常的业务逻辑或者提示错误信息。

这种方法可以适用于插入、更新和删除操作。限制就是需要生成全局唯一Token串,而且需要用Redis进行数据校验。
这里我们具体看一下他的实现方法:
pom实现
引入springboot、Redis、lombok等相关依赖

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <version>2.3.5.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
            <version>2.2.2.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
            <version>2.6.1</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.16</version>
        </dependency>
    </dependencies>

application实现
一个Redis连接相关参数配置文件

spring:
  redis:
    ssl: false
    host: 127.0.0.1
    port: 6379
    database: 0
    timeout: 1000
    password:
    lettuce:
      pool:
        max-active: 100
        max-wait: -1
        min-idle: 0
        max-idle: 20

创建Token验证Token工具类

@Slf4j
@Service
public class TokenUtilService {

    @Autowired
    private StringRedisTemplate redisTemplate;

    /*
    * 存入Redis的Token的前缀
    */
    private static final String IDEMPOTENT_TOKEN_PREFIX = "idempotent_token:";
    
    
    public String generateToken(String value) {
        String token = UUID.randomUUID().toString();
        //设置存入Redis的key
        String key = IDEMPOTENT_TOKEN_PREFIX + token;
        //存储Token到Redis并设置过期时间为5分钟
        redisTemplate.opsForValue().set(key, value, 5, TimeUnit.MiNUTES);
        
        return token;
    }
    
    public boolean validToken(String token, String value) {
        //设置Lus脚本,KEYS[1]是key,KEYS[2]是value
        String script = "if redis.call('get', KEYS[1]) == KEYS[2] then return redis.call('del', KEYS[1]) else return 0 end";
        RedisScript<Long> redisScript= new DefaultRedisScript<>(script, Long.class);
        
        String key = IDEMPOTENT_TOKEN_PREFIX + token;
        //执行Lua脚本
        Long result = redisTemplate.execute(redisScript, Arrays.asList(key, value));
        //根据返回结果判断是否成功匹配并删除,结果不为空或0,验证通过
        if (result != null && result != 0L) {
            log.info("验证 token={}, key={}, value={}, 成功", token, key, value);
            return true;
        }
        log.info("验证 token={}, key={}, value={}, 失败", token, key, value);
        return false;
    }

}

测试类(Controller层模拟)

@Slf4j
@RestController
public class TokenContoller {
    
    @Autowired
    private TokenUtilService tokenService;
    
    /*
    * 获取Token接口,返回Token串
    */
    @GetMapping("/token")
    public String getToken() {
        //模拟数据,使用token验证是否存在对应的key
        String userInfo = "myInfo";
        
        return tokenService.generateToken(userInfo);
    }
    
    /*
    * 幂等性测试接口
    */
    @PostMapping("/test")
    public String test(@RequestHeader(value = "token") String token) {
        String userInfo = "myInfo";
        boolean result = tokenService.validToken(tolen, userInfo);
        return result ? "正常调用":"重复调用";
    }
}

最后,这个方案还有一个改进版本,就是引入关系库,利用关系库事务的特性来保证操作的原子性,就是把处理过的数据插入到关系库中,最后再把幂等Key插入到Redis 上,在并发情况下仍然可以保证幂等。

2)下游传递唯一序列号实现

每次向服务端请求时附带一个短时间唯一不重复的序列号,这个序列号一般由下游生成,在调用上游服务端接口时附加序列号和用于认证的ID。上游服务器拿这个序列号和下游认证ID组合,形成用于操作Redis的Key,然后到Redis中查询是否存在对应的key。如果存在,说明已经对下游的序列号的请求做了处理,直接返回重复请求的错误信息;如果不存在,就以这个Key作为Redis的键,以下游关键信息做为存储的值,将该键值对存储到Redis中,然后再执行正常的业务逻辑。
使用的流程如下图所示:

需要注意的是插入数据到Redis一定要设置过期时间。这样保证在时间范围内,重复调用接口可以识别,不然可能导致数据无限量存入Redis。
这个方法适用于插入、更新和删除操作,代价是需要第三方传递唯一序列号,而且需要使用Redis进行数据校验。

3)借助数据库主键实现

这里使用数据库唯一主键的约束特性,这种方法适用于插入时的幂等,能保证一张表值存一个带该主键的记录,这里使用的主键一般来说指的是分布式ID,这样可以保证分布式环境下ID的全局唯一性。
使用流程如下图所示:

①客户端执行创建请求,调用服务端接口。
②服务端执行业务逻辑并生成一个分布式ID,将该ID作为插入数据的主键执行插入操作,这里的ID生成算法可以使用雪花算法,也可以使用数据库号段模式或者Redis自增的方法生成分布式唯一ID。
③服务端执行数据库的插入,如果插入成功代表没有重复调用接口。如果抛出主键重复异常,就返回错误信息到客户端。

这种方法适用于插入操作和删除操作,限制是需要生成一个主键。

4)借助数据库乐观锁

数据库乐观锁一般用于更新操作的,方法是在对应的数据库表中多加一个版本标识的字段,这样每次更新都会检查这个版本标识值。
他的使用流程很简单如下图:
唯一需要注意的就是执行update语句时多一个判断当前版本的条件,例如:
update my_table set price=price+50, version=version+1 where id = 3 and version = 5;
这样每执行一次version会变,如果重复执行还是原来的版本号执行不会生效,保证了幂等。
这种方法只能用于更新操作,而且还需要在对应的数据库表中多加一个字段。
最后,我们总结一下常用的四种后端的处理幂等性问题的方法,如下所示:

除了以上说的主要的方法,还有一些方法也可以采用:

5)借助本地锁

使用了ConcurrentHashMap并发容器putIfAbsent方法和ScheduledThreadPoolExecutor 定时任务,也可以使用guava cache的机制,guava中有配有缓存的有效时间也是可以,key的生成通过Content-MD5,Content-MD5在一定范围内是唯一的,用的时候可以认为近似唯一,在低并发的环境下可以当做key 用。
当然本地锁也只适用于单机部署的应用,我们看一下他的简单实现:
配置注解:

import java.lang.annotation.Documented;
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)
@Documented
public @interface Resubmit {
    /*
     * 延时时间,在延时多久后可以再次提交,单位为秒
     * */
    int delaySeconds() default 20;
}

实例化锁:

import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils;

import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

@Slf4j
public final class ResubmitLock {
    private static final ConcurrentHashMap LOCK_CACHE = new ConcurrentHashMap(200);
    private static final ScheduledThreadPoolExecutor EXECUTOR = new ScheduledThreadPoolExecutor(5, new ThreadPoolExecutor.DiscardPolicy());

    private ResubmitLock() {
    }

    /*
     * 静态内部类的单例模式
     * */
    private static class SingletonInstance {
        private static final ResubmitLock Instance = new ResubmitLock();
    }

    public static ResubmitLock getInstance() {
        return SingletonInstance.Instance;
    }

    public static String handleKey(String param) {
        return DigestUtils.md5Hex(param == null ? "" : param);
    }

    public boolean lock(final String key, Object value) {
        return Objects.isNull(LOCK_CACHE.putIfAbsent(key, value));
    }

    public void unlock(final boolean lock, final String key, final int delaySeconds) {
        if (lock) {
            EXECUTOR.schedule(() -> {
                LOCK_CACHE.remove(key);
            }, delaySeconds, TimeUnit.SECONDS);
        }
    }
}

AOP切面:

import java.lang.reflect.Method;

@Log4j
@Aspect
@Component
public class ResubmitDataAspect {
    private final static String DATA = "data";
    private final static Object PRESENT = new Object();

    @Around("@annotation(com.cn.xxx.common.annotation.Resubmit)")
    public Object handleResubmit(ProceedingJoinPoint joinPoint) {
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        //获取注解信息
        Resubmit annotation = method.getAnnotation(Resubmit.class);
        int delaySeconds = annotation.delaySeconds();
        Object[] pointArgs = joinPoint.getArgs();
        String key = "";
        //获取第一个参数
        Object firstParam = pointArgs[0];
        if (firstParam instanceof RequestDTO) {
            //解析参数
            JSONObject requestDTO = JSONObject.parseObject(firstParam.toString());
            JSONObject data = JSONObject.parseObject(requestDTO.getString(DATA));

            if (data != null) {
                StringBuffer sb = new StringBuffer();
                data.forEach((k, v) -> {
                    sb.apperd(v);
                });
                key = ResubmitLock.handleKey(sb.toString());
            }
        }

        boolean lock = false;
        try {
            //设置解锁key
            lock = ResubmitLock.getInstance().lock(key, PRESENT);
            if (lock) {
                //放行
                return joinPoint.proceed();
            } else {
                //响应重复提交异常
                return new ResponseDTO<>(RespoinseCode.REPEAT_SUBMIT_OPERATION_EXCEPTION);
            }
        } finally {
            //设置解锁key和解锁时间
            ResubmitLock.getInstance().unLock(lock, key, delaySeconds);
        }
    }
}

使用注解:

public class ResponseToSavaPosts {

    @ApiOperation(value = "保存我的帖子接口", notes = "保存我的帖子接口")
    @PostMapping("/posts/save")
    @Resubmit(delaySeconds = 10)
    public void ResponseToSava(@RequestBody @Validated RequestDTOrequestDto) {
        return bbsPostsBizService.saveBbsPosts(requestDto);
    }
}
6)借助分布式Redis锁

熟悉Redis的都知道它是线程安全的,我们利用它的特性可以很轻松的实现一个分布式锁,比如opsForValue().setIfAbsent(key)它的作用就是如果缓存中没有当前key则进行缓存同时返回true,当缓存后给key再设置一个过期时间,防止因为系统崩溃而导致锁不释放形成死锁,我们可以认为当返回true的时候他取到锁了,在锁没有释放的时候我们进行异常的抛出。

7)借助数据库悲观锁

使用select … for update,这样和synchronized的原理是一样的,先锁住再查再执行update或insert操作。这样做的问题是要考虑如何避免死锁,而且效率也比较差,这种方法针对单体应用并发量小的情况下可以用。

8)前端页面保证

通常是在提交后,设置提交按钮禁止点击(一般会设定一个定长的时间段)。

9)使用Post/Redirect/Get模式

这种方式就是提交后执行页面重定向,PRG(Post-Redirect-Get)模式。
也就是说用户提交表单后,去执行一个客户端的重定向,转到提交成功的信息页面。这样能够避免页面刷新导致的重复提交,也不会出现浏览器表单重复提交的警告,也能消除按浏览器前进后退按钮导致的问题。

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

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

相关文章

[BitSail] Connector开发详解系列四:Sink、Writer

更多技术交流、求职机会&#xff0c;欢迎关注字节跳动数据平台微信公众号&#xff0c;回复【1】进入官方交流群 Sink Connector BitSail Sink Connector交互流程介绍 Sink&#xff1a;数据写入组件的生命周期管理&#xff0c;主要负责和框架的交互&#xff0c;构架作业&#x…

Vue2项目练手——通用后台管理项目第四节

Vue2项目练手——通用后台管理项目 数据的请求mock数据模拟实战文件目录src/api/mock.jssrc/api/mockServeData/home.jsmain.js 首页组件布局可视化图表可视化图表布局Home.vue echarts表Home.vue 数据的请求 mock数据模拟实战 mock官方文档 前端用来模拟后端接口的工具&…

3分钟做出的大屏可视化报表,被领导疯狂点赞

3分钟&#xff0c;不仅做出了大屏可视化报表&#xff0c;还被领导疯狂点赞&#xff01;你没看错&#xff0c;这确实是可以实现的。奥威BI数据可视化工具提供大量可视化大屏报表模板&#xff0c;只需一键下载使用&#xff0c;替换数据源&#xff0c;再根据个性化需求进行调整修改…

Windows环境下的Tomcat服务器安装和配置教程,包括外网远程访问的设置方法

文章目录 前言1.本地Tomcat网页搭建1.1 Tomcat安装1.2 配置环境变量1.3 环境配置1.4 Tomcat运行测试1.5 Cpolar安装和注册 2.本地网页发布2.1.Cpolar云端设置2.2 Cpolar本地设置 3.公网访问测试4.结语 前言 Tomcat作为一个轻量级的服务器&#xff0c;不仅名字很有趣&#xff0…

【算法与数据结构】617、LeetCode合并二叉树

文章目录 一、题目二、解法三、完整代码 所有的LeetCode题解索引&#xff0c;可以看这篇文章——【算法和数据结构】LeetCode题解。 一、题目 二、解法 思路分析&#xff1a;采用递归的方式遍历二叉树&#xff0c;【算法与数据结构】144、94、145LeetCode二叉树的前中后遍历&am…

手机提词器怎么开启?这些方法不要错过

随着科技的发展&#xff0c;手机提词器成为了越来越多人写作的好帮手。在很多情况下比如直播、视频会议我们不方便一边看镜头一边看文稿&#xff0c;这种时候我们就需要使用提词器功能来规避麻烦了&#xff0c;如何开启手机提词器&#xff1f;有哪些需要注意的事项呢&#xff1…

常见变频器品牌-修改参数时的密码汇总

常见变频器品牌-修改参数时的密码汇总 1. 艾默生TD3000系列 密码:8888 2. 艾默生TD3300系列 密码:2002 3. 施耐德变频器 在SUP菜单下,找到COD选项进入,输入6969即可, 4. 台达变频器-B系列 密码:57522 5. 台达变频器-H系列 密码:33582 6. 台达S1系列 密码:57522

网络渗透day6-面试01

&#x1f609; 和渗透测试相关的面试问题。 介绍 如果您想自学网络渗透&#xff0c;有许多在线平台和资源可以帮助您获得相关的知识和技能。以下是一些受欢迎的自学网络渗透的平台和资源&#xff1a; Hack The Box: Hack The Box&#xff08;HTB&#xff09;是一个受欢迎的平…

深度学习-4-二维目标检测-YOLOv3模型

单阶段目标检测模型YOLOv3 R-CNN系列算法需要先产生候选区域&#xff0c;再对候选区域做分类和位置坐标的预测&#xff0c;这类算法被称为两阶段目标检测算法。近几年&#xff0c;很多研究人员相继提出一系列单阶段的检测算法&#xff0c;只需要一个网络即可同时产生候选区域并…

简单深度理解c++数论--资料免费分享

本篇博文想分享一个数论资料,是帮助大家简单深度理解c数论. 作者承诺&#xff1a;分享的东西没有病毒&#xff0c;是资料。 分享的东西是关于数论的。 分享的东西免费&#xff01;免费&#xff01;免费&#xff01;欢迎大家下载学习&#xff01; 创作不易&#xff0c;请多加…

django中配置使用websocket终极解决方案

django ASGI/Channels 启动和 ASGI/daphne的区别 Django ASGI/Channels 是 Django 框架的一个扩展&#xff0c;它提供了异步服务器网关接口&#xff08;ASGI&#xff09;协议的支持&#xff0c;以便处理实时应用程序的并发连接。ASGI 是一个用于构建异步 Web 服务器和应用程序…

若依tab-content面板失效、使用load的解决方法(附详细步骤)

【版权所有&#xff0c;文章允许转载&#xff0c;但须以链接方式注明源地址&#xff0c;否则追究法律责任】【创作不易&#xff0c;点个赞就是对我最大的支持】 前言 仅作为学习笔记&#xff0c;供大家参考 总结的不错的话&#xff0c;记得点赞收藏关注哦&#xff01; 思路&…

智慧工地-工地管理系统源码

智慧工地是聚焦工程施工现场&#xff0c;紧紧围绕人、机、料、法、环等关键要素&#xff0c;综合运用物联网、云计算、大数据、移动计算和智能设备等软硬件信息技术&#xff0c;与施工生产过程相融合。 智慧工地管理平台充分运用数字化技术&#xff0c;聚焦施工现场岗位一线&am…

剑指 Offer 14- I. 剪绳子(中等)

题目&#xff1a; class Solution { public:int cuttingRope(int n) {vector<int> dp(n1); //dp[i]表示长度为i的绳子能得到的最大乘积dp[0]0;dp[1]0;dp[2]1; //长度为0和1的绳子不能剪不了for(int i3;i<n;i){for(int j1;j<i/2;j){ //j代表这一次剪…

K8s组件全解析,你需要知道的一切秘密

当你部署完 Kubernetes&#xff0c;便拥有了一个完整的集群。 Kubernetes&#xff0c;作为目前最流行和广泛采用的容器编排和管理平台&#xff0c;背后有一系列强大的组件&#xff0c;共同协作以实现容器化应用的自动化部署、弹性扩展、服务发现和负载均衡等关键功能。本文将介…

环保环卫行业案例 | 燕千云助力高能环境搭建数智化IT服务管理体系及平台

当前环境卫生问题在全球已引起前所未有的关注&#xff0c;而促进健康又成为环境与发展所关注的核心问题。随着数字化时代的到来&#xff0c;环保环卫行业呈现出多个发展趋势&#xff0c;随着业务系统规模的不断扩大&#xff0c;信息系统的运维问题也日益突出&#xff0c;需要得…

Linux6.41 Kubernetes 对外服务之 Ingress

文章目录 计算机系统5G云计算第三章 LINUX Kubernetes 对外服务之 Ingress一、Ingress 简介1.NodePort2.LoadBalancer3.externalIPs4.Ingress 二.Ingress 组成1.ingress2.ingress-controller3.总结 三、Ingress-Nginx 工作原理四、ingress 暴露服务的方式1.DaemonSetHostNetwor…

详解排序算法(附带Java/Python/Js源码)

冒泡算法 依次比较两个相邻的子元素&#xff0c;如果他们的顺序错误就把他们交换过来&#xff0c;重复地进行此过程直到没有相邻元素需要交换&#xff0c;即完成整个冒泡&#xff0c;时间复杂度。 比较相邻的元素。如果第一个比第二个大&#xff0c;就交换它们两个&#xff1b;…

MATLAB中circshift函数转化为C语言

背景 有项目算法使用matlab中circshift函数进行运算&#xff0c;这里需要将转化为C语言&#xff0c;从而模拟算法运行&#xff0c;将算法移植到qt。 MATLAB中circshift简单介绍 circshift是循环移位函数。可以使用于数组和矩阵元素的循环移位。 当A是数组 Bcircshift(A,p);如果…