幂等问题解决方案

这篇具有很好参考价值的文章主要介绍了幂等问题解决方案。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

一、什么是幂等

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

  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)模式。
也就是说用户提交表单后,去执行一个客户端的重定向,转到提交成功的信息页面。这样能够避免页面刷新导致的重复提交,也不会出现浏览器表单重复提交的警告,也能消除按浏览器前进后退按钮导致的问题。文章来源地址https://www.toymoban.com/news/detail-687714.html

到了这里,关于幂等问题解决方案的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • 前端下载后端文件流,文件可以下载,但是打不开,显示“文件已损坏”的问题分析与解决方案

    我在前端使用axios接收后端xlsx表格文件流并下载,xlsx文件能够下载成功,但是打开却显示文件无法打开 请求API封装: Content–Type 以及 responseType 经核对均没有问题 接口调用: 下载函数封装: 预览: 二进制数据 可以看到文件数据已经接收到并且是二进制的,但是转为的xlsx文件却

    2024年02月04日
    浏览(55)
  • 【JavaWeb】项目后端部分统一解决方案

    Result类 ResultCodeEnum 枚举类 步骤: 前端登陆时发送登陆请求,后端校验,成功则将用户id加密到token中,返回给前端 前端收到 token 存到 localStorage 或 sessionStorage 中 后续发送请求时都将 token 放到请求头中,带给后端 且前端在路由跳转前都需校验是否存在 token 才进行页面跳转(

    2024年02月04日
    浏览(33)
  • 前后端分离 后端获取不到header解决方案

    我这里只是把重要的逻辑放在里面,如果要看所有文件的话就太多了 这个案例不要拿来用,这个是有问题的,我只是讲一下问题在哪

    2024年02月12日
    浏览(62)
  • 后端返回图片,前端接收并显示的解决方案

    后端通过二进制流的形式,写入response中 controller层 service层 axios接受数据时,responseType 默认返回数据类型是 json,必须将其改为返回数据类型 blob。否则axois无法正确解析数据。 这里的http.request是对axios的封装,把他当作axios用就行 vue界面渲染 后端接口,最好不要有返回值,

    2024年02月12日
    浏览(53)
  • 前端jd要求:了解一门后端开发语言优先 解决方案之Node.js

    作为前端开发者,了解一门后端开发语言可以为我们提供更多的职业机会和技术优势。在当今的技术领域中,前后端分离的开发模式已经成为主流,前端和后端的协作和沟通变得越来越紧密。因此,作为前端开发者,学习一门后端语言已经成为提高自己技能的重要途径。 以下

    2024年02月12日
    浏览(49)
  • postman后端测试时invalid token报错+token失效报错解决方案

    没有添加postman的token信息 写了token但是token信息写的是错的,会提示token失效

    2024年01月19日
    浏览(40)
  • uniapp 开发App使用微信H5支付解决方案(包含前后端,后端用的thinkphp)

    我们在开发app常常需要接入支付功能,但是有时候出于包体积或审核的因素,并不想接入支付相关的sdk,这个时候,就可以考虑使用 h5支付 完成购买服务,只需要访问后端返回的 h5支付链接 即可,便捷而简单。 话不多说,进入正题! 前往微信商户平台 - 产品中心 - 我的产品

    2024年02月06日
    浏览(43)
  • 前端传参中带有特殊符号导致后端接收时乱码或转码失败的解决方案

    项目中采用富文本编辑器后传参引起的bug,起因如下: 数据库中存入的数据会变成这种未经转码的URL编码 使用JSON方式传参,但富文本编辑器不支持将内容转成JSON,会遗失标签,显然不符合把富文本文章存入数据库的需求,所以PASS 使用URL拼接方式传参,而缺点也是明显的,

    2024年01月25日
    浏览(47)
  • 请求后端出现“Content type ‘application/octet-stream‘not supported“错误解决方案

    首先看报错。此报错是Springboot 报错。 看看Postman 正确的传递方式。 Vue应该怎么传递呢?使用 FormData 对象。 说明: 主要的解决思路是,要指定上传文件时其他附加信息的 contentType,那么去FormData对象如何指定某个参数的 contentType属性。 参考: https://blog.csdn.net/weixin_44030791/

    2024年02月11日
    浏览(46)
  • 微信小程序学习实录2(下拉刷新、下拉加载更多、小程序事件、PHP后端代码、刷新无数据解决方案)

    lazyCodeLoading基础库 2.11.1 及以上版本支持,2.11.1 以下兼容但无优化效果 通常情况下,在小程序启动期间,所有页面及自定义组件的代码都会进行注入,当前页面没有使用到的自定义组件和页面在注入后其实并没有被使用。自基础库版本 2.11.1 起,小程序支持有选择地注入必要

    2024年02月05日
    浏览(49)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包