幂等设计的应用
1、特点
幂等特点:执行一次与执行多次产生的结果影响相同
2、幂等与并发安全的区别
3、为什么需要幂等?(幂等的使用场景)
接上,例如,mq不保证消息唯一,可能重复推送;消息处理ack超时重复推送等
--上层业务重复调用。例如,由于上层业务逻辑的不合理,重复调用底层服务处理
--任务调度中心重复调度。例如,定时任务被多次重复调用
--单个请求业务处理时,部分流程异常。例如,处理单个请求时,步骤1成功,步骤2异常,步骤1未回滚,第二次请求时,步骤1仍会重复处理
--网络报文重发。server端正常收到报文,但client端未收到ack,于是client端进行重发
4、引入幂等后的影响
在系统中,不是所有的接口都需要做幂等设计,其实大部分情况下是不需要做幂等设计的,或者通过前端的某些操作来避免增加幂等设计。
5、增删改查及接口幂等性分析
Restful API最终作用就是数据库,所以我们做幂等最主要的就是保证sql的幂等操作
6、幂等的实现方案
幂等处理的过程,其实就是过滤一下已经收到的请求。如何判断请求是否已收到过,把请求存储起来,收到请求时,先查下存储记录,记录存在就返回上次的结果,不存在就处理请求。
1、初级方法:
实现方案一(select+insert+主键/唯一索引冲突 == 》插入前先判断数据是否存在)
--实现:
当请求过来时,先根据请求的唯一字段id,select数据库
如果数据已经存在,说明是重复请求,直接返回成功
如果数据不存在,就执行insert,如果insert成功,直接返回成功;如果insert产生主键冲突异常,则捕获异常,直接返回成功
--示例:
Rsp idempotent(Request req){
Object requestRecord =selectById(id);
if(requestRecord !=null){
//拦截是重复请求
log.info("重复请求,直接返回成功,流水号:{}",id);
return rsp;
}
try{
//使用try的原因:
//高并发场景下,两个请求去select的时候,可能都没查到,然后都走到insert的地方
insert(req);
}catch(DuplicateKeyException e){
//拦截是重复请求,直接返回成功
log.info("主键冲突,是重复请求,直接返回成功,流水号:{}",id);
return rsp;
}
//正常处理请求
dealRequest(req);
return rsp;
}
实现方案二(前端按钮控制)
前端页面中的,需要控制幂等的地方,当用户点击该按钮后,立马置灰,防止重复提交
2、高并发下实现方法:
实现方案一(数据库唯一主键/全局的唯一性ID)
如何生成全局的唯一性ID?如果是我首先想到的是UUID,但是UUID的缺点比较明显,它字符串占用的空间比较大,生成的ID过于随便,可读性差,而且没有递增。
--实现:
数据库唯一主键的实现是利用数据库中主键唯一约束的特性,一般来说唯一主键比较适用于“插入”时的幂等性,其能保证一张表中只能存在一条带该唯一主键的记录。
使用数据库唯一主键完成幂等性时需要注意的是,该主键一般来说并不是使用数据库中自增主键,而是使用分布式 ID 充当主键,这样才能能保证在分布式环境下 ID 的全局唯一性。
--适用操作:插入操作、删除操作
--使用限制:需要生成全局唯一主键ID
--生成全局唯一主键ID方法
1)雪花算法
2)令牌桶算法
3)漏桶算法
4)自定义唯一索引生成器
--主要流程:
1)客户端执行创建请求,调用服务端接口。
2)服务端执行业务逻辑,生成一个分布式 ID,将该 ID 充当待插入数据的主键,然后执数据插入操作,运行对应的 SQL 语句。
3)服务端将该条数据插入数据库中,如果插入成功则表示没有重复调用接口。如果抛出主键重复异常,则表示数据库中已经存在该条记录,返回错误信息到客户端。
实现方案二(数据库乐观锁)
--实现:
数据库乐观锁方案一般只能适用于执行“更新操作”的过程,我们可以提前在对应的数据表中多添加一个字段,充当当前数据的版本标识。这样每次对该数据库该表的这条数据执行更新时,都会将该版本标识作为一个条件,值为上次待更新数据中的版本标识的值
--适用操作:更新操作
--使用限制:需要数据库对应业务表中添加额外字段
--实现流程如下
实现方案三(悲观锁-select for update)
悲观锁就是,每次去操作数据时,都觉得别人中途会修改,所以每次在拿数据的时候都会上锁,执行完逻辑后再把资源让给其他线程
begin; # 1.开始事务
select * from order where order_id='666' # 查询订单,判断状态
if(status !=处理中){
//非处理中状态,直接返回;
return ;
}
## 处理业务逻辑
update order set status='完成' where order_id='666' # 更新完成
commit; # 5.提交事务
--存在的问题:
这种场景是非原子操作的,在高并发环境下,可能会造成一个业务被执行两次的问题,
当一个请求A在执行中时,而另一个请求B也开始状态判断的操作。因为请求A还未来得及更改状态,所以请求B也能执行成功,这就导致一个业务被执行了两次。
--解决方法:
使用数据库悲观锁(select ...for update)解决这个问题
begin; # 1.开始事务
select * from order where order_id='666' for update # 查询订单,判断状态,锁住这条记录
if(status !=处理中){
//非处理中状态,直接返回;
return ;
}
## 处理业务逻辑
update order set status='完成' where order_id='666' # 更新完成
commit; # 5.提交事务
注意:order_id需要是索引或主键哈,要锁住这条记录就好,如果不是索引或者主键,会锁表的
悲观锁性能不佳所以一般不建议用悲观锁做这个事情
for update 仅适用于InnoDB(默认是行级别的锁),并且必须开启事务,在begin与commit之间才生效
实现方案四(防重Token令牌)
--实现:
针对客户端连续点击或者调用方的超时重试等情况,例如提交订单,此种操作就可以用 Token 的机制实现防止重复提交。简单的说就是调用方在调用接口的时候先向后端请求一个全局 ID(Token),请求的时候携带这个全局 ID 一起请求(Token 最好将其放到 Headers 中),后端需要对这个 Token 作为 Key,用户信息作为 Value 到 Redis 中进行键值内容校验,如果 Key 存在且 Value 匹配就执行删除命令,然后正常执行后面的业务逻辑。如果不存在对应的 Key 或 Value 不匹配就返回重复执行的错误信息,这样来保证幂等操作
--适用操作:
插入操作、更新操作、删除操作
--使用限制:
1)需要生成全局唯一Token串
2)需要使用第三方组件Redis进行数据校验
--主要流程:
1)服务端提供获取 Token 的接口,该 Token 可以是一个序列号,也可以是一个分布式 ID 或者 UUID 串。
2)客户端调用接口获取 Token,这时候服务端会生成一个 Token 串。
3)然后将该串存入 Redis 数据库中,以该 Token 作为 Redis 的键(注意设置过期时间)。
4)将 Token 返回到客户端,客户端拿到后应存到表单隐藏域中。
5)客户端在执行提交表单时,把 Token 存入到 Headers 中,执行业务请求带上该 Headers。
6)服务端接收到请求后从 Headers 中拿到 Token,然后根据 Token 到 Redis 中查找该 key 是否存在。
7)服务端根据 Redis 中是否存该 key 进行判断,如果存在就将该 key 删除,然后正常执行业务逻辑。如果不存在就抛异常,返回重复提交的错误信息
--注意:
1)在并发情况下,执行 Redis 查找数据与删除需要保证原子性(参考之前的文章:Redis相关笔记中的分布式锁部分),否则很可能在并发下无法保证幂等性。其实现方法可以使用分布式锁
2)token 在 redis 上设置的超时时间控制合理,防止恶意请求
3)服务器校验 token 时,采用 redis.delete 操作,直接使用 get+delete 规避并发带来的问题
4)token 一定程度上可以限流
实现方案五(下游传递唯一序列号)
--实现:
所谓请求序列号,其实就是每次向服务端请求时候附带一个短时间内唯一不重复的序列号,该序列号可以是一个有序 ID,也可以是一个订单号,一般由下游生成,在调用上游服务端接口时附加该序列号和用于认证的 ID。
当上游服务器收到请求信息后取该序列号 和下游 认证ID 进行组合,形成用于操作 Redis 的 Key,然后到 Redis 中查询是否存在对应的 Key 的键值对,根据其结果:
如果存在,就说明已经对该下游的该序列号的请求进行了业务处理,这时可以直接响应重复请求的错误信息。
如果不存在,就以该 Key 作为 Redis 的键,以下游关键信息作为存储的值(例如下游商传递的一些业务逻辑信息),将该键值对存储到 Redis 中 ,然后再正常执行对应的业务逻辑即可
--适用操作:
插入操作、更新操作、删除操作
--使用限制:
需要第三方传递唯一序列号
需要使用第三方Redis进行数据校验
--主要流程:
1)下游服务生成分布式 ID 作为序列号,然后执行请求调用上游接口,并附带“唯一序列号”与请求的“认证凭据ID”。
2)上游服务进行安全效验,检测下游传递的参数中是否存在“序列号”和“凭据ID”。
3)上游服务到 Redis 中检测是否存在对应的“序列号”与“认证ID”组成的 Key,如果存在就抛出重复执行的异常信息,然后响应下游对应的错误信息。如果不存在就以该“序列号”和“认证ID”组合作为 Key,以下游关键信息作为 Value,进而存储到 Redis 中,然后正常执行接下来的业务逻辑。
上面步骤中插入数据到 Redis 一定要设置过期时间。这样能保证在这个时间范围内,如果重复调用接口,则能够进行判断识别。如果不设置过期时间,很可能导致数据无限量的存入 Redis,致使 Redis 不能正常工作
实现方案六(状态机幂等)
很多业务表,都是有状态的,比如转账表,就会有0-待处理,1-处理中、2-成功、3-失败状态。转账更新的时候,都会涉及状态更新,即涉及状态机。状态机幂等就是利用数据中状态的改变来控制幂等。
--示例
Rsp idempotentTransfer(Request req){
String bizSeq = req.getBizSeq();
int rows= "update transfr_flow set status=2 where biz_seq=#{bizSeq} and status=1;"
if(rows==1){
log.info(“更新成功,可以处理该请求”);
//其他业务逻辑处理
return rsp;
}else if(rows==0){
log.info(“更新不成功,不处理该请求”);
//不处理,直接返回
return rsp;
}
log.warn("数据异常")
return rsp:
}
--实现原理:
当请求第一次进来时,能根据条件找到对应数据并更新,然后状态改变
当同样的请求第二次进来时,根据条件找不到对应数据,所以更新失败