分布式幂等性设计

系统设计

Posted by Duu on June 17, 2024

分布式幂等性设计

一个接口如果幂等,不管被调多少次,只要参数不变,结果也不变。

幂等性是对于写操作来说的,一个写操作,一般都需要保证:

  • 幂等性
  • 可用性
  • ACID事务属性。

在高并发场景的架构里,幂等性是必须得保证的。比如说支付功能,用户发起支付,如果后台没有做幂等校验,刚好用户手抖多点了几下,于是后台就可能多次受到同一个订单请求,不做幂等很容易就让用户重复支付了,这样用户是肯定不能忍的。

出现原因

原因之一:底层网络阻塞和延迟的问题

在系统高并发的环境下,很有可能因为网络阻塞等等问题,导致客户端不能及时的收到服务端响应,甚至是调用超时。这时候用户会重复点击,重复请求。

在消息队列组件中,客户端也有重试机制,如果投递失败/投递超时,则会重新投递。对于服务端来说,可能会收到重复投递的一份消息。

在RPC组件中,客户端也有重试机制,如果投递失败/投递超时,则会重试调用。对于服务端来说,可能会重复收到通用的调用。

原因之二:用户层面的重复操作

比如下单的按键在点按之后,在没有收到服务器请求之前,用户还可以被按。

或者,用户的App闪退/人工强退,之后重新打开重新下单

场景

可能会发生重复请求或重试操作的场景,在分布式、微服务架构中是随处可见的。

  • 网络波动:因网络波动,可能会引起重复请求
  • 分布式消息消费:任务发布后,使用分布式消息服务来进行消费
  • 用户重复操作:用户在使用产品时,可能无意地触发多笔交易,甚至没有响应而有意触发多笔交易
  • 未关闭的重试机制:因开发人员、测试人员或运维人员没有检查出来,而开启的重试机制(如Nginx重试、RPC通信重试或业务层重试等)

大致可以分为两大类:

  • 第一类:单数据CRUD操作的幂等性保证方案
  • 第二类:多数据并发操作的幂等性保证方案
单数据CRUD操作的幂等性保证方案

首先,来看看单数据CRUD操作的幂等性保证方案

对于单数据CRUD操作,很多具备天然幂等性

  • 新增类动作:不具备幂等性
  • 查询类动作:重复查询不会产生或变更新的数据,查询具有天然幂等性
  • 更新类动作:

    • 基于主键的计算式Update,不具备幂等性,即UPDATE goods SET number=number-1 WHERE id=1
    • 基于主键的非计算式Update:具备幂等性,即UPDATE goods SET number=newNumber WHERE id=1
    • 基于条件查询的Update,不一定具备幂等性(需要根据实际情况进行分析判断)
  • 删除类动作:

    • 基于主键的Delete具备幂等性
    • 一般业务层面都是逻辑删除(即update操作),而基于主键的逻辑删除操作也是具有幂等性的

大家看到,对于单数据CRUD操作, 只有在下面的三个场景,保证幂等即可:

  • 主键的计算式Update
  • 基于条件查询的Update
  • 新增类动作
多数据并发操作的幂等性保证方案

大部分,都是这种场景。

现在的应用,大部分都是微服务的。并且一个操作会涉及到多个数据的并发操作,会通过RPC调用到多个微服务。

分为两种情况:

  • 多数据同步操作,一般是服务端提供一个统一的同步操作api,客户端调用该api完成,直接获得操作结果。
  • 多数据异步操作,由于同步操作性能低,在高并发场景都会同步变异步,于是乎,服务端还要额外提供一个查询操作结果的api,去查询结果。第一次超时之后,调用方调用查询接口,如果查到了就走成功的流程,失败了就走失败的流程。
多数据并发操作的经典场景,参考如下:
1. 高并发抢红包

在抢一份红包的时候,点击了抢,开始异步抢红包。

抢到就有,没抢到就没有。

抢完之后,无论我们重复点击多少次,红包都会提示你已经抢过该红包了。

2. 高并发下单

高并发下单的一个很基本的问题,就是要避免重复订单。

如果用户操作一次,由于超时重试等原因,一看下了两个单,甚至10个重复单。

3. 高并发支付

在支付场景,支付平台会生成唯一的支付连接,不会再次生成另外的支付连接。

解决方案

img

  1. 查询和删除不在幂等讨论范围,查询肯定没有幂等的说,删除:第一次删除成功后,后面来删除直接返回0,也是返回成功。
  2. 全局唯一 ID:根据业务的操作和内容生成一个全局ID,在执行操作前先根据这个全局唯一ID是否存在,来判断这个操作是否已经执行。
  3. 建唯一索引:新建一张去重表创建唯一索引或唯一组合索引来防止新增数据存在脏数据 (当表存在唯一索引,并发时新增异常时,再查询一次就可以了,数据应该已经存在了,返回结果即可)。
  4. token机制:由于重复点击或者网络重发,或者nginx重发等情况会导致数据被重复提交。前端在数据提交前要向后端服务的申请token,token放到 Redis 或 JVM 内存,token有效时间。提交后后台校验token,同时删除token,生成新的token返回。redis要用删除操作来判断token,删除成功代表token校验通过,如果用select+delete来校验token,存在并发问题,不建议使用。
  5. 悲观锁
select id ,name from table_? where id='??' for update; 

悲观锁使用时一般伴随事务一起使用,数据锁定时间可能会很长,根据实际情况选用(另外还要考虑id是否为主键,如果id不是主键或者不是 InnoDB 存储引擎,那么就会出现锁全表)。

  1. 乐观锁,给数据库表增加一个version字段,可以通过这个字段来判断是否已经被修改了
update table_xxx set name=#name#,version=version+1 where version=#version#
  1. 分布式锁,比如 Redis 、 Zookeeper 的分布式锁。单号为key,然后给Key设置有效期(防止支付失败后,锁一直不释放),来一个请求使用订单号生成一把锁,业务代码执行完成后再释放锁。
  2. 状态机控制:这种方法适合在有状态机流转的情况下,比如就会订单的创建和付款,订单的付款肯定是在之前,这时我们可以通过在设计状态字段时,使用int类型,并且通过值类型的大小来做幂等,比如订单的创建为0,付款成功为100,付款失败为99。
update goods_order set status=#{status} where id=#{id} and status<#{status}
  1. 保底方案,先查询是否存在此单,不存在进行支付,存在就直接返回支付结果。
  2. 综合性方案:一锁二判三更新

简单来说就是当任何一个并发请求过来的时候

  • 1.先锁定单据
  • 2.然后判断单据状态,是否之前已经更新过对应状态了
  • 3.1 如果之前并没有更新,则本次请求可以更新,并完成相关业务逻辑。
  • 3.2 如果之前已经有更新,则本次不能更新,也不能完成业务逻辑。

第一步:先加锁

高并发场景,建议是redis分布式锁,而不是低性能的DB锁,也不是CP型的 Zookeeper锁。

如果普通的redis分布式锁性能太低,该如何?

还可以考虑引入 锁的分段机制, 比如内部分成100端,总体上,就大概能线性提升 100倍。

第二步:进行幂等性判断

幂等性判断,就是 进行 数据检查。

可以基于状态机、流水表、唯一性索引等等前面介绍的 基础方案,进行重复操作的判断。

第三步:数据更新

如果通过了第二步的幂等性判断, 说明之前没有执行过更新操作。

那么就进入第三步,进行数据的更新,将数据进行持久化。

操作完成之后, 记得释放锁, 结束整个流程。