分布式服务 API 的幂等设计方案 & Spring Boot + Redis 拦截器实现实例

分布式服务 API 的幂等设计方案 & Spring Boot + Redis 拦截器实现实例

什么是幂等?

简单讲,幂等性是指相同的参数调用同一个 API,执行一次或多次效果一样。

在函数式编程里面,这叫“无副作用”,Pure Function。

用业务的语言将,就是:对于同一笔业务操作,不管调用多少次,得到的结果都是一样的。

问题场景

假如你有个服务提供一个接口,结果这个服务部署在了5台机器上,接着有个接口就是付款接口。

然后用户在前端上操作的时候,不知道为啥,总之就是一个订单不小心发起了两次支付请求,然后这俩请求分散在了这个服务部署的不同的机器上,结果造成一个订单扣款扣两次。

所谓幂等性,就是说一个接口,多次发起同一个请求,你这个接口得保证结果是准确的,比如不能多扣款,不能多插入一条数据,不能将统计值多加了1等等。

例如一个用户在一次购买中不能重复下单;

例如库存剩下了1个商品,现在有10个人抢购,怎么保证不超卖;

例如在MQ的生产者是不需要保证幂等,很可能把同一条消息发送多次,需要保证MQ消费端去重,MQ消费者保证这批消息只会执行一个。

幂等的定义

服务的幂等可能划分为2个层面,一个是从接口的请求层面,一个是从业务层面考虑。

请求层面:

从请求层面考虑,就是一个接口得保证请求一个和请求多次得到的效果是一致的。 如果用数据表达式是这样的

f...f(f(x)) = f(x) 

x是参数
f是执行函数

把相同的参数传给执行函数,不管执行了多少次,结果是一致的。

超时重试机制

场景:微服务A中调用微服务B中的接口,会有三种结果出现,即成功、失败、超时。

成功和失败两种结果非常明确,如果是成功,那么表示此次调用是正常的。如果是失败,那么表示此次调用是失败的,可以由调用的发起方来根据失败的结果决定接下来要做的事情。

但是超时就是一个非常不明确的事情了, 有可能是微服务B中的逻辑已经成功执行完成,但是返回成功的结果的网络传输过程中产生了超时;也有可能是微服务B中的逻辑执行中超时,比如插入数据库数据的过程中超时;也有可能是执行失败了,但是返回失败的结果的网络传输过程中产生了超时。总之,业务执行过程中,产生了超时,如何处理超时是最让开发人员头疼的问题。

如果接口仅仅只是查询数据,那么超时后重试即可。

如果接口是删除数据,哪怕是第一次执行删除成功了但是返回超时,那么第二次重试执行一次删除操作也不会造成什么影响。但是删除要注意ABA的问题,即上一次执行删除成功了但是返回了超市,在第二次重试执行前,又插入了同样的一条数据,那么第二次重试执行就会把本不应该删除的数据给删除了。当然这种场景其实在很多业务流程上不会出现,也可以避免,甚至是就算会出现,也可以针对性的去处理这种情况,消除对业务上的影响。

如果接口是简单的更新操作,哪怕是上一次执行更新成功但是返回超时,那么第二次重试执行一次更新也是没有关系的。当然,也会出现删除的时候ABA的问题。

如果接口是增加数据,哪怕是第一次执行成功了但是返回超时,那么第二次重试执行就可能会出现同一笔数据被插入两次,当然这种情况,也是可以规避的,可以用数据库的UK来保证一条业务数据只会生成一条数据。

所以,超时重试就需要接口幂等来支持。

重复数据或数据不一致

产生重复数据或数据不一致(假定程序业务代码没问题),绝大部分就是发生了重复的请求,重复请求是指同一个请求因为某些原因被多次提交。导致这个情况会有几种场景:

1)微服务场景,在我们传统应用架构中调用接口,要么成功,要么失败。但是在微服务架构下,会有第三个情况【未知】,也就是超时。如果超时了,微服务框架会进行重试。

2)用户交互的时候多次点击。如:快速点击按钮多次。

3)MQ消息中间件,消息重复消费

4)第三方平台的接口(如:支付成功回调接口),因为异常也会导致多次异步回调

5)其他中间件/应用服务根据自身的特性,也有可能进行重试。

我们知道了发生的原因,本质就是多次请求了,那如何解决呢?

导致非幂等原因

先了解下为何会出现不幂等的原因,因为retry重试,如果取消retry机制,是否就能杜绝不幂等呢,答应应该是肯定的,但取消retry是否现实,我们来看看究竟在什么场合会出现retry。

用户进行下订单,调用下单接口超时,调用方又发起一次创建下单接口。

用户下单进行扣减库存,调用扣减库存接口超时了,调用方又发起一次扣减库存接口。

下单完毕后,生产者发送一条MQ,MQ超时没有及时响应ACK,生产者又再发送一条MQ,消费者连续就收到了两条MQ。

从整个系统或业务层面其实很难去做到去retry,所以在一些接口的幂等性还是需要我们自己来做。

幂等作用范围

读/写请求层面范围幂等

读没有造成数据的改变,只有写请求才会造成数据改变。

架构层面范围幂等

在哪些层会造成数据的改变:反向代理?网关?业务逻辑?数据访问?

数据访问层哪些操作需要幂等

从数据层面出发,数据访问层 也就提供了 CRUD 四个请求层面,先站在数据层出发,看看是否可以对数据访问层进行一定改造让数据访问层达到幂等性

业务层面的幂等

上面从数据层面对CRUD做幂等处理,不过幂等性更多是考虑到业务场景。

为什么需要服务的幂等?

在互联网中由于网络的不稳定和一些业务重复确认设计,对一个接口的调用存在重试的机制,为了确保执行同一个请求执行一次和执行多次的效果是一样的,所以就存在了幂等的设计。

举个例子,如果在转账的交易中,A给B进行一笔转账,如果没有幂等性,很可能就因为各种原因导致了A给B进行了多笔转账,在银行系统中,这个就是重大的灾难。服务的幂等可能划分为2个层面,一个是从接口的请求层面,一个是从业务层面考虑。从请求层面考虑,就是一个接口得保证请求一个和请求多次得到的效果是一致的。

怎样保障幂等性?

保证幂等性主要是三点:

1、对于每个请求必须有一个唯一的标识,比如:订单支付请求,肯定得包含订单id,一个订单id最多支付一次。

2、每次处理完请求之后,必须有一个记录标识这个请求处理过了,比如说常见的方案是在mysql中记录个状态啥的,比如支付之前记录一条这个订单的支付流水,而且支付流水采

3、每次接收请求需要进行判断之前是否处理过的逻辑处理,比如说,如果有一个订单已经支付了,就已经有了一条支付流水,那么如果重复发送这个请求,则此时先插入支付流水,orderId已经存在了,唯一键约束生效,报错插入不进去的。然后你就不用再扣款了。

还有一种方法,比如说使用 redis ,用 orderId 作为唯一键。只有成功插入这个支付流水,才可以执行实际的支付扣款。

实现幂等的方案

「如何设计」具备幂等性的服务? 从架构层面出发,哪些层会对数据造成改变,只有造成数据改变的层才需要做出幂等,很显然,数据访问层直接操作DB和Cache (业务逻辑层也可能访问操作cache),从请求层面来看,我们需要对数据访问层进行幂等操作。

关键点

根据定义中幂等的概念,关键点之一在于如何识别是同一个业务请求,所以幂等是脱离不开业务来单独讲的,并且幂等也是为了我们业务服务的。

举个具体的例子,一个用户可以发起多笔的售后退款申请,那么这笔退款申请的单号可以作为业务请求是不是同一个的区分凭证,也就是说为这个幂等增加这样的一个幂等号,如果两次请求都是这样同一个售后单号,那么就说明这两次是同一个业务请求,只需要执行一次即可。

但是这里会有一个问题,如果我们的幂等是设计给很多业务使用的,那么幂等号最好是脱离具体业务单号的生成规则,由自己来生成和分配幂等号。

基本结论:

1.实现幂等性常见的方式有:悲观锁(for update)、乐观锁(version)、唯一约束(uk)

2.几种方式,按照最优排序:乐观锁 > 唯一约束 > 悲观锁

番外篇:消息中间件中的幂等设计

其实,在消息中间件中,消息的幂等性设计也是很重要的一部分。对每条消息,MQ系统内部必须生成一个inner-msg-id,作为去重和幂等的依据,这个内部消息ID的特性是:

(1)全局唯一

(2)MQ生成,具备业务无关性,对消息发送方和消息接收方屏蔽

有了这个inner-msg-id,就能保证,消息重复发送,也只有1条消息落到 MQ-server 的DB中,实现幂等。

为了保证业务幂等性,业务消息体中,必须有一个biz-id,作为去重和幂等的依据,这个业务ID的特性是:

(1)对于同一个业务场景,全局唯一

(2)由业务消息发送方生成,业务相关,对MQ透明

(3)由业务消息消费方负责判重,以保证幂等

最常见的业务ID有:支付ID,订单ID,帖子ID等。

方案详细设计

我们以对接支付宝充值为例,来分析支付回调接口如何设计?

如果我们系统中对接过支付宝充值功能的,我们需要给支付宝提供一个回调接口:支付宝回调信息中会携带

  • out_trade_no【商户订单号】
  • trade_no【支付宝交易号】)

其中,trade_no在支付宝中是唯一的,out_trade_no 在商户系统中是唯一的。

回调接口实现有以下实现方式。

方式1(普通方式)

过程如下:

1.接收到支付宝支付成功请求
2.根据trade_no查询当前订单是否处理过
3.如果订单已处理直接返回,若未处理,继续向下执行
4.开启本地事务
5.本地系统给用户加钱
6.将订单状态置为成功
7.提交本地事务

上面的过程,对于同一笔订单,如果支付宝同时通知多次,会出现什么问题?当多次通知同时到达第2步时候,查询订单都是未处理的,会继续向下执行,最终本地会给用户加两次钱。

此方式适用于单机其,通知按顺序执行的情况,只能用于自己写着玩玩。

方式2(jvm加锁方式)

方式1中由于并发出现了问题,此时我们使用java中的Lock加锁,来防止并发操作,过程如下:

1.接收到支付宝支付成功请求
2.调用java中的Lock加锁
3.根据trade_no查询当前订单是否处理过
4.如果订单已处理直接返回,若未处理,继续向下执行
5.开启本地事务
6.本地系统给用户加钱
7.将订单状态置为成功
8.提交本地事务
9.释放Lock锁

分析问题:
Lock只能在一个jvm中起效,如果多个请求都被同一套系统处理,上面这种使用Lock的方式是没有问题的,不过互联网系统中,多数是采用集群方式部署系统,同一套代码后面会部署多套,如果支付宝同时发来多个通知经过负载均衡转发到不同的机器,上面的锁就不起效了。此时对于多个请求相当于无锁处理了,又会出现方式1中的结果。此时我们需要分布式锁来做处理。

方式3(悲观锁方式)

使用数据库中悲观锁实现。悲观锁类似于方式二中的Lock,只不过是依靠数据库来实现的。数据中悲观锁使用for update来实现,过程如下:

1.接收到支付宝支付成功请求
2.打开本地事物
3.查询订单信息并加悲观锁

select * from t_order where order_id = trade_no for update;

4.判断订单是已处理
5.如果订单已处理直接返回,若未处理,继续向下执行
6.给本地系统给用户加钱
7.将订单状态置为成功
8.提交本地事物

重点在于for update,对for update,做一下说明:

1.当线程A执行for update,数据会对当前记录加锁,其他线程执行到此行代码的时候,会等待线程A释放锁之后,才可以获取锁,继续后续操作。
2.事物提交时,for update获取的锁会自动释放。

方式3可以正常实现我们需要的效果,能保证接口的幂等性,不过存在一些缺点:
1.如果业务处理比较耗时,并发情况下,后面线程会长期处于等待状态,占用了很多线程,让这些线程处于无效等待状态,我们的web服务中的线程数量一般都是有限的,如果大量线程由于获取for update锁处于等待状态,不利于系统并发操作。

方式4(乐观锁方式)

依靠数据库中的乐观锁来实现。

通常可以用一个 version 字段,每次更新加1,更新之前先查出来这个版本号。

update t_order set pay_status = 100,version=version+1 where order_id = trade_no where version = #{version};

也可以用一个状态字段来 status 标识有没有更新完成。

1.接收到支付宝支付成功请求
2.查询订单信息

select * from t_order where order_id = trade_no;

3.判断订单是已处理
4.如果订单已处理直接返回,若未处理,继续向下执行
5.打开本地事物
6.给本地系统给用户加钱
7.将订单状态置为成功,注意这块是重点,伪代码:

update t_order set status = 1 where order_id = trade_no where status = 0;

注意:

update t_order set status = 1 where order_id = trade_no where status = 0; 

是依靠乐观锁来实现的,status=0作为条件去更新,类似于java中的cas操作;关于什么是cas操作,可以移步:什么是 CAS 机制?

执行这条sql的时候,如果有多个线程同时到达这条代码,数据内部会保证update同一条记录会排队执行,最终最有一条update会执行成功,其他未成功的,他们的num为0,然后根据num来进行提交或者回滚操作。

方式4(唯一约束方式)

依赖数据库中唯一约束来实现。

我们可以创建一个表:

CREATE TABLE `t_uq_dipose` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `ref_type` varchar(32) NOT NULL DEFAULT '' COMMENT '关联对象类型',
  `ref_id` varchar(64) NOT NULL DEFAULT '' COMMENT '关联对象id',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uq_1` (`ref_type`,`ref_id`) COMMENT '保证业务唯一性'
) ENGINE=InnoDB;

对于任何一个业务,有一个业务类型(ref_type),业务有一个全局唯一的订单号,业务来的时候,先查询t_uq_dipose表中是否存在相关记录,若不存在,继续放行。

过程如下:

1.接收到支付宝支付成功请求
2.查询t_uq_dipose(条件ref_id,ref_type),可以判断订单是否已处理

select * from t_uq_dipose where ref_type = '充值订单' and ref_id = trade_no;

3.判断订单是已处理
4.如果订单已处理直接返回,若未处理,继续向下执行
5.打开本地事物
6.给本地系统给用户加钱
7.将订单状态置为成功
8.向t_uq_dipose插入数据,插入成功,提交本地事务,插入失败,回滚本地事务,伪代码:

try{
    insert into t_uq_dipose (ref_type,ref_id) values ('充值订单',trade_no);
    提交本地事务:
}catch(Exception e){
    回滚本地事务;
}

说明:
对于同一个业务,ref_type是一样的,当并发时,插入数据只会有一条成功,其他的会违法唯一约束,进入catch逻辑,当前事务会被回滚,最终最有一个操作会成功,从而保证了幂等性操作。
关于这种方式可以写成通用的方式,不过业务量大的情况下,t_uq_dipose插入数据会成为系统的瓶颈,需要考虑分表操作,解决性能问题。
上面的过程中向t_uq_dipose插入记录,最好放在最后执行,原因:插入操作会锁表,放在最后能让锁表的时间降到最低,提升系统的并发性。

关于消息服务中,消费者如何保证消息处理的幂等性?
每条消息都有一个唯一的消息id,类似于上面业务中的trade_no,使用上面的方式即可实现消息消费的幂等性。

方式5 防重 Token 令牌

方案描述:

针对客户端连续点击或者调用方的超时重试等情况,例如提交订单,此种操作就可以用 Token 的机制实现防止重复提交。

简单的说就是:

1、调用方在调用接口的时候先向后端请求一个全局 ID(Token),
2、请求的时候携带这个全局 ID 一起请求(Token 最好将其放到 Headers 中),
3、后端需要对这个 Token 作为 Key,用户信息作为 Value 到 Redis 中进行键值内容校验:
如果 Key 存在且 Value 匹配就执行删除命令,然后正常执行后面的业务逻辑;
如果不存在对应的 Key 或 Value 不匹配就返回重复执行的错误信息,这样来保证幂等操作。

适用操作:

插入操作
更新操作
删除操作

使用限制:

需要生成全局唯一 Token 串;
需要使用第三方组件 Redis 进行数据效验;

主要流程:

① 服务端提供获取 Token 的接口,该 Token 可以是一个序列号,也可以是一个分布式 ID 或者 UUID 串。

② 客户端调用接口获取 Token,这时候服务端会生成一个 Token 串。

③ 然后将该串存入 Redis 数据库中,以该 Token 作为 Redis 的键(注意设置过期时间)。

④ 将 Token 返回到客户端,客户端拿到后应存到表单隐藏域中。

⑤ 客户端在执行提交表单时,把 Token 存入到 Headers 中,执行业务请求带上该 Headers。

⑥ 服务端接收到请求后从 Headers 中拿到 Token,然后根据 Token 到 Redis 中查找该 key 是否存在。

⑦ 服务端根据 Redis 中是否存该 key 进行判断,如果存在就将该 key 删除,然后正常执行业务逻辑。如果不存在就抛异常,返回重复提交的错误信息。
注意,在并发情况下,执行 Redis 查找数据与删除需要保证原子性,否则很可能在并发下无法保证幂等性。其实现方法可以使用分布式锁或者使用 Lua 表达式来注销查询与删除操作。

项目实战案例: 用token机制实现接口的幂等性

1、pom.xml:主要是引入了redis相关依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- spring-boot-starter-data-redis -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>
<!-- jedis -->
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
</dependency>
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
<!-- commons-lang3 -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
</dependency>
<!-- org.json/json -->
<dependency>
    <groupId>org.json</groupId>
    <artifactId>json</artifactId>
    <version>20190722</version>
</dependency>

2、application.yml:主要是配置redis

server:
  port: 6666
spring:
  application:
    name: idempotent-api
  redis:
    host: 192.168.2.43
    port: 6379

3、业务代码:

新建一个枚举,列出常用返回信息,如下:

@Getter
@AllArgsConstructor
public enum ResultEnum {
    REPEATREQUEST(405, "重复请求"),
    OPERATEEXCEPTION(406, "操作异常"),
    HEADERNOTOKEN(407, "请求头未携带token"),
    ERRORTOKEN(408, "token正确")
    ;
    private Integer code;
    private String msg;
}

新建一个JsonUtil,当请求异常时往页面中输出json:

public class JsonUtil {
    private JsonUtil() {}
    public static void writeJsonToPage(HttpServletResponse response, String msg) {
        PrintWriter writer = null;
        response.setCharacterEncoding("UTF-8");
        response.setContentType("text/html; charset=utf-8");
        try {
            writer = response.getWriter();
            writer.print(msg);
        } catch (IOException e) {
        } finally {
            if (writer != null)
                writer.close();
        }
    }
}

新建一个RedisUtil,用来操作redis:

@Component
public class RedisUtil {
    
    private RedisUtil() {}

    private static RedisTemplate redisTemplate;

    @Autowired
    public  void setRedisTemplate(@SuppressWarnings("rawtypes") RedisTemplate redisTemplate) {
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        //设置序列化Value的实例化对象
        redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        RedisUtil.redisTemplate = redisTemplate;
    }
    
    /**
     * 设置key-value,过期时间为timeout秒
     * @param key
     * @param value
     * @param timeout
     */
    public static void setString(String key, String value, Long timeout) {
        redisTemplate.opsForValue().set(key, value, timeout, TimeUnit.SECONDS);
    }
    
    /**
     * 设置key-value
     * @param key
     * @param value
     */
    public static void setString(String key, String value) {
        redisTemplate.opsForValue().set(key, value);
    }
    
    /**
     * 获取key-value
     * @param key
     * @return
     */
    public static String getString(String key) {
        return (String) redisTemplate.opsForValue().get(key);
    }
    
    /**
     * 判断key是否存在
     * @param key
     * @return
     */
    public static boolean isExist(String key) {
        return redisTemplate.hasKey(key);
    }
    
    /**
     * 删除key
     * @param key
     * @return
     */
    public static boolean delKey(String key) {
        return redisTemplate.delete(key);
    }
}

新建一个TokenUtil,用来生成和校验token:生成token没什么好说的,这里为了简单直接用uuid生成,然后放入redis中。校验token,如果用户没有携带token,直接返回false;如果携带了token,但是redis中没有这个token,说明已经被删除了,即已经访问了,返回false;如果redis中有,但是redis中的token和用户携带的token不一致,也返回false;有且一致,说明是第一次访问,就将redis中的token删除,然后返回true。

public class TokenUtil {

    private TokenUtil() {}
    
    private static final String KEY = "token";
    private static final String CODE = "code";
    private static final String MSG = "msg";
    private static final String JSON = "json";
    private static final String RESULT = "result";
    
    /**
     * 生成token并放入redis中
     * @return
     */
    public static String createToken() {
        String token = UUID.randomUUID().toString();
        RedisUtil.setString(KEY, token, 60L);
        return RedisUtil.getString(KEY);
    }
    
    /**
     * 校验token
     * @param request
     * @return
     * @throws JSONException 
     */
    public static Map<String, Object> checkToken(HttpServletRequest request) throws JSONException {
        String headerToken = request.getHeader(KEY);
        JSONObject json = new JSONObject();
        Map<String, Object> resultMap = new HashMap<>();
        // 请求头中没有携带token,直接返回false
        if (StringUtils.isEmpty(headerToken)) {
            json.put(CODE, ResultEnum.HEADERNOTOKEN.getCode());
            json.put(MSG, ResultEnum.HEADERNOTOKEN.getMsg());
            resultMap.put(RESULT, false);
            resultMap.put(JSON, json.toString());
            return resultMap;
        }
        
        if (StringUtils.isEmpty(RedisUtil.getString(KEY))) {
            // 如果redis中没有token,说明已经访问成功过了,直接返回false
            json.put(CODE, ResultEnum.REPEATREQUEST.getCode());
            json.put(MSG, ResultEnum.REPEATREQUEST.getMsg());
            resultMap.put(RESULT, false);
            resultMap.put(JSON, json.toString());
            return resultMap;
        } else {
            // 如果redis中有token,就删除掉,删除成功返回true,删除失败返回false
            String redisToken = RedisUtil.getString(KEY);
            boolean result = false;
            if (!redisToken.equals(headerToken)) {
                json.put(CODE, ResultEnum.ERRORTOKEN.getCode());
                json.put(MSG, ResultEnum.ERRORTOKEN.getMsg());
            } else {
                result = RedisUtil.delKey(KEY);
                String msg = result ? null : ResultEnum.OPERATEEXCEPTION.getMsg();
                json.put(CODE, 400);
                json.put(MSG, msg);
            }
            resultMap.put(RESULT, result);
            resultMap.put(JSON, json.toString());
            return resultMap;
        }
    }
}

新建一个注解,用来标注需要进行幂等的接口:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NeedIdempotent {
}

接着要新建一个拦截器,对有@NeedIdempotent注解的方法进行拦截,进行自动幂等。

public class IdempotentInterceptor implements HandlerInterceptor{

    @Override
    public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,Object object) throws JSONException {
        // 拦截的不是方法,直接放行
        if (!(object instanceof HandlerMethod)) {
            return true;
        }
        HandlerMethod handlerMethod = (HandlerMethod) object;
        Method method = handlerMethod.getMethod();
        // 如果是方法,并且有@NeedIdempotent注解,就自动幂等
        if (method.isAnnotationPresent(NeedIdempotent.class)) {
            Map<String, Object> resultMap = TokenUtil.checkToken(httpServletRequest);
            boolean result = (boolean) resultMap.get("result");
            String json = (String) resultMap.get("json");
            if (!result) {
                JsonUtil.writeJsonToPage(httpServletResponse, json);
            }
            return result;
        } else {
            return true;
        }
    }
    
    @Override
    public void postHandle(HttpServletRequest httpServletRequest,HttpServletResponse httpServletResponse, Object o,ModelAndView modelAndView) {
    }

    @Override
    public void afterCompletion(HttpServletRequest httpServletRequest,HttpServletResponse httpServletResponse,Object o, Exception e) {
    }
}

然后将这个拦截器配置到spring中去:

@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
    
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(idempotentInterceptor())
                .addPathPatterns("/**");   
    }
    @Bean
    public IdempotentInterceptor idempotentInterceptor() {
        return new IdempotentInterceptor();
    }

}

最后新建一个controller,就可以愉快地进行测试了。

@RestController
@RequestMapping("/idempotent")
public class IdempotentApiController {

    @NeedIdempotent
    @GetMapping("/hello")
    public String hello() {
        return "are you ok?";
    }
    
    @GetMapping("/token")
    public String token() {
        return TokenUtil.createToken();
    }
}

访问/token,不需要什么校验,访问/hello,就会自动幂等,每一次访问都要先获取token,一个token不能用两次。

本文小结

幂等性是开发当中很常见也很重要的一个需求,尤其是支付、订单等与金钱挂钩的服务,保证接口幂等性尤其重要。在实际开发中,我们需要针对不同的业务场景我们需要灵活的选择幂等性的实现方式:

对于下单等存在唯一主键的,可以使用 “唯一主键方案” 的方式实现。
对于更新订单状态等相关的更新场景操作,使用 “乐观锁方案” 实现更为简单。
对于上下游这种,下游请求上游,上游服务可以使用 “下游传递唯一序列号方案” 更为合理。
类似于前端重复提交、重复下单、没有唯一 ID 号的场景,可以通过 Token 与 Redis 配合的 “防重 Token 方案” 实现更为快捷。

上面只是给与一些建议,再次强调一下,实现幂等性需要先理解自身业务需求,根据业务逻辑来实现这样才合理,处理好其中的每一个结点细节,完善整体的业务流程设计,才能更好的保证系统的正常运行。

参考资料

https://www.pianshen.com/article/98952043194/
https://www.jianshu.com/p/8b77d4583bab
https://www.codercto.com/a/84322.html
http://www.itsoku.com/article/77
https://blog.csdn.net/u010372981/article/details/107599657
https://blog.csdn.net/u014756827/article/details/95195648
https://www.jianshu.com/p/d8b30b85d8a9

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350

推荐阅读更多精彩内容