2的负x次幂图像_幂等 — 一个接口的基本素养

概念


幂等的定义:多次执行所产生的影响均与⼀次执行的影响相同。 数学公式:f(x) = f(f(x)),即多次“幂”后结果相“等”。

问题及场景


众所周知,服务间的调用可能会存在三个状态:成功失败超时。前两者都是明确状态,超时则是个未知状态。

对于未知状态,我们可以看看如下几个场景:

  • 场景⼀:创建订单

  1. 由于网络卡顿或响应不及时,用户多次点击提交订单按钮,造成后台服务器的多次请求。(订单会不会生成多笔呢?)

  2. 用户提交订单成功后,刷新页面再次触发表单提交。(订单会不会再次创建?)

  • 场景⼆:订单核销优惠券

  1. 订单调用优惠券核销接口进行优惠券的核销,接口超时,进行重试。(优惠券会不会被使用多次?)

  • 场景三:订单扣库存

  1. 订单调用扣减库存接口进行库存的扣减,接口超时,进行重试。(库存会不会被反复扣减?)

分析与解决

可以看到:对于以上场景,因为系统超时,客户端的任何重试都可能造成服务端不⼀致的影响。那么在这种情况下,⼀般要求服务端对提供的服务接口保证幂等

如何保证幂等,涉及到两个问题:

  • 如何识别多个请求是否为同⼀个?

  • 对于同⼀个请求的多次执行,采用什么机制来保障只执⾏⼀次,或即使执行多次也不会造成状态不⼀致的影响?

基于上述2个问题,很容易就可以归纳出如下2个要素:

  • 幂等标识

客户端与服务端之间需要确定⼀个协议,以保证在同⼀个请求上具备唯⼀标识的元素,用于识别是否同⼀个请求的多次尝试,这个元素通常由客户端生成。例如:订单号。(对于如何生成⼀个全局唯⼀标识,这又是另外⼀个话题,后面我们单开文章再展开讲。)

  • 唯⼀性保障机制

服务端需要通过某种机制去确保同⼀个请求的多次尝试具有同样的影响。例如:同⼀笔交易不应该因为客户端发起两次请求就发生两次扣款,这种机制通常是服务端通过检查客户端传递的幂等标识及其关联状态来实现,存在且状态一致就证明已执行过,不存在就证明是初次执行。

能确保以上两个要素,基本就能满足幂等的要求。

基于以上分析,我们尝试先画⼀个通用的处理流程:

2436654521a0efe30c878f9713825134.png

再看看具体怎么实现:

  1. 首先,需要⼀个记录请求日志的表,包含幂等标识字段

  2. 其次,请求日志表中的数据需要跟业务处理保持⼀致,必要时通过事务进行保证

一个简化实现的伪代码如下:

sql = select count(*) from request_log where idempotent_id = 123;int count = db.select(sql);if(count == 0) {  // 业务未执⾏过  // 1、保存请求⽇志  insert into request_log (idempotent_id, other fields...) values(123, ...);  // 2、执⾏业务处理}else {  // 业务已经执⾏过,不能再次执⾏,幂等返回}

上述简化版的实现,在实际的工程环境中基本不可用。我们进一步分析:

  • 并发场景

对于并发场景,由于相同请求同时到达,存在查询的时候没有,插入的时候出现重复的可能,对于这种情况,常规思路就是加锁,加锁的实现方式有多种,对于分布式场景,通常会使用分布式锁。(怎么实现⼀个可靠的分布式锁,又是另外⼀个大的话题,后面我们单开文章再细讲。)

考虑并发场景,加锁实现的伪代码如下:

lock(123);try {  sql = select count(*) from request_log where idempotent_id = 123;  int count = db.select(sql);  if(count == 0) {    // 业务未执⾏过    // 1、保存请求⽇志    insert into request_log (idempotent_id, other fields...) values(123, ...);    // 2、执⾏业务处理  }else {    // 业务已经执⾏过,不能再次执⾏,幂等返回  }}finally {  unlock(123);}
  • 查询操作优化

实际生产中,对于99.9%的请求(甚至更多),是不存在重复的(可以认为重试的情况占比非常少),然而我们为了这0.1%的重复可能性,做了100%的查询(每次都会先查询后处理),是比较浪费的行为(查询数据库是网络IO操作,而数据库⼀般还存在磁盘IO操作,相对比较耗时)。上述查询浪费的理由同样适用于并发场景的加锁操作,毕竟锁的开销在高并发的场景下也不可忽视。所以,我们换个思路,是否可以考虑在保存 request_log 时做做⼿脚,对 idempotent_id 加唯⼀索引,如果重复,则保存失败,抛出重复数据异常(DuplicateKeyException);反之,保存没有异常,则证明 idempotent_id 不存在。

查询优化实现的伪代码如下:

// 1、保存请求⽇志sql = insert into request_log (idempotent_id, other fields...) values(123,...);try {  db.insert(sql);}catch(DuplicateKeyException e) {  // 捕获DuplicateKeyException,则证明业务已执⾏过,幂等返回}// 2、执⾏业务处理

结合⼀些数据库本身独有的语法特性来做重复判断(例如:MySQL 的 insert ignore,可以通过返回的 affected rows 是否为0来判重),相较于捕获异常,是否可能会更优雅⼀点?(但这样又会引入对特定数据库的强依赖,在实际工程环境中,需要综合权衡选择)

结合MySQL语法特性来实现的伪代码如下:

// 1、保存请求⽇志sql = insert ignore into request_log (idempotent_id, other fields...)values(123, ...);int affectedRows = db.insert(sql);if(affectedRows == 0) {  // insert操作返回影响⾏数为0,证明因为重复键保存失败,幂等返回}// 2、执⾏业务处理

⼀些特定场景的定制化方案


  • 上述场景⼀的定制化方案

  1. 通过前端控制点击后按钮置灰,限制不能再次点击,点击后跳转页⾯。(PRG模式:Post/Redirect/Get)

  2. 通过后端控制,进入页面前先在客户端生成token(也可获取服务端生成的 token),提交时将token⼀起提交至服务端,服务端通过判断token是否已使用来判断是否重复提交。

实际工程环境中建议两者结合一起使用。
需要补充一点是:从服务端获取token,除了防重外,其实也包含了⼀些鉴权的思路在里面。

  1. 服务端会对客户端请求时携带token做校验,判断这个token是否由服务端签发;

  2. 为了避免客户端恶意频繁获取token,服务端通常会通过限频的方式来规避风险

交互形式如下图:

ca012947bf38126d28b7051ee89085e2.png

  • 上述场景二的定制化方案

场景⼆,粗略一看跟场景三是⼀回事,实际分析业务,又会发现二者有所差异。
对于优惠券,核销完后会更新优惠券状态为已使用,所以只需要通过判断优惠券状态是否已更改,就能判断核销是否成功,这样即使多次核销同⼀张优惠券也只会成功⼀次,符合⼀次执⾏与多次执⾏影响相同的结论。
对于这类天然包含了幂等条件的业务,一个通用的最佳实践是:使用状态机来实现幂等,即通过业务主体的状态变更来判断业务是否已经被执行,无需额外创建请求日志表,例如:订单状态变更、优惠券状态变更等都属于这一类场景。
简化的伪代码实现如下:

sql = select coupon_status from coupon where coupon_id = 123;int coupon_status = db.select(sql);if(coupon_status == '已使⽤') {  // 状态校验失败,⽆法执⾏业务处理,幂等返回} else {  // 执⾏业务处理  update coupon set coupon_status = '已使⽤' where coupon_id = 123;}

优化思路与通用方案类似:防止并发带来的不⼀致 + 省略查询操作,这里可以使用乐观锁的方式来处理。
伪代码实现如下:

sql = update coupon set coupon_status = '已使⽤' where coupon_id = 123 andcoupon_status = '未使⽤';int affectedRows = db.update(sql);if(affectedRows == 0) {  // update操作返回影响⾏数为0,证明状态更新失败,幂等返回}

总结


要想实现幂等,不管用何种方式,都需要满足两个基本要素:

  1. 幂等标识

  2. 唯⼀性保障机制

上文中给出了几种常规⽅案:

  1. 锁 + select + insert

  2. 数据库唯⼀约束(insert + DuplicateKeyException / insert ignore + affected rows)

  3. 状态机幂等

  4. 前后端结合的防重复提交

方案实现过程中,需要注意的⼀些细节点:

  1. 不管是悲观锁还是乐观锁,解决的都只能是并发带来的问题,而幂等的本质问题还是需要通过幂等标识 + 唯⼀性保障机制来解决。

  2. 注意确认重试时的请求内容是否跟第⼀次调用时⼀致,即两个相同唯⼀标识对应的请求内容是否⼀致。

  3. 对于不同渠道/客户端的唯⼀标识,需要通过渠道/客户端 + 唯⼀标识结合的方式才能唯⼀确定。

额外的⼀些Tips:

  1. 数据库判重有多种方式,如果仅仅是为了判重,那么select *是相对耗时的操作,可以优化成select count(*),甚至对于count结果较大的语句,可以优化成 select(*) ... limit 1(这是MySQL语法,其它数据库自行替换)。

  2. 对于无并发场景的非热点数据,可以简单的通过数据库唯⼀约束的方案解决,对于⾼并发场景的热点数据,可使用分布式锁、队列等变并为串的方式,减少数据库锁的争用,避免数据库成为性能瓶颈。(这个Tip是附赠的,与幂等其实无关)

附加


HTTP的幂等性:

  • GET方法用于获取资源,不应有副作用,所以是幂等的。(HEAD本质同GET)

  • DELETE方法用于删除资源,有副作用,但它应该满足幂等性。

  • POST方法用于创建资源,所对应的URI并非创建的资源本身,而是去执行创建动作的操作者,有副作用,不满足幂等性。

  • PUT 方法用于创建或更新操作,所对应的URI是要创建或更新的资源本身,有副作用,它应该满足幂等性。

详情可参考RFC2616幂等⽅法(https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html)章节

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值