什么是幂等
"幂等"是一个数学和计算机科学术语,描述的是一种性质:一个操作多次应用后,结果和只应用一次相同。在编程中,如果一个函数或操作无论执行多少次,产生的效果都是一样的,则称该函数或操作为幂等的。
例如,在数学中,乘法的单位元1就具有幂等性(任何数乘以1仍然等于它自身),在网络编程中,HTTP的GET方法就是幂等的(多次GET同一个URL获取到的结果应该是一样的)。
为什么要考虑幂等
在计算机软件编程中,幂等性的保证尤其重要,主要有以下几个原因:
- 接口超时:在网络环境中,由于各种原因,响应可能会失败或丢失。如果一个操作是幂等的,客户端可以简单地重新发出同一请求,而不必担心这可能导致问题。
- 并发控制:在多用户、多进程或多线程环境中,同一操作可能被同时触发多次(比如提交form表单快速点击)。如果操作是幂等的,那么无论执行多少次,结果都是一样的,这大大简化了并发控制。
- 系统性能优化:幂等性可以用来做缓存优化。例如,在Web应用中,如果知道某个HTTP GET请求是幂等的,那么就可以安全地缓存该请求的结果,从而提高系统效率。
- 消息中间件:MQ(消息中间件)消费者读取消息时,有可能会读取到重复消息,此时需要下游设计幂等
这里提到了接口超时,那么就再多说一点,如果我们调用下游接口超时了,我们应该怎么办?大致有两种方案:
- 下游系统提供一个对应的查询接口。如果接口超时了,先查下对应的记录,如果查到是成功,就走成功流程,如果是失败,就按失败处理
- 下游接口支持幂等,上游系统如果调用超时,发起重试即可
其中方案一存在一定的局限性,比如我们是从MQ重复消费,此时我们接口并没有超时,我们也就不会去调用对应的查询接口,这时候只能寄托于下游支持幂等了。
设计幂等的核心是什么
幂等要求标记一次调用请求是唯一的,那么不管采用什么具体方案,都需要有一个标记来标识这个请求的唯一性,比如你是利用唯一索引控制幂等,那这里的唯一索引是唯一的。
幂等有哪些落地方案
直接insert + 主键/唯一索引冲突
如果重复请求的概率比较低的话,我们可以直接插入请求,利用主键/唯一索引冲突,去判断是重复请求。当插入抛出DuplicateKeyException则意味着是重复请求,直接返回成功,否则正常处理请求。
状态机幂等
很多业务表,都是有状态相关的字段的,比如订单表它有三个状态:0-“未付款”、1-“已付款”和2-“已发货”。当订单表更新的时候,就会涉及状态的更新,比如把未付款的订单改成已付款:
update order set status=1 where biz_seq=‘666’ and status=0;
- 第1次请求来时,bizSeq流水号是
666,该流水的状态是处理中,值是0,要更新为1,所以该update语句可以正常更新数据,sql执行结果的影响行数是1,流水状态最后变成了1。 - 第2请求也过来了,如果它的流水号还是
666,因为该流水状态已经1了,所以更新结果是0,不会再处理业务逻辑,接口直接返回。
抽取防重表
前面的两种方案都和业务强耦合,需要业务表有唯一标识,但是有时候我们希望能设计一个通用的幂等框架,将防重功能与业务表分隔开来,这时候我们可以单独搞个防重表。当然防重表也是利用主键/索引的唯一性,如果插入防重表冲突即直接返回成功,如果插入成功,即去处理请求。
token
token去重有两种方案:
- 客户端在发起请求的时候自己生成一个唯一的token,服务端收到请求时候会把这个token存储在redis并设置过期时间,当下次同样的token过来之后服务端发现redis中已经有token了,则认定这是重复请求。
- 客户端发起申请token的请求,此时服务端生成唯一token并存储在redis中设置过期时间,客户端拿着token发起请求,客户端使用redis.del(token),删除成功则说明是第一次请求,删除失败认为是重复请求
相比来说我个人更倾向于第一种方案,第一种方案不需要额外发起一次请求,第二种方案需要额外发一次申请token的请求,效率低,并且如果客户端不申请token,自己生成一个token过来就会被判定为重复请求而不在进行处理。但是第一种方案也有劣势,如果token过期了,客户端还是拿着之前生成好的token来请求这时候就会误判,只是一般这种情况出现的比较少。
悲观锁(如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需要是索引或主键,只要锁住这条记录,如果不是索引或者主键,会锁表的!
- 悲观锁在同一事务操作过程中,锁住了一行数据。别的请求过来只能等待,如果当前事务耗时比较长,就很影响接口性能。所以一般不建议用悲观锁做这个事情
乐观锁
悲观锁有性能问题,可以试下乐观锁。乐观锁其实不会加锁,只有在最后更新数据的时候判断是否有别人修改了数据。乐观锁的实现一般是给表多加一列version字段,每次更新记录都升级一下version(version+1)。具体流程就是先查出当前的版本号version,然后去更新修改数据时,确认下是不是刚刚查出的版本号,如果是才执行更新,如果不是则执行循环。
比如,我们更新前,先查下数据,查出的版本号是version =1
select order_id,version from order where order_id='666';
然后使用version =1和订单Id一起作为条件,再去更新
update order set version = version +1,status='P' where order_id='666' and version =1
最后更新成功,才可以处理业务逻辑,如果更新失败,默认为重复请求,直接返回。这里的update会生成一个隐式的事务,在这是事务中会多涉及到的数据加锁。
为什么版本号建议自增的呢?
因为乐观锁存在ABA的问题,如果version版本一直是自增的就不会出现ABA的情况。举个例子:
假设我们有一个线程或者进程 A,它读取了某个数据项的值,这个值是原始值,我们称之为 "A"。在这个过程中,另一个线程或进程 B 进来修改了这个数据项的值,把它从 "A" 改成了 "B",然后再改回 "A"。
现在如果进程 A 想要更新这个数据项,它会检查当前值是否还是 "A"。由于进程 B 把值又改回了 "A",所以这个检查会通过,进程 A 接下来就会进行更新操作。但实际上,在进程 A 的两次操作之间,数据项的值已经被其它进程(进程 B)修改过了,这种现象被称为 ABA 问题。