分布式服务下幂等性解决方案

现象:之前的一个项目的技术使用了Spring Cloud微服务的框架,利用Nacos+Fegin的方式进行RPC调用,部署在K8S上,存在多个pod,使用的过程中还是出现了问题:订单中心里的存在重复处理订单表数据。。。。
那简单做一下处理总结

一、幂等性(温习)
什么是幂等性:系统针对同样的请求(包括参数,地址),无论发生多少次请求,得到的结果都是一致的,接口的幂等性保证了分布式系统在复杂的环境中不会因为重复请求而发生重复处理,简化了系统处理故障的步骤
业界高手常用解决方案

MVCC多版本并发控制,使用乐观锁来保证数据不会被重复处理,一个Version只有一次成功机会

去重表,使用数据库对数据行的唯一约束来保证数据不会被重复处理,一个行对于一次成功

TOKEN,长流程业务在处理中常用的机制,根据某个标识在系统间进行传递,每个业务在同一标识上只有一次成功机会

悲观锁,将数据行锁住,防止其他线程的事务进行提交,通过串行化保证只有一次成功

分布式锁,使用Redis或者ZK节点等方式,防止其他线程的事务进行提交,通过串行化保证只有一次成功

异步处理,对Insert操作不保证幂等性,使用定时任务或者其他方式对数据进行筛选处理

状态机幂,在处理长流程的时候,如果状态字段已经变更,不应处理之前所有字段的业务请求
接口的幂等性

GET 不用额外处理,进行数据查询的接口本身就是幂等性的,不管请求多少次,数据都是一样的

POST POST请求并不能保证幂等性,在HTTP的规范中都没有保证POST的幂等,但是实际场景中经常碰到需要处理的例子

PUT 需要保证幂等性,需要保证数据不会重复提交以及重复更新

DELETE 删除一个资源的请求需要保证幂等性,对一个资源多次请求删除,结果都是删除成功

二、寻根问底
进行排查

打印日志排查,针对某一订单号查询Client和Server的日志,发现Client只发生过一次调用,而Server在2个pod(K8S环境)下共发生了3次消费,相互间隔10S
Server端3个线程在接收请求之后全部发生了阻塞,直到某一时刻,3次请求全部成功并打印出成功日志
Client得到一次响应结果并继续处理

问题的原因很容易思考出来,在框架选型的时候,远程RPC架构使用了Nacos+Fegin,在实现方式上Spring的RestTemplate进行的HTTP协议的调用,而一次HTTP调用是不可能有3个服务端链接的,出现这个问题的原因是因为我所看到的1次请求只不过是1次业务端日志打印,而实际上Fegin的默认实现里,超过10秒没有接收到响应的请求就会被抛弃(可以配置),Fegin会根据事先设定好的访问规则选取另一台服务器再次发起请求,我们没有主动设置规则,默认的实现是轮询的方式,多次请求全都因为数据库而堵塞,在轮询的时候其实已经抛弃了上一个Http链接的结果,但是HTTP链接结束并没有把Server端线程给结束,造成了这次的问题出现
分三阶段去解决,不要问我为什么,厂长不是我表哥(订单数据安全考虑):
三、动刀切猪
3.1、幂等性设计1.0,出错版本

根据交易订单号作为幂等性的标识
每次请求第一步操作是根据订单号进行查询,发现有数据则将原结果返回

进行业务处理,获取所有交易方的账户信息,增加流水,进行余额变更
原因分析
数据库堵塞导致了上述3 没有继续进行下去,但是Server端线程实际上都是在运行状态的,本次出现该问题的原因是数据库操作超过10S;而接口重复提交,内存、CPU、磁盘IO、网络等许多情况都会影响到响应时间,这时需要将幂等性重新进行设计
入手方向(解决请求重复提交)
Token机制
针对前端重复连续多次点击的情况,例如用户购物提交订单,提交订单的接口就可以通过 Token 的机制实现防止重复提交。

主要流程就是:

服务端提供了发送token的接口。我们在分析业务的时候,哪些业务是存在幂等问题的,就必须在执行业务前,先去获取token,服务器会把token保存到redis中。(微服务肯定是分布式了,如果单机就适用jvm缓存)。
然后调用业务接口请求时,把token携带过去,一般放在请求头部。
服务器判断token是否存在redis中,存在表示第一次请求,这时把redis中的token删除,继续执行业务。
如果判断token不存在redis中,就表示是重复操作,直接返回重复标记给client,这样就保证了业务代码,不被重复执行。

这个操作是一个INSERT+UPDATE操作,本身属于一个POST请求,但是需要保证幂等性来满足上游系统,原本的幂等性设计是根据数据库中有没有记录来确定是否有过处理记录,而数据库的数据改变是事务提交之后的,这样的设计是完全无法保证高并发下的幂等的,而此次的入手点就是修复重复提交
数据库去重表
往去重表里插入数据的时候,利用数据库的唯一索引特性,保证唯一的逻辑。唯一序列号可以是一个字段,例如订单的订单号,也可以是多字段的唯一性组合。例如设计如下的数据库表。
CREATE TABLE t_idempotent
( id
int(11) NOT NULL COMMENT ‘ID’, serial_no
varchar(255) NOT NULL COMMENT ‘唯一序列号’, source_type
varchar(255) NOT NULL COMMENT ‘资源类型’, status
int(4) DEFAULT NULL COMMENT ‘状态’, remark
varchar(255) NOT NULL COMMENT ‘备注’, create_by
bigint(20) DEFAULT NULL COMMENT ‘创建人’, create_time
datetime DEFAULT NULL COMMENT ‘创建时间’, modify_by
bigint(20) DEFAULT NULL COMMENT ‘修改人’, modify_time
datetime DEFAULT NULL COMMENT ‘修改时间’,
PRIMARY KEY ( id
)
UNIQUE KEY key_s
( serial_no
, source_type
, remark
) COMMENT ‘保证业务唯一性’
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT=’幂等性校验表’;
复制代码
复制代码
我们注意看如下这几个关键性字段,

serial_no:唯一序列号的值,在这里我设置的是通过注解@IdempotentKey来标识请求对象中的字段,通过对他们 MD5 加密获取对应的值。
source_type:业务类型,区分不同的业务,订单,支付等。
remark:是由标识字段的拼接成的字符串,拼接符为 “|”。

由于数据建立了 serial_no,source_type, remark 三个字段组合构成的唯一索引,所以可以通过这个来去重达到接口的幂等性,具体的代码设计如下,
3.2、幂等性设计2.0
在业务处理之前,需要保证Server端只有一个线程进行业务处理和数据提交,我们采用Redis做一个分布式锁,将执行操作和分布式锁结合,修改后的Server端步骤如下

根据订单号作为幂等性的标识

使用Redis的SetNX把订单号放在Redis里,设置一个中等的过期时间1分钟,如果没有拿到分布式锁则进行自旋

每次请求第一步操作是根据订单号进行查询,发现有数据则将原结果返回

进行业务处理,处理此交易的所有流水和余额

提交事务

释放Redis锁

Redis的SetNx方法并不能同时设置超时时间,所以原方案其实是两步操作,没有保证原子性(自己发现)

分布式锁的时间和数据库事务的等待时间不一致,分布式锁的1分钟等待时间远远低于设置的数据库事务等待时间,所以在超过1分钟的时候仍有可能绕过幂等性校验

解决方案

如果想要解决第一个问题,有2种可行方案:

开启Redis事务
在Redis锁中添加过期时间

将事务等待时间和分布式锁生效时间全部设置N,在这个时间内只有一个线程会争夺到锁并处理,如果失败就会发起重试,线程处理+M次重试的总时间和也为N

考虑开发人员水平参差不齐,在解决上述问题1于是我选择使用第2种方式对Redis进行了封装,避免因为环境问题造成的潜在风险。

3.3、幂等性设计3.0
状态机
对于很多业务是有一个业务流转状态的,每个状态都有前置状态和后置状态,以及最后的结束状态。例如流程的待审批,审批中,驳回,重新发起,审批通过,审批拒绝。订单的待提交,待支付,已支付,取消。
以订单为例,已支付的状态的前置状态只能是待支付,而取消状态的前置状态只能是待支付,通过这种状态机的流转我们就可以控制请求的幂等。

根据订单号作为幂等性的标识
使用以下状态

UN_SUBMIT(0, 0, “待提交”),  UN_PADING(0, 1, “待支付”), PAYED(1, 2, “已支付待发货”), DELIVERING(2, 3, “已发货”),  COMPLETE(3, 4, “已完成”), CANCEL(0, 5, “已取消”)

稀土掘金
我还没有学会写个人说明!
上一篇

用docker-compose快速部署ChirpStack

下一篇

[图]重编程细胞新成果:配合支撑结构可用于重建肌肉

你也可能喜欢

评论已经被关闭。

插入图片