什么是幂等性
HTTP 幂等方法,是指无论调用多少次都不会有不同结果的 HTTP 方法。不管你调用一次,还是调用一百次,一千次,结果都是相同的。
HTTP GET 方法,用于获取资源,不管调用多少次接口,结果都不会改变,所以是幂等的。
只是查询数据,不会影响到资源的变化,因此我们认为它幂等。
值得注意,幂等性指的是作用于结果而非资源本身。怎么理解呢?例如,这个 HTTP GET 方法可能会每次得到不同的返回内容,但并不影响资源。
可能你会问有这种情况么?当然有咯。例如,我们有一个接口获取当前时间,我们就应该设计成
它本身不会对资源本身产生影响,因此满足幂等性。
HTTP POST 方法是一个非幂等方法,因为调用多次,都将产生新的资源。
因为它会对资源本身产生影响,每次调用都会有新的资源产生,因此不满足幂等性。
HTTP PUT 方法是不是幂等的呢?我们来看下
因为它直接把实体部分的数据替换到服务器的资源,我们多次调用它,只会产生一次影响,但是有相同结果的 HTTP 方法,所以满足幂等性。
HTTP PATCH 方法是非幂等的。HTTP POST 方法和 HTTP PUT 方法可能比较好理解,但是 HTTP PATCH 方法只是更新部分资源,怎么是非幂等的呢?
因为,PATCH 提供的实体则需要根据程序或其它协议的定义,解析后在服务器上执行,以此来修改服务器上的资源。换句话说,PATCH 请求是会执行某个程序的,如果重复提交,程序可能执行多次,对服务器上的资源就可能造成额外的影响,这就可以解释它为什么是非幂等的了。
可能你还不能理解这点。我们举个例子
此时,我们服务端对方法的处理是,当调用一次方法,更新部分字段,将这条 ticket 记录的操作记录加一,这次,每次调用的资源是不是变了呢,所以它是有可能是非幂等的操作。
HTTP DELETE 方法用于删除资源,会将资源删除。
调用一次和多次对资源产生影响是相同的,所以也满足幂等性。
也许,你会想起一个面试题。 HTTP 请求的 GET 与 POST 方式有什么区别? 你可能会回答到:GET 方式通过 URL 提交数据,数据在 URL 中可以看到;POST 方式,数据放置在 HTML HEADER 内提交。但是,我们现在从 RESTful 的资源角度来看待问题,HTTP GET 方法是幂等的,所以它适合作为查询操作,HTTP POST 方法是非幂等的,所以用来表示新增操作。
但是,也有例外,我们有的时候可能需要把查询方法改造成 HTTP POST 方法。比如,超长(1k)的 GET URL 使用 POST 方法来替代,因为 GET 受到 URL 长度的限制。虽然,它不符合幂等性,但是它是一种折中的方案。
对于 HTTP POST 方法和 HTTP PUT 方法,我们一般的理解是 POST 表示创建资源,PUT 表示更新资源。当然,这个是正确的理解。
但是,实际上,两个方法都用于创建资源,更为本质的差别是在幂等性。HTTP POST 方法是非幂等,所以用来表示创建资源,HTTP PUT 方法是幂等的,因此表示更新资源更加贴切。
此时,你看会有另外一个问题。HTTP PUT 方法和 HTTP PATCH 方法,都是用来表述更新资源,它们之间有什么区别呢?我们一般的理解是 PUT 表示更新全部资源,PATCH 表示更新部分资源。首先,这个是我们遵守的第一准则。根据上面的描述,PATCH 方法是非幂等的,因此我们在设计我们服务端的 RESTful API 的时候,也需要考虑。如果,我们想要明确的告诉调用者我们的资源是幂等的,我的设计更倾向于使用 HTTP PUT 方法。
一次和多次请求某一个资源对于资源本身应该具有同样的结果(网络超时等问题除外)。
也就是说,其任意多次执行对资源本身所产生的影响均与一次执行的影响相同。换种说法,就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。
举个最简单的例子,那就是支付,用户购买商品使用支付,此时多次触发支付,只会支付一次,而不会多扣钱。
1. 幂等需要关注的几个重点:
(1)幂等不仅仅只是一次(或多次)请求对资源没有副作用。
(2)幂等还包括第一次请求的时候对资源产生了副作用,但是以后的多次请求都不会再对资源产生副作用。
(3)幂等关注的是以后的多次请求是否对资源产生的副作用,而不关注结果。
(4)网络超时等问题,不是幂等的讨论范围。
幂等性是系统服务对外一种承诺(而不是实现),承诺只要调用接口成功,外部多次调用对系统的影响是一致的。声明为幂等的服务会认为外部调用失败是常态,并且失败之后必然会有重试。
2. 幂等与防重的区别:
(1)重复提交是在 第一次请求已经成功的情况下 ,人为的进行多次操作,导致不满足幂等要求的服务多次改变状态。
(2)幂等更多使用的情况是 第一次请求不知道结果(比如超时)或者失败的异常情况下 ,发起多次请求,目的是多次确认第一次请求成功,却不会因多次请求而出现多次的状态变化。( 重点重点重点!!! )
业务开发中,经常会遇到重复提交的情况,无论是由于网络问题无法收到请求结果而重新发起请求,或是前端的操作抖动而造成重复提交情况。
在交易系统,支付系统这种重复提交造成的问题有尤其明显,比如:
(1)用户在APP上连续点击了多次提交订单,后台应该只产生一个订单
(2)向支付宝发起支付请求,由于网络问题或系统BUG重发,支付宝应该只扣一次钱。 很显然,声明幂等的服务认为,外部调用者会存在多次调用的情况,为了防止外部多次调用对系统数据状态的发生多次改变,将服务设计成幂等。
以SQL为例,有下面三种场景,只有第三种场景需要开发人员使用其他策略保证幂等性:
幂等可以使得客户端逻辑处理变得简单,但是却以服务逻辑变得复杂为代价。 满足幂等服务的需要在逻辑中至少包含两点:
(1)首先去查询上一次的执行状态,如果没有则认为是第一次请求。
(2)在服务改变状态的业务逻辑前,保证防重复提交的逻辑。
幂等是为了简化客户端逻辑处理,却增加了服务提供者的逻辑和成本,是否有必要,需要根据具体场景具体分析,因此除了业务上的特殊要求外,尽量不提供幂等的接口。
(1)增加了额外控制幂等的业务逻辑,复杂化了业务功能;
(2)把并行执行的功能改为串行执行,降低了执行效率。
幂等需要通过唯一的业务单号来保证。也就是说相同的业务单号,认为是同一笔业务。使用这个唯一的业务单号来确保,后面多次的相同的业务单号的处理逻辑和执行效果是一致的。
下面以支付为例, 在不考虑并发的情况下,实现幂等很简单:
① 先查询一下订单是否已经支付过;
② 如果已经支付过,则返回支付成功;如果没有支付,进行支付流程,修改订单状态为‘已支付’。
上述的保证幂等方案是分成两步的,第②步依赖第①步的查询结果,无法保证原子性的。在高并发下就会出现下面的情况:第二次请求在第一次请求第②步订单状态还没有修改为‘已支付状态’的情况下到来。
既然得出了这个结论,余下的问题也就变得简单: 把查询和变更状态操作加锁,将并行操作改为串行操作。
(1)乐观锁
如果只是更新已有的数据,没有必要对业务进行加锁,设计表结构时使用乐观锁,一般通过version来做乐观锁,这样既能保证执行效率,又能保证幂等。
例如: UPDATe tab1 SET col1=1,version=version+1 WHERe version=#version# 不过,乐观锁存在失效的情况,就是常说的ABA问题,不过如果version版本一直是自增的就不会出现ABA的情况。
(2)悲观锁
select * from xx for update
悲观锁和乐观锁的区别:
使用订单号orderNo做为去重表的唯一索引,每次请求都根据订单号向去重表中插入一条数据。
第一次请求查询订单支付状态,当然订单没有支付,进行支付操作,无论成功与否,执行完后更新订单状态为成功或失败,删除去重表中的数据。
后续的订单因为表中唯一索引而插入失败,则返回操作失败,直到第一次的请求完成(成功或失败)。可以看出防重表作用是加锁的功能。
订单发起支付请求,支付系统会去Redis缓存中查询是否存在该订单号的Key,如果不存在,则向Redis增加Key为订单号。
查询订单支付已经支付,如果没有则进行支付,支付完成后删除该订单号的Key。通过Redis做到了分布式锁,只有这次订单订单支付请求完成,下次请求才能进来。
相比去重表,将放并发做到了缓存中,较为高效。思路相同,同一时间只能完成一次支付请求。
这种方式分成两个阶段:申请token阶段和支付阶段。
第一阶段,在进入到提交订单页面之前,需要订单系统根据用户信息向支付系统发起一次申请token的请求,支付系统将token保存到Redis缓存中,为第二阶段支付使用。
第二阶段,订单系统拿着申请到的token发起支付请求,支付系统会检查Redis中是否存在该token,如果存在,表示第一次发起支付请求,删除缓存中token后开始支付逻辑处理;如果缓存中不存在,表示非法请求。
实际上这里的token是一个信物,支付系统根据token确认是否是非法请求。不足是需要系统间交互两次,流程较上述方法复杂。
把订单的支付请求都快速地接下来,一个快速接单的缓冲管道。后续使用异步任务处理管道中的数据,过滤掉重复的待支付订单。优点是同步转异步,高吞吐。不足是不能及时地返回支付结果,需要后续监听支付结果的异步返回。
在微服务架构下,我们在完成一个订单流程时经常遇到下面的场景:
为了解决以上问题,就需要保证接口的幂等性 ,接口的幂等性实际上就是接口可重复调用,在调用方多次调用的情况下,接口最终得到的结果是一致的。 有些接口可以天然的实现幂等性 ,比如查询接口,对于查询来说,你查询一次和两次,对于系统来说,没有任何影响,查出的结果也是一样。
除了查询功能具有天然的幂等性之外,增加、更新、删除都要保证幂等性。 那么如何来保证幂等性呢?
多版本并发控制,该策略主要使用update with condition(更新带条件来防止)来保证多次外部请求调用对系统的影响是一致的。在系统设计的过程中,合理的使用乐观锁,通过version或者updateTime(timestamp)等其他条件,来做乐观锁的判断条件,这样保证更新操作即使在并发的情况下,也不会有太大的问题。例如
在更新的过程中利用version来防止,其他操作对对象的并发更新,导致更新丢失。为了避免失败,通常需要一定的重试机制。
在插入数据的时候,插入去重表,利用数据库的唯一索引特性,保证唯一的逻辑。
这种方法适用于在业务中有唯一标的插入场景中,比如在以上的支付场景中,如果一个订单只会支付一次,所以订单ID可以作为唯一标识。这时,我们就可以建一张去重表,并且把唯一标识作为唯一索引,在我们实现时,把创建支付单据和写入去去重表,放在一个事务中,如果重复创建,数据库会抛出唯一约束异常,操作就会回滚。
select for update,整个执行过程中锁定该订单对应的记录。注意:这种在DB读大于写的情况下尽量少用。
并发不高的后台系统,或者一些任务JOB,为了支持幂等,支持重复执行,简单的处理方法是,先查询下一些关键数据,判断是否已经执行过,在进行业务处理,就可以了。注意:核心高并发流程不要用这种方法。
在设计单据相关的业务,或者是任务相关的业务,肯定会涉及到状态机,就是业务单据上面有个状态,状态在不同的情况下会发生变更,一般情况下存在有限状态机,这时候,如果状态机已经处于下一个状态,这时候来了一个上一个状态的变更,理论上是不能够变更的,这样的话,保证了有限状态机的幂等。
这种方法适合在有状态机流转的情况下,比如就会订单的创建和付款,订单的付款肯定是在之前,这时我们可以通过在设计状态字段时,使用int类型,并且通过值类型的大小来做幂等,比如订单的创建为0,付款成功为100。付款失败为99
在做状态机更新时,我们就这可以这样控制
业务要求:页面的数据只能被点击提交一次
发生原因:由于重复点击或者网络重发,或者nginx重发等情况会导致数据被重复提交
解决办法:
处理流程:
token特点:要申请,一次有效性,可以限流
如银联提供的付款接口:需要接入商户提交付款请求时附带:source来源,seq序列号。source+seq在数据库里面做唯一索引,防止多次付款,(并发时,只能处理一个请求)
总结: 幂等性应该是合格程序员的一个基因,在设计系统时,是首要考虑的问题,尤其是在像支付宝,银行,互联网金融公司等涉及的都是钱的系统,既要高效,数据也要准确,所以不能出现多扣款,多打款等问题,这样会很难处理,用户体验也不好 。
如果使用全局唯一ID,就是根据业务的操作和内容生成一个全局ID,在执行操作前先根据这个全局唯一ID是否存在,来判断这个操作是否已经执行。如果不存在则把全局ID,存储到存储系统中,比如数据库、redis等。如果存在则表示该方法已经执行。
从工程的角度来说,使用全局ID做幂等可以作为一个业务的基础的微服务存在,在很多的微服务中都会用到这样的服务,在每个微服务中都完成这样的功能,会存在工作量重复。另外打造一个高可靠的幂等服务还需要考虑很多问题,比如一台机器虽然把全局ID先写入了存储,但是在写入之后挂了,这就需要引入全局ID的超时机制。
使用全局唯一ID是一个通用方案,可以支持插入、更新、删除业务操作。但是这个方案看起来很美但是实现起来比较麻烦,下面的方案适用于特定的场景,但是实现起来比较简单。
从定义上看,HTTP方法的幂等性是指一次和多次请求某一个资源应该具有同样的副作用。幂等性属于语义范畴,正如编译器只能帮助检查语法错误一样,HTTP规范也没有办法通过消息格式等语法手段来定义它,这可能是它不太受到重视的原因之一。但实际上,幂等性是分布式系统设计中十分重要的概念,而HTTP的分布式本质也决定了它在HTTP中具有重要地位。
而RESTFul API中的幂等性是指调用某个接口1次或N次,对所访问的资源产生的影响结果都是相同的,需要特别注意的是:这里幂等性指的是对资源产生的影响结果,而非调用HTTP请求的返回结果。
举个例子,RESTFul API中的GET方法是查询资源信息,不会对资源产生影响,所以它是符合幂等性的,但是每次调用GET方法返回的结果有可能不同(可能资源的某个属性在调用GET方法之前已经被其他方法修改了,例如在多次访问期间,接口返回对象的update_time字段被别的请求更新,但GET本身是幂等性的)。
实际上,在分布式架构中的API幂等性不仅仅针对RESTFul接口,而是对所有类型的接口适用,目的是为了确保调用1次或N次接口时对资源的影响结果都是相同的。
接口的幂等性确保了无论调用1次还是N次对资源的影响都是相同的,这在某些场合下是非常有用的。
举个业务场景:用户下单,银行从用户账户扣款。
有这样一个接口方法:pay(long account, int money),该方法用于银行卡扣款支付,参数account为账户ID,money为需要扣除的钱数。
当用户从网页上点击支付按钮时,在该方法的实现逻辑中需要从指定账户中扣除对应的商品价钱。如果支付操作已经成功执行,但是响应消息因为某种原因未能及时返回给客户端,这时候给用户的体验是可能是未支付成功,如果此时再次点击支付按钮,那么将再一次执行该方法,结果可能会导致用户只买了一件商品却扣减了双份的钱,这当然是不合理的。整个流程如下图所示:
当然,就上述例子的场景,为了避免用户重复支付,是可以通过别的方式解决的,比如:分布式事务;或者根据支付状态提示给予用户进行提示等等。
但是,如果引入了分布式事务,那么将带来实现上的复杂性,而且会影响到接口性能;而采取提示信息的方式并不能百分之百确保用户不会重复支付,存在一定的风险。
而如果接口符合幂等性,即:对同一个订单无论是执行一次支付还是多次支付,在服务端都确保只会扣一次款,那么既不需要引入分布式事务的复杂性,也能从根本上解决重复支付的问题,这也就是接口符合幂等性的价值所在。
总而言之,接口符合幂等性在可以降低系统实现的复杂性,并能保证资源状态的一致性。
RESTFul风格的接口设计本质上使用的是HTTP协议的请求方法,因此,RESTFul接口方法的幂等性指的就是HTTP方法的幂等性。
常用的HTTP方法有:
那么,这些HTTP方法的幂等性又是什么样的呢?除了幂等性之外,HTTP方法的安全性是指不对资源产生修改。
如下是常用HTTP方法的幂等性和安全性总结:
从上述表格中可以看出,HTTP方法的幂等性和安全性并不是同一个概念,如下是对个各个方法的幂等性和安全性解释:
设计幂等性接口的关键在于保证接口不论是被调用1次还是N次,它对资源所产生的影响都是相同的。
从上述HTTP方法的幂等性总结中可以得知,HTTP协议的POST和PATCH方法都不是幂等性的(但是我们却经常会在RESTFul接口中使用到它们),那是否就意味中无法将POST和PATCH方法设计为幂等性接口了呢?答案显然是否定的。在上述例子中,可以将订单ID也作为方法参数之一,如:pay(long account, int money, long order),这样在服务端确保一个订单只会被支付一次(订单号是全局唯一的),那么无论该方法被调用1次还是N次结果都是一样的,也就保证了接口的幂等性。当然,在哪些没有订单号的场景,可以为接口操作生成一个全局唯一的处理号ID,并把该处理号ID作为方法参数之一,这样在服务端确保一个处理号ID只会被执行一次就保证了接口的幂等性。
符合幂等性的接口调用流程描述如下图所示:
虽然说设计符合幂等性的接口在某些场合可以降低系统的复杂性(如:可以不用引入分布式事务),但是并非在所有场合的问题都能通过幂等性接口解决,在必要的时候依然需要引入分布式事务处理这样的框架。我们不要也不能把接口幂等性作为万能的解决办法,但是,我们在设计接口时尽量考虑符合幂等性处理是非常有价值的。
【参考】
听到幂等性这个词时,是不是内心一阵恐慌?What?幂等性是个什么鬼?测过相关支付的业务,但没听过幂等性啊?别方,其实就是数据一致性和事务完整性。
什么是幂等
数学上的定义:f(f(x))=f(x)。x被函数f作用一次和作用无限次的结果是一样的。幂等性应用在软件系统中,可以把它简单定义为: 某个函数或者某个接口使用相同参数调用一次或者无限次****,其造成的后果是一致的****, 在实际应用中一般针对于接口进行幂等性设计。例如:
为什么要做幂等
如上文问题一中示例所述,可知,如果支付相关接口不保证幂等性。可能会造成很严重的后果,例如:
所以说保证接口的幂等性是非常重要的。
如何保证幂等性
幂等需要 通过唯一的业务单号 来保证。也就是说相同的业务单号,认为是同一笔业务。使用这个唯一的业务单号来确保,后面多次的相同的业务单号的处理逻辑和执行效果是一致的。 下面以支付为例,在不考虑并发的情况下,实现幂等很简单:①先查询一下订单是否已经支付过;②如果已经支付过,则返回支付成功;如果没有支付,进行支付流程,修改订单状态为‘已支付’。
防重复提交策略
上述的保证幂等方案是分成两步的,第②步依赖第①步的查询结果,无法保证原子性的。在高并发下就会出现下面的情况:第二次请求在第一次请求第②步订单状态还没有修改为‘已支付状态’的情况下到来。既然得出了这个结论,余下的问题也就变得简单:把查询和变更状态操作加锁,将并行操作改为串行操作。
乐观锁
如果只是更新已有的数据,没有必要对业务进行加锁,设计表结构时使用乐观锁,一般通过version来做乐观锁,这样既能保证执行效率,又能保证幂等。例如: UPDATE tab1 SET col1=1,version=version+1 WHERe version=#version# 。但是,乐观锁存在失效的情况,就是常说的ABA问题。如果version版本一直是自增的就不会出现ABA的情况。
防重表
使用订单号orderNo做为去重表的唯一索引,每次请求都根据订单号向去重表中插入一条数据。第一次请求查询订单支付状态,当然订单没有支付,进行支付操作,无论成功与否,执行完后更新订单状态为成功或失败,删除去重表中的数据。后续的订单因为表中唯一索引而插入失败,则返回操作失败,直到第一次的请求完成(成功或失败)。可以看出防重表作用是加锁的功能。
分布式锁
这里使用的防重表可以使用分布式锁代替,比如Redis。订单发起支付请求,支付系统会去Redis缓存中查询是否存在该订单号的Key,如果不存在,则向Redis增加Key为订单号。查询订单支付已经支付,如果没有则进行支付,支付完成后删除该订单号的Key。通过Redis做到了分布式锁,只有这次订单支付请求完成,下次请求才能进来。相比去重表,将并发做到了缓存中,较为高效。思路相同,同一时间只能完成一次支付请求。
token令牌
这种方式分成两个阶段:申请token阶段和支付阶段。 第一阶段,在进入到提交订单页面之前,需要订单系统根据用户信息向支付系统发起一次申请token的请求,支付系统将token保存到Redis缓存中,为第二阶段支付使用。 第二阶段,订单系统拿着申请到的token发起支付请求,支付系统会检查Redis中是否存在该token,如果存在,表示第一次发起支付请求,删除缓存中token后开始支付逻辑处理;如果缓存中不存在,表示非法请求。 实际上这里的token是一个信物,支付系统根据token确认操作权限。缺点是需要系统间交互两次,流程较上述方法复杂一些。
支付缓冲区
把订单的支付请求都快速地接下来,一个快速接单的缓冲管道。后续使用异步任务处理管道中的数据,过滤掉重复的待支付订单。优点是同步转异步,高吞吐量。缺点是不能及时地返回支付结果,需要后续监听支付结果的异步返回。
幂等性 :就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。
接口幂等性不只是分布式事务方便回滚的保证,同时也是SOA服务实现中摒除重复消息的保证。
那就是支付,用户购买商品使用约支付,支付扣款成功,但是返回结果的时候网络异常,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户查询余额返发现多扣钱了,流水记录也变成了两条。
方法一、 单次支付请求,也就是直接支付了,不需要额外的数据库操作了,这个时候发起异步请求创建一个唯一的ticketId,就是门票,这张门票只能使用一次就作废,具体步骤如下:
如果步骤4通信失败,用户再次发起请求,那么最终结果还是一样的
方法二、 分布式环境下各个服务相互调用
这边就要举例我们的系统了,我们支付的时候先要扣款,然后更新订单,这个地方就涉及到了订单服务以及支付服务了。
用户调用支付,扣款成功后,更新对应订单状态,然后再保存流水。
而在这个地方就没必要使用门票ticketId了,因为会比较闲的麻烦
(支付状态:未支付,已支付)
步骤:
1、查询订单支付状态
2、如果已经支付,直接返回结果
3、如果未支付,则支付扣款并且保存流水
4、返回支付结果
如果步骤4通信失败,用户再次发起请求,那么最终结果还是一样的
对于做过支付的朋友,幂等,也可以称之为冲正,保证客户端与服务端的交易一致性,避免多次扣款。
等幂性简单点说就是一次请求和多次请求,资源的状态是一样。比如GET和HEAD,不论你请求多少次,资源还是在那里。请注意,DELETE和PUT也是等幂的,以为对同一个资源删除一次或者多次,结果是一样的,就是资源被删除了,不存在了。为什么说PUT也是等幂的?当你PUT一个新资源的时候,资源被创建,再次PUT这个URI的时候,资源还是没变。当你PUT一个存在的资源时,更新了资源,再次PUT的时候,还是更新成这个样子。在PUT更新的时候,不能做相对的更新(依赖资源现在的状态),比如每次对一个数加1,这样资源状态就会变化。应该每次更新成某个数,比如把某个数变成4,则无论多少次PUT,值都是4,这样就是等幂了。
我们设计Restful WS的时候,GET,HEAD, PUT, DELETE一定要设计成等幂的。由于网络是不可靠的,安全性和等幂性就显得特别重要。如果一次请求,服务器收到处理以后,客户端没有收到相应,客户端会再次请求,如果没有等幂性保障,就会发生意想不到的问题。
POST是不安全也不等幂的,还是拿weblog的例子,如果两次POST相同的博文,则会产生两个资源,URI可能是这样/weblogs/myweblog/entries/1和/weblogs/myweblog/entries/2,尽管他们的内容是一摸一样的。