飞道的博客

写一个通用的幂等组件,我觉得很有必要

337人阅读  评论(0)

本文目录

  1. 背景

  2. 简单幂等实现

2.1 数据库记录判断

2.2 并发问题解决

  1. 通用幂等实现

3.1 设计方案

3.1.1 通用存储

3.1.2 使用简单

3.1.3 支持注解

3.1.4 多级存储

3.1.5 并发读写

3.1.6 执行流程

3.2 幂等接口

3.3 幂等注解

3.4 自动区分重复请求

3.5 存储结构

3.6 源码地址

背景

回答群友的问题:幂等有没有什么通用的方案和实践?

关于什么是幂等,本文就不再阐述了。相信大家都知道,并且也都遇到过类似的问题以及有自己的一套解决方案。

基本上所有业务系统中的幂等都是各自进行处理,也不是说不能统一处理,统一处理的话需要考虑的内容会比较多。

我个人认为核心的业务还是适合业务方自己去处理,比如订单支付,会有个支付记录表,一个订单只能被支付一次,通过支付记录表就可以达到幂等的效果。

还有一些不是核心的业务,但是也有幂等的需求。比如网络问题,多次重试。用户点击多次等场景。这种场景下还是需要一个通用的幂等框架来处理,会让业务开发更加简单。

简单幂等实现

幂等的实现其实并不复杂,方案也有很多种,首先介绍下基于数据库记录的方案来实现,后面再介绍通用方案。

数据库记录判断

以文章开头讲的支付场景来举例。业务场景是一个订单只能支付一次,所以我们在支付之前会判断这个订单有没有支付过,如果没有支付过则进行支付,如果支付过了,就反正支付成功,幂等。

这种方式需要有一个额外的表来存储做过的动作,才能判断之前有没有做过这件事情。

就好比你年龄大了,然后还是单身的技术宅。这个时候你家里着急了呀,你老妈天天给你介绍小姐姐。你每个周末都要打扮的非常帅气,去见你老妈给你介绍的小姐姐。

去之前你得记录下吧,8 月第一周我见的 XXX, 第二周我见的 YYY, 如果第三周又让你去见 XXX, 如果这个时候你不喜欢 XXX, 你会翻出你的小本本看下,这个之前见过了,没必要再见了,不然见了多尴尬啊。

并发问题解决

通过查询支付记录,判断能否进行支付在业务逻辑上没一点问题。但是在并发场景就会有问题。

1001 的订单发起了两次支付请求,当前两个请求同时查询支付记录,都没有查询到,然后都开始走支付的逻辑,最后发现同一个订单支付了两次,这就是并发导致的幂等问题。

并发解决的方案也有很多种,简单点的直接用数据库的唯一索引解决,稍微麻烦点的都会用分布式锁来对同一个资源进行加锁。

比如我们对订单 1001 进行加锁,如果同时发起了两次支付请求,那么同一时间只能有一个请求可以获取锁,另一个请求获取不到锁可以直接失败,也可以等待前面的请求执行完成。

如果等待前面的请求执行完成,接着往下处理,就能查到 1001 已经支付过了,直接返回支付成功了。

通用幂等实现

为了能够让大家更专注于业务功能的开发,简单场景的幂等操作我认为可以进行统一封装来处理,下面介绍一下通用幂等的实现。

设计方案

通用存储

一般我们在程序内部做幂等的话都是先查询,然后根据查询的结果做对应的操作。同时会对相同的资源进行加锁来避免并发问题。

加锁是通用的,不通用的部分就是判断这个操作之前有没有操作过,所以我们需要有一个通用的存储来记录所有的操作。

使用简单

提供通用的幂等组件,注入对应的类即可实现幂等,屏蔽加锁,记录判断等逻辑。

支持注解

除了通过代码的方式来进行幂等的控制,同时为了让使用更加简单,还需要提供注解的方式来支持幂等,使用者只需要在对应的业务方法上增加对应的注解,即可实现幂等。

多级存储

需要支持多级存储,比如一级存储可以用 Redis 来实现,优点是性能高,适用于 90%的场景。因为很多场景都是为了防止短时间内请求重复导致的问题,通过设置一定的失效时间,让 Key 自动失效。

二级存储可以支持 Mysql, Mongo 等数据库,适用于时间长或者永久存储的场景。

可以通过配置指定一级存储用什么,二级存储用什么。这个场景非常适合用策略模式来实现。

并发读写

引入多级存储势必会涉及到并发读写的场景,可以支持两种方式,顺序和并发。

顺序就是先写一级存储,再写二级存储,读也是一样。这样的问题在于性能会有点损耗。

并发就是多线程同时写入,同时读取,提高性能。

幂等执行流程

幂等接口

幂等接口定义

public interface DistributedIdempotent {
    /**
     * 幂等执行
     * @param key 幂等Key
     * @param lockExpireTime 锁的过期时间
     * @param firstLevelExpireTime 一级存储过期时间
     * @param secondLevelExpireTime 二级存储过期时间
     * @param timeUnit 存储时间单位
     * @param readWriteType 读写类型
     * @param execute 要执行的逻辑
     * @param fail Key已经存在,幂等拦截后的执行逻辑
     * @return
     */
    <T> T execute(String key, int lockExpireTime, int firstLevelExpireTime, int secondLevelExpireTime, TimeUnit timeUnit, ReadWriteTypeEnum readWriteType, Supplier<T> execute, Supplier<T> fail);
}

使用方式

/**
 * 代码方式幂等-有返回值
 * @param key
 * @return
 */
public String idempotentCode(String key) {
    return distributedIdempotent.execute(key, 10, 10, 50, TimeUnit.SECONDS, ReadWriteTypeEnum.ORDER, () -> {
        System.out.println("进来了。。。。");
        return "success";
    }, () -> {
        System.out.println("重复了。。。。");
        return "fail";
    });
}

幂等注解

使用注解,能够让使用更加简单,比如我们的事务处理,缓存等都使用了注解来简化逻辑。

幂等的场景也可以定义通用的注解来简化使用难度,在需要支持幂等的业务方法上增加注解,配置基本信息。

idempotentHandler 是触发幂等规则后执行的方法,也就是我们用代码实现幂等时候的 Supplier fail 参数。实现是用的阿里 Sentinel 限流,熔断后的处理那套逻辑。

在幂等的场景下,如果是重复执行,通常返回跟正常执行一样的结果即可。

/**
 * 注解方式幂等-指定幂等规则触发后执行的方法
 * @param key
 */
@Idempotent(spelKey = "#key", idempotentHandler = "idempotentHandler", readWriteType = ReadWriteTypeEnum.PARALLEL, secondLevelExpireTime = 60)
public void idempotent(String key) {
    System.out.println("进来了。。。。");
}
public void idempotentHandler(String key, IdempotentException e) {
    System.out.println(key + ":idempotentHandler已经执行过了。。。。");
}

自动区分重复请求

代码方式处理幂等,需要传入幂等的 Key,注解方式处理幂等,支持配置 Key,支持 SPEL 表达式。这两种都是需要在使用的时候就确定好根据什么来作为幂等的唯一性判断。

还有一种幂等的场景是比较常见的,就是防止重复提交或者网络问题超时重试。同样的操作会请求多次,这种场景下可以在操作之前先申请一个唯一的 ID,每次请求的时候带给后端,这样就能标识整个请求的唯一性。

我目前做了一个自动生成唯一标识的功能,简单来说就是根据请求的信息进行 MD5,如果 MD5 值没有变化就认为是同一次请求。

需要进行 MD5 的内容有请求 URL 参数,请求体,请求头信息。请求头的信息在没有指定用户相关 Key 的场景下会进行全部拼接,如果配置了请求头 userId 为用户的标识,那么只会用 userId。

会在请求的入口处进行幂等 Key 的自动生成,如果在使用幂等注解的时候没有指定 spelKey, 就会使用自动生成的 Key。

存储结构

Redis: 使用 String 类型存储,Key 是幂等 Key, Value 默认为 1。

Mysql: 需要创建一张记录表。(过期的数据需要定时清理,也可以永久存储)

CREATE TABLE `idempotent_record` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `key` varchar(50) NULL DEFAULT '',
  `value` varchar(50) NOT NULL DEFAULT '',
  `expireTime` timestamp NOT NULL COMMENT '过期时间',
  `addTime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='幂等记录';

Mongo: 字段跟 Mysql 一样,转换成 Json 格式即可。Mongo 会自动创建集合。

码字不易,可以的话来个三连击,感谢!

关于作者:尹吉欢,简单的技术爱好者,《Spring Cloud 微服务-全栈技术与案例解析》, 《Spring Cloud 微服务 入门 实战与进阶》作者, 公众号猿天地发起人。

微信搜索 猿天地 回复 kitty 获取源码


转载:https://blog.csdn.net/u010889990/article/details/108487008
查看评论
* 以上用户言论只代表其个人观点,不代表本网站的观点或立场