Implements Idempotent

实现幂等

一、什么是幂等

一个HTTP方法是幂等的,指的是同样的请求被执行一次与连续执行多次的效果是一样的,服务器的状态也是一样的。换句话说就是,幂等方法不应该具有副作用(统计用途除外)。在正确实现的条件下, GET , HEAD , PUT 和 DELETE  等方法都是幂等的,而  POST  方法不是。所有的 safe 方法也都是幂等的。
幂等性只与后端服务器的实际状态有关,而每一次请求接收到的状态码不一定相同。例如,第一次调用 DELETE 方法有可能返回 200 ,但是后续的请求可能会返回 404 。 DELETE 的言外之意是,开发者不应该使用 DELETE 法实现具有删除最后条目功能的 RESTful API。

二、如何定义无副作用

  1. 严格的。如果一个请求,不管其业务状态是成功还是失败,都最多执行一次。实现可见源码中的strict包。
  2. 宽松的。这种情况只认为成功的会产生影响,也就是transaction commit。请求失败时,服务器rollback。

三、幂等的场景

幂等,一般在以下场景中会遇到:

  1. 前端重复提交
  2. 接口的攻击或测试(和1类似)
  3. 接口超时重试
  4. 消息队列消息的重复消费

3.1. 防重

幂等与防重初看起来是相同的,但幂等在功能方面优于防重,与业务更相关。 幂等可能会要求存在重复的请求时返回与首次请求相同的响应,同时严格的防重使客户端处理超时变得复杂。 防重在性能优于幂等,并且通常在调用链的“偏前端”完成。在两者结合时,可以实现短时防重、只防成功响应等,来减小只实现幂等代价。

四、设计实现

本文以较为通用的幂等表方案进行说明。 对于查询以及天然幂等这样没有副作用的操作(也就是第一节中描述的safe方法),一般不用做额外的工作。
对于页面的重复提交,前端页面可以以跳转、按钮置灰的方式在重复发生前阻止重复。
当然,前端的实现,一般可以被绕过直接请求后端接口,所以后端在需要的地方必须要实现幂等。

唯一性

为了识别不同时间发送的相同请求,首先需要定义的是什么是重复,唯一性从何而来。
为了让我们的唯一性能更通用一些,我们可以:

客户端友好

关键点

  1. 每一个需要幂等的请求,都应落库(同一consumerId+requestId的只需要落1份),以阻止副作用的发生(数据库唯一索引提供互斥)。在宽松的情况下,失败的不需要落库。
  2. 成功请求记录落库,与业务数据写库,需保持原子性(数据库事务)。特别是在宽松无副作用的条件下,原子性保证了失败的请求,在解决失败的原因后,可以再次成功。而严格无副作用的情况下,原子性是实现客户端友好所必需的。
  3. 对于相同请求的两个并发,因事务并发的原因而可能会造成request落库冲突,这时可以直接失败其中一个,并直接丢弃响应; 鉴于服务端简单的丢弃可能造成资源的占用,可以与客户端约定【处理中】的一种响应,客户端接收到这种响应重试或直接丢弃。如果不用【处理中】,则可以分离提示码/错误码到不同的响应字段。

这里还有个小插曲,在实现严格无副作用幂等的时候,因为服务端需要在业务逻辑失败返回客户端前将失败的结果落库,而失败的业务逻辑通常需要rollback,因而又有两种不同的事务实现方式。
使用Propagation.REQUIRES_NEW或Propagation.NESTED。
使用Propagation.NESTED实现相对简单一些,因为nested的事务与外层事务是同一事务,异常处理起来相对简单。
而使用Propagation.REQUIRES_NEW的话,因为是两个事务,所以基本上需要当作分布式系统的情况来考虑各种异常。因此单机还是尽量用Propagation.NESTED。
业务处理过程中存在未知异常时(例如空指针)或者某个切面异常,在Propagation.REQUIRES_NEW实现中,由于外层无法判断new transaction的提交与否(因为数据库提交后也可能发生异常),可以捕获后返回【处理中】。

如果是用MySQL实现,需要注意MySQL的隔离级别、并发控制( MySQL并发实验 )。

  1. 客户端不友好的实现: 如果是用MySQL insert(consumerId+requestId)的话,需要区分键值重复异常和获取锁超时异常/其它异常。
    键值重复异常,返回重复相关返回码,对于其它异常,不管是服务端还是客户端,都对事务的状态未明,对于这种情况,服务端返回未明错误,或者还是像第1种方式那样直接丢弃或者返回【处理中】。
    对比客户端友好的实现,少了服务端需要将返回包落库的微小代价,但多了客户端服务端各种异常返回的处理,且无法一次性返回除【返回码】以外的信息,耦合了服务端和客户端。
    对于分布式的系统,通常客户端也作为服务对外暴露API,而较重的客户端处理,容易造成不同客户端的实现缺陷,因此个人倾向于不使用这种。

中间件

按照中间件的可靠性等方面,可以有:

  1. 本项目采用的MySQL insert(consumerId+requestId) + 唯一索引约束
  2. Redis set相关命令或lua + key唯一约束

由于Redis与MySQL相比可靠性较低,且Redis一般无法与MySQL保持同一事务,在一些不太重要的地方或者失败可接受的情况下可以使用,这里没再深入了。

查询接口

查询是被滥用最严重的接口了。在查询仅用于展示数据,而不是控制流程或者其它目的的情况下,查询一般工作得很好。
一旦查询的结果影响了系统后面动作的时候,就会发生问题。此时查询的含义已经变了,变得不再单纯,它已经不能被称作是查询接口了。
查询的数据一旦脱离了其专门领域的掌控,就任由其它实体摆布。这也是贫血模型导致的失忆症的情况。这时可以运用TDA原则。
在客户端多次请求无果的情况下,客户端可能希望查询后进行回退补偿或者其它操作(其实不查询直接回退补偿也是可以的)。 这时的查询,也已经不是单纯的查询,而是查询for补偿,需在在查无结果的情况下,插入失败的请求记录,以此来确保返回数据的准确。

跳转