分布式服务 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中的逻辑执行中超时,比如插入数据库数据的过程中超时;也有可能是执行失败了,但是返回失败的结果的网络传输过程中产生了超时。总之,业务执行过程中,产生了超时,如何处理超时是最让开发人员头疼的问题。
【分布式服务 API 的幂等设计方案 & Spring Boot + Redis 拦截器实现实例】如果接口仅仅只是查询数据,那么超时后重试即可。
如果接口是删除数据,哪怕是第一次执行删除成功了但是返回超时,那么第二次重试执行一次删除操作也不会造成什么影响。但是删除要注意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 DEFAULTCOMMENT 关联对象类型,
`ref_id` varchar(64) NOT NULL DEFAULTCOMMENT 关联对象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< /

    推荐阅读