浅谈网络中接口幂等性设计问题

所谓幂等性设计,就是说,一次和多次请求某一个资源应该具有同样的副作用。用数学的语言来表达就是:f(x) = f(f(x))。

在数学里,幂等有两种主要的定义。 在某二元运算下,幂等元素是指被自己重复运算(或对于函数是为复合)的结果等于它自己的元素。

某一元运算为幂等的时,其作用在任一元素两次后会和其作用一次的结果相同。



一、关于幂等性

1、幂等的定义

所谓幂等性设计,就是说,一次和多次请求某一个资源应该具有同样的副作用。用数学的语言来表达就是:f(x) = f(f(x))。

下面是维基百科中对于幂等的定义:

在数学里,幂等有两种主要的定义。 在某二元运算下,幂等元素是指被自己重复运算(或对于函数是为复合)的结果等于它自己的元素。

某一元运算为幂等的时,其作用在任一元素两次后会和其作用一次的结果相同。

百度百科上是这么说的:

在编程中一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。

幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。

简而言之,幂等是指:多次调用方法或者接口不会改变业务状态,可以保证重复调用的结果和单次调用的结果一致。

2、幂等的必要性

根据上面对幂等性的定义我们得知:产生重复数据或数据不一致,这个绝大部分是由于发生了重复请求。

这里的重复请求是指同一个请求在一些情况下被多次发起。

在接口调用时一般情况下都能正常返回信息不会重复提交,不过在遇见以下情况时可能就会出现问题,如:

  • 微服务架构下,不同微服务间会有大量的基于 http,rpc 或者 mq 消息的网络通信。如果超时了,微服务框架会进行重试;
  • 用户交互的时候多次点击,无意地触发多笔交易;
  • MQ消息中间件,消息重复消费;
  • 第三方平台的接口(如:支付成功回调接口),因为异常也会导致多次异步回调;
  • 其他中间件/应用服务根据自身的特性,也有可能进行重试。

举几个支付时的例子:

  • 订单创建接口,第一次调用超时了,然后调用方重试了一次。是否会多创建一笔订单?
  • 订单创建时,我们需要去扣减库存,这时接口发生了超时,调用方重试了一次。是否会多扣一次库存?
  • 当这笔订单开始支付,在支付请求发出之后,在服务端发生了扣钱操作,接口响应超时了,调用方重试了一次。是否会多扣一次钱?

因为系统超时,而调用户方重试一下,会给我们的系统带来不一致的副作用。

3、幂等性

幂等性主要保证多次调用对资源的影响是一致的。其本质是通过唯一标识,标记同一操作的方式,来消除多次执行的副作用。

下一用 SQL DML 命令来理解幂等性操作:

  • 查询(SELECT):查询语句不会对数据产生任何变化,天然具备幂等性;
  • 新增(INSERT):带有唯一索引,重复插入会导致后续执行失败,具有幂等性;不带有唯一索引,多次插入会导致数据重复,不具有幂等性;
  • 修改(UPDATE):直接赋值(score = 1),不管执行多少次 score 都一样,具备幂等性;计算赋值(score = score + 1),每次操作 score 数据都不一样,不具备幂等性;
  • 删除(DELETE):绝对值删除(DELETE FROM … WHERE …),重复多次结果一样,具备幂等性;相对值删除(DELETE top(3) FROM …),重复多次结果不一致,不具备幂等性。

二、对于幂等性的实现

总的来说,我们解决幂等性问题就是要控制对资源的写操作,因此我们可以通过控制重复请求、过滤重复动作、解决重复写风险三种方式分别在源头、过程以及结果上对幂等性问题进行分析解决。

1、源头上–控制重复请求(前端防重)

通过前端防重保证幂等是最简单的实现方式,前端相关属性和JS代码即可完成设置。可靠性并不好,有经验的人员可以通过工具跳过页面仍能重复提交。主要适用于表单重复提交或按钮重复点击。

主要解决方案**:**

  • 控制操作次数,例如:提交按钮仅可操作一次(提交动作后按钮置灰)
  • 及时重定向,例如:下单/支付成功后跳转到成功提示页面,这样消除了浏览器前进或后退造成的重复提交问题。

2、过程上–过滤重复动作

# PRG 模式(前端)

PRG 模式即 POST-REDIRECT-GET。当用户进行表单提交时,会重定向到另外一个提交成功页面,而不是停留在原先的表单页面。这样就避免了用户刷新导致重复提交。同时防止了通过浏览器按钮前进/后退导致表单重复提交。 是一种比较常见的前端防重策略。

# 分布式锁

利用 Redis 记录当前处理的业务标识,当检测到没有此任务在处理中,就进入处理,否则判为重复请求,可做过滤处理。

订单发起支付时,支付系统先去 Redis 中查询是否存在该订单号的 Key。若不存在,则在 Redis 中新增这个Key。

接着查询订单是否已支付,若未支付,则支付完成后删除该订单的Key。

通过Redis做分布式锁,只有当一个请求执行完成后,才能执行下个请求。

# Token 机制实现

通过 Token 机制实现接口的幂等性,这是一种比较通用性的实现方法。

具体流程步骤:

  1. 客户端先发送一个请求去获取 Token,服务端会生成一个全局唯一的 ID 作为 Token 保存在 Redis 中,同时把这个 ID 返回给客户端;
  2. 客户端第二次调用业务请求的时候必须携带这个 Token;
  3. 服务端校验这个 Token,如果校验成功,则执行业务,并删除 Redis 中的 Token;
  4. 如果校验失败,说明 Redis 中已经没有对应的 Token,表示重复操作,直接返回指定的结果给客户端。

# 防重表

对于防止数据重复提交,还有一种解决方案就是通过防重表实现。防重表的实现思路也非常简单。首先创建一张表 作为防重表,同时在该表中建立一个或多个字段的唯一索引作为防重字段,用于保证并发情况下,数据只有一条。 在向业务表中插入数据之前先向防重表插入,如果插入失败则表示是重复数据。

  • 对于防重表的解决方案,可能有人会说为什么不使用悲观锁。悲观锁在使用的过程中也是会发生死锁的。悲观锁是 通过锁表的方式实现的。 假设现在一个用户 A 访问表 A(锁住了表 A),然后试图访问表 B;
  • 另一个用户 B 访问表 B(锁住了表 B),然后试图访问表 A。 这时对于用户 A 来说,由于表 B 已经被用户 B 锁住了,所以用户 A 必须等到用户 B 释放表 B 才能访问。 同时对于用户 B 来说,由于表 A 已经被用户 A 锁住了,所以用户 B 必须等到用户 A 释放表 A 才 能访问。此时死锁就已经产生了。

3、结果上–解决重复写风险

常见的方式有:悲观锁(for update)、乐观锁、唯一约束。

# 悲观锁

假设每一次拿数据,都有认为会被修改,所以给数据库的行或表上锁。

当数据库执行 select for update 时会获取被 select 中的数据行的行锁,因此其他并发执行的 select for update 如果试图选中同一行则会发生排斥(需要等待行锁被释放),因此达到锁的效果。

# 乐观锁

就是很乐观,每次去拿数据的时候都认为别人不会修改。更新时如果 version 变化了,更新不会成功。

缺点:就是在操作业务前,需要先查询出当前的 version 版本。

另外,还存在一种:状态机控制

例如:支付状态流转流程:待支付 -> 支付中 -> 已支付

具有一定要的前置要求的,严格来讲,也属于乐观锁的一种。

# 唯一约束

这种实现方式是利用 mysql 唯一索引的特性。

  • 因为表中某个字段带有唯一索引,如果插入成功,证明表中没有这次请求的信息,则执行后续的业务逻辑;
  • 如果插入失败,则代表已经执行过当前请求,直接返回。

猜你喜欢

转载自blog.csdn.net/weixin_45187434/article/details/129233903