基于幂等表思想的幂等实践
一、为什么需要幂等
分布式场景下,多个业务系统间实现强一致的协议是极其困难的。一个最简单和可实现的假设就是保证最终一致性,这要求服务端在处理一个重复的请求时需要给出相同的回应,同时不会对持久化数据产生副作用(即多次操作与单次操作的结果需要是业务角度一致的)。
一个 API 拥有幂等能力的话,调用发起方就可以很安全的进行重试。这符合我们普遍的假设。提供幂等能力是服务提供方必须需要做的事。
拥有幂等能力的话可以保证我们的接口不会被各种异常重试或恶意请求锁冲击。
二、幂等方式
不同的场景下(常见的是【界面和后端接口交互场景】和【接口于接口交互场景】),幂等方式有很多并且各不相同,都各有一些局限性和缺点!!!
可以基于【
业务key + 业务状态机 + 乐观锁
】去做幂等实现(一般适用于比较简单的 update 场景)比如:更新订单状态为
finished
的场景中,先根据订单号查询订单,判断订单状态是否为finished
,若不是则更新为finished
可以基于【
业务key + 分布式锁 + 业务状态机
】去做幂等实现比如:新增用户信息场景中,执行方法前先加分布式锁(防并发),以用户身份证号为查询条件,查询用户,如果用户不存在则进行新增。如果用户存在则幂等处理
这种方案一般有个固定的流程:【
一锁、二判、三执行
】可以基于【
业务key + 唯一索引
】去做幂等实现(一般适用于新增数据场景)比如:新增用户信息场景中,以用户身份证号为唯一键,建立唯一索引,新增用户时通过捕获唯一键冲突异常(
DuplicateKeyException
)进行幂等控制可以基于【
Redis + token模式
】去做幂等实现(多用于界面和接口交互,接口于接口交互不太适用,该方案也是比较常见的方案,但不在本次讨论范围中)比如:用户提交填写好的表单信息,多次重复提交时保证仅仅真正执行一次,其余的都幂等返回相同结果
可以基于【
幂等表
】去做幂等实现(比较通用的一种方案,具体效率取决于存储幂等记录的存储介质)比如:消费 MQ 消息时,为了避免消息重复消费,消费消息前可以先插入一条幂等记录,然后再执行消费逻辑,消费完成后修改幂等记录的幂等状态为消费成功!
接口互调的情况类似
三、幂等设计原则
一个具有幂等性的服务,要求无论重复请求在多么极端的情况下发生,都要表里如一,此时必须满足:
对外:返回完全相同的结果
对内:自身状态不再发生任何改变
对于服务提供方来说:严格来说需要请求中的字段完全一样,服务提供方才认为是重复请求。但是在实际环境中我们可能没有这么严格的要求,我们一般认为只要关键的业务参数相同,那么他就属于重复请求,应该被幂等处理。
对于服务调用方来说:需要做好幂等结果处理,多次请求返回相同结果需要正确被处理
幂等设计要尽量从
简单、可靠、高效
(过多的幂等逻辑会对可用性和性能造成影响)角度出发简单:幂等流程和逻辑要尽量简单
可靠:不仅仅在正常运行的情况下要保证幂等的可靠性,在某些异常场景下也要尽量保证幂等的可靠性,否则该幂等设计的意义将大打折扣
高效:幂等逻辑执行不能高耗时,针对于一些高并发的接口需要做到尽量减少幂等逻辑执行耗时
通用幂等组件设计易用性和可扩展性也同样重要
四、常见幂等场景例子
【
MQ消息消费场景中
】,由于 MQ 为了保证消息投递成功,可能会发起多次重试,那么消费者方便需要保证重复的消息能够被幂等处理(比如:监听用户支付成功消息进行生成支付单)【
界面和接口交互场景中
】,前端重复提交数据,后台接口需要保证只执行一次,其余重复请求均幂等返回(比如:用户重复提交订单、重复提交录入的用户信息)【
接口互调的场景中
】,调用方可能由于多种原因没能收到响应结果而发起重试(比如:数据同步、库存扣减等),此时被调用方需要保证重复调用幂等处理
五、幂等实践
为了将幂等这个常见的通用需求尽量设计得通用化,我们这里采用【
幂等表 + 幂等状态机
】来实现,该方案可以适用于绝大部分的【界面 + 接口交互】和【接口 与 接口交互】模式如果项目仅仅是界面和接口交互模式,那么采用【
Redis + token
】方案也是一个不错的选择当然,软件工程中几乎没有银弹,很难有一种完美适用与所有场景的方案
1、设计流程
调用放发起请求,请求到达服务提供方
获取指定的业务 key 作为唯一的幂等键,构建幂等记录(此时幂等记录 status 为处理中(
processing
)),然后尝试将幂等记录写入存储介质(可以是 Redis 也可以是 MySQL 或其他存储介质)如果幂等记录写入成功,则执行业务逻辑
业务逻辑执行完毕,通过唯一键修改幂等记录的 status 为成功(
success
)如果幂等记录写入失败,则说明幂等记录已存在(该业务 key 对应的数据,之前有被执行过),需要进行如下处理:
通过幂等唯一键查询幂等记录,并且判定幂等记录的 status
如果 status 为成功(
success
),则说明上次已经执行过该业务了,本次无需再重复执行,获取上次执行的结果(如果有需要的话)幂等返回即可如果 status 为处理中(
processing
),则说明已经有其他线程正在处理业务数据 或者是 极端情况下应用宕机导致的异常情况。此时需要判定【请求处于处理中(processing)状态的时长
】,并且结合应用配置的【允许的最大业务执行时长
】进行判断处于
processing
状态的时间已经超过配置的【允许的最大业务执行时长
】,则尝试以乐观锁的方式重新修改幂等记录,如果修改成功则执行业务逻辑,反之则抛出并发异常。处于
processing
状态的时间没有超过配置的【允许的最大业务执行时长
】,那么直接抛出并发请求异常
2、问题思考
关于上述的幂等实现流程中,极端情况下,有如下几点需要思考和注意的问题点
极端情况下,如果插入幂等记录成功,并且正常执行了业务流程,此时更新幂等状态为 success 时出现异常(比如存储幂等记录的存储介质宕机了),此时是否需要处理该异常,还是说抛出异常中断流程???如果抛出异常会有什么影响?如果 catch 异常会有什么影响?
方案一、抛出异常:如果抛出异常中断流程,那么调用方应该感知到调用失败了,但是实际上业务流程已经执行完毕,这种情况如果调用方发起重试,那么幂等便会失效(同一个业务 code 被执行了两次)
方案二、不抛出异常:如果不抛出异常,接口会继续执行,然后返回数据给调用方,如果调用方收到了返回数据,那么便不会发起重试了,不会有幂等问题。但是此时幂等记录的状态仍然是处理中(
processing
),再指定了业务最大执行时间
的情况下,如果调用方【超过指定的最大执行
】时间再次发起重试,那么幂等仍然失效(当然我们可以不指定业务最大执行时间)经过上述两种情况的比较,我们一般倾向第二种方案,自己处理掉异常,并且做一层 【兜底策略】 (比如告警或记录该条幂等数据信息等,后续可以转人工核对该数据),这种方案更加稳定和适用
如果业务逻辑执行失败,那么是否应该删除之前创建的幂等记录?
按照严格的幂等含义来说,我们应该保留这条幂等记录,并且将幂等记录的状态修改为
Exception或failed
,后续有重试请求进来时执行返回 failed 给调用方即可(保证多次调用得到的结果相同)。但是在真实环境中业务执行异常有可能是数据校验失败、接口里调用外部系统失败(比如外部系统正在发版(没有做优雅发布)等)
针对于这些情况可能调用方修改数据后进行重试或过一定时间后进行重试,那么此时最好有一定的自愈能力,而不是每次这种数据都转人工处理(一些场景中会加大人力成本,比如我之前涉及到的某个系统,经常有些调用方传递的业务参数有问题或接口里调用外部系统失败的情况)
当然这两种策略需要根据具体的情况来选择,没有谁好谁坏之分。
是否需要设定【最大的处理时间】,比如我们期望接口最大处理时间为 1 小时(也就是说幂等记录处理 processing 状态的时间最大为 1h),如果超过这个时间,那么认为这不是一种正常的 case,下次重试请求时应该尝试恢复业务执行。
这也是个具有两面性的选择问题,需要根据实际项目情况权衡选择
评论