接口幂等性

背景

在微服务架构下,我们在完成一个订单流程时经常遇到下面的场景:

  • 一个订单创建接口,第一次调用超时了,然后调用方重试了一次
  • 在订单创建时,我们需要去扣减库存,这时接口发生了超时,调用方重试了一次
  • 当这笔订单开始支付,在支付请求发出之后,在服务端发生了扣钱操作,接口响应超时了,调用方重试了一次
  • 一个订单状态更新接口,调用方连续发送了两个消息,一个是已创建,一个是已付款。但是你先接收到已付款,然后又接收到了已创建
  • 在支付完成订单之后,需要发送一条短信,当一台机器接收到短信发送的消息之后,处理较慢。消息中间件又把消息投递给另外一台机器处理
  • 前端重复提交选中的数据,应该后台只产生对应这个数据的一个反应结果
  • 我们发起一笔付款请求,应该只扣用户账户一次钱,当遇到网络重发或系统bug重发,也应该只扣一次钱
  • 发送消息,也应该只发一次,同样的短信发给用户,用户会哭的
  • 创建业务订单,一次业务请求只能创建一个,创建多个就会出大问题等等

以上问题,就是在单体架构转成微服务架构之后,带来的问题。当然不是说单体架构下没有这些问题,在单体架构下同样要避免重复请求。但是出现的问题要比这少得多。

1. 幂等性的概念

幂等(idempotent、idempotence)是一个数学与计算机学概念,常见于抽象代数中。 在编程中.一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。例如,“getUsername()和setTrue()”函数就是一个幂等函数。更复杂的操作幂等保证是利用唯一交易号(流水号)实现,综上所述:幂等就是一个操作,不论执行多少次,产生的效果和返回的结果都是一样的。

为了解决以上问题,就需要保证接口的幂等性,接口的幂等性实际上就是接口可重复调用,在调用方多次调用的情况下,接口最终得到的结果是一致的。有些接口可以天然的实现幂等性,比如查询接口,对于查询来说,你查询一次和两次,对于系统来说,没有任何影响,查出的结果也是一样。

接口幂等性就是用户对同一操作发起了一次或多次请求的对数据的影响是一致不变的,不会因为多次的请求而产生副作用。

副作用:可以认为多次请求操作,每一次对数据状态都会产生影响 。
注意这里并没有要求接口返回结果是一致的。
例如:update order set moeny = 100 where orderId = 2029282312
该操作无论执行多少次,对数据的影响都是一致的,不变的。

2.什么情况需要处理接口幂等性问题?

2.1 select 天然自带幂等性。

每次查询对数据都不会产生副作用。

2.2 insert 当我们重复插入数据的时候,会出现什么情况?

第一种情况:自增主键,没有幂等性。

eg:insert into product_info (id,name,type,price,tm)
执行多次,会新增多条记录。对结果集产生了副作用。

第二种情况:业务主键,具有幂等。

eg:insert into product_info (orderId,name,type,price,tm) orderId 为主键唯一
无论该sql执行多少次,对结果集产生的效果都是一样只增加了一条数据。

2.3 delete 是否具有幂等性?

第一种情况:绝对删除,具有幂等性。

eg:delete from order where id = 3;
无论该sql执行多少次,对结果集产生的效果都是一样只删除了一条数据。

第二种情况: 相对删除,不具有幂等性。

eg:delete from order where id > 23;
该操作每执行一次,对结果集产生的结果,可能都不一样,同一操作多次执行对数据产生了副作用。

2.4 update 是否具有天然幂等性?

第一种情况:绝对更新,具有幂等性。

eg:update good set stock= 586 where goodId = 10;
该操作无论执行多少次操作对结果的影响都是一样。

第二种情况:相对更新,不具有幂等性。

eg:update good set stock = stock+10 where goodid= 10;
每次执行该操作库存数量都会加10,所以不具备幂等操作。

总结:以上都是基于单库,单表的操作幂等性的分析,其实在具体业务当中,可能要设计多个表,多个库,甚至跨服务操作。比如分布式系统中,我们一个接口,可能需要调用多个服务来完成任务。那么这种情况,如何保证接口的幂等性呢?

3. 接口幂等性解决方案

接口幂等处理要根据具体业务来判断怎么处理,以下会举例来阐述接口幂等处理解决方案。

3.1 唯一索引,防止新增脏数据

比如:支付宝的资金账户,支付宝也有用户账户,每个用户只能有一个资金账户,怎么防止给用户创建资金账户多个,那么给资金账户表中的用户ID加唯一索引,所以一个用户新增成功一个资金账户记录。要点:唯一索引或唯一组合索引来防止新增数据存在脏数据(当表存在唯一索引,并发时新增报错时,再查询一次就可以了,数据应该已经存在了,返回结果即可)。

3.2 token+redis机制

比如订单支付场景:
该支付分为两个步骤:

  • 1.1 获取全局唯一token
    • 接口处理生成唯一标识(token) 存储到redis中(redis单线程的,处理需要排队),并返回给调用客户端。
  • 1.2 发起支付操作并附带token
    接口处理:
    • 1.2.1 获得分布式锁(处理并发情况)
    • 1.2.2 判断redis中是否存在token
    • 1.2.3 存在 执行支付业务逻辑,否则返回该订单已经支付
    • 1.2.4 释放分布式锁

思考:为什么要加分布式锁?
原因1:在高并发请求中 ,token判断是否存在是非线程安全的,所以要加分布式锁来保证该条件的判断为线程安全。

注释:也可redis用删除操作来判断token,删除成功代表token校验通过 这个删除是原子操作的

原因2:在支付业务中,判断支付订单是否已经存在,存在说明该订单已经支付过了,不存在就执行扣款操作,如果相同操作并发两个请求来到判断条件可能两个请求都能判断支付订单不存在,造成重复扣款。 所以也要加分布式锁保证线程的安全。

token特点:要申请,一次有效性,可以限流。注意:redis要用删除操作来判断token,删除成功代表token校验通过,如果用select+delete来校验token,存在并发问题,不建议使用;

3.3 CAS 保证接口幂等性

状态机制幂等(状态不可逆)
针对更新操作:
例如 电商订单,订单支付状态:0 待支付,1 支付中,3 支付成功,4 支付失败。

update order set status = 1 where status = 0 and orderId = “201251487987”
该sql语句利用状态CAS 保证该操作的幂等。

eg:比如要进行订单支付,上来先用CAS更新订单状态,
   返回影响数 1 代表修改成功,可以支付,继续执行支付业务代码
   返回影响数 0 代表修改失败,该订单已经不是待支付订单了。

注释:实际这里是利用CAS原理

3.4 悲观锁

悲观锁——获取数据的时候加锁获取。select * from table_xxx where id='xxx' for update; 注意:id字段一定是主键或者唯一索引,不然是锁表,会死人的悲观锁使用时一般伴随事务一起使用,数据锁定时间可能会很长,根据实际情况选用。

3.5 乐观锁实现幂等

背景由来:

为什么要有幂等这种场景?因为在大的系统中,都是分布式部署,如:订单业务 和 库存业务有可能都是独立部署的,都是单独的服务。用户下订单,会调用到订单服务和库存服务。

比如:订单系统:
订单服务 —> 库存服务(PRC远程调用(服务接口))

因为分布式部署,很有可能在调用库存服务时,因为网络等原因,订单服务调用失败,但其实库存服务已经处理完成,只是返回给订单服务处理结果时出现了异常。这个时候一般系统会作补偿方案,也就是订单服务再次发起库存服务的调用,库存减1

update t_goods set count = count -1 where good_id = 2;

这样就出现了问题,其实上一次调用已经减了1,只是订单服务没有收到处理结果。现在又调用一次,又要减1,这样就不符合业务了,多扣了。

幂等这个概念就是,不管库存服务在相同条件下调用几次,处理结果都一样。这样才能保证补偿方案的可行性。

乐观锁方案
借鉴数据库的乐观锁机制,如:

update t_goods set count = count -1,version = version + 1 where good_id = 2 and version = 1;

乐观锁只是在更新数据那一刻锁表,其他时间不锁表,所以相对于悲观锁,效率更高。乐观锁的实现方式多种多样可以通过version或者其他状态条件:

  1. 通过版本号实现update table_xxx set name=#name#,version=version+1 where version=#version#;
  2. 通过条件限制 update table_xxx set avai_amount=avai_amount-#subAmount# where avai_amount-#subAmount# >= 0要求:quality-#subQuality# >=,这个情景适合不用版本号,只更新是做数据安全校验,适合库存模型,扣份额和回滚份额,性能更高;

注意:乐观锁的更新操作,最好用主键或者唯一索引来更新,这样是行锁,否则更新时会锁表,上面两个sql改成下面的两个更好:

update table_xxx set name=#name#,version=version+1 where id=#id# and version=#version#;
update table_xxx set avai_amount=avai_amount-#subAmount# where id=#id# and avai_amount-#subAmount# >= 0;

3.6 分布式锁

还是拿插入数据的例子,如果是分布是系统,构建全局唯一索引比较困难,例如唯一性的字段没法确定,这时候可以引入分布式锁,通过第三方的系统(redis或zookeeper),在业务系统插入数据或者更新数据,获取分布式锁,然后做操作,之后释放锁,这样其实是把多线程并发的锁的思路,引入多多个系统,也就是分布式系统中得解决思路。要点:某个长流程处理过程要求不能并发执行,可以在流程执行之前根据某个标志(用户ID+后缀等)获取分布式锁,其他流程执行时获取锁就会失败,也就是同一时间该流程只能有一个能执行成功,执行完成后,释放分布式锁(分布式锁要第三方系统提供)。

3.7 防重表

1.利用数据库建一张防重表(加唯一索引)

比如订单支付,
反正重复支付:订单号插入防重表成功执行支付业务逻辑,失败说明已经支付过。

防重表支付成功是否要删除:
1.可定期清除数据
2.也可结合订单状态,在支付前查询订单状态为待支付执行支付操作,操作后删除订单号若第二个请求插入防重表成功,但是这个时候查询订单状态失败。
(实际这个防重表就是实现了分布式锁)

3.8 缓存队列

将请求放入队列,后续使用异步任务处理队列中的数据,过滤掉重复的消息。和防止重复消费道理是一样。

posted @ 2023-03-09 16:51  ll=ll  阅读(32)  评论(0编辑  收藏  举报