从数学上讲,对于函数f,当其满足如下条件时:
我们则称函数f是幂等的。
其本质的含义。是指一个方法,无论执行多少次,和只执行一次的效果是一样的。在计算机领域,当一个方法或操作具有这样的性质时,我们就说这个方法或操作是幂等的。其基本含义如下图所示:
在不同的实际场景或上下文中,“幂等”可能指代不同的含义。需要分辨清楚,以免歧义。比如,有时幂等会被用来指代业务逻辑层面的数据唯一性校验,并称之为业务幂等。这种用法,在充分解释之下可以理解,但是严格来讲并不严格。业务逻辑层面的数据唯一性校验本质上属于数据一致性检查。而非幂等校验。
幂等指副作用只发生一次,纯粹指没有副作用。一般情况下,多数有状态服务(如一切资源管理型服务),都是有副作用的,于是就不是纯粹的。而如翻译服务,它的所有输出(不计日志),就是它返回的响应体,即没有副作用,那可以说这个服务是纯粹的。
幂等用来形容请求方的申请或意图,唯一则用来指代响应方的许可及约束。 比如:车牌管理服务,服务调用方,可以用不同的幂等键来请求同一个车牌(尽管在此场景下,应该用同一个幂等键);而服务提供方可以以车牌重复为由,认定第二个请求,因违反唯一约束而不合法(尽管此时幂等校验通过)。
服务提供方支持幂等,主要好处是降低服务对接成本,同时提高服务的鲁棒性和容错性,在分布式系统中,提供最终一致性保证。
从接口的调用方角度,在一次请求失败的情况下,如果接口本身幂等,调用方可以安全地进行重试。如果接口本身没有幂等性,则调用方势必需要先查询一下当前状态是否需要重试,在需要的情况下才重试。后面虽然可行,却存在两个问题:
在RFC7231[1]中,是这样定义幂等的:
A request method is considered "idempotent" if the intended effect on
the server of multiple identical requests with that method is the
same as the effect for a single such request. Of the request methods
defined by this specification, PUT, DELETE, and safe request methods
are idempotent.
同时,也指出了很重要的一点:
Like the definition of safe, the idempotent property only applies to
what has been requested by the user; a server is free to log each
request separately, retain a revision control history, or implement
other non-idempotent side effects for each idempotent request.
即,幂等与否,是站在用户的角度看的。服务器本身记日志、产生新监控指标,虽然都是额外的副作用,但是对用户是无感的。所以依然视为幂等。
最后,RFC7231也在具体实现上给出一些建议:
Idempotent methods are distinguished because the request can be
repeated automatically if a communication failure occurs before the
client is able to read the server's response. For example, if a
client sends a PUT request and the underlying connection is closed
before any response is received, then the client can establish a new
connection and retry the idempotent request. It knows that repeating
the request will have the same intended effect, even if the original
request succeeded, though the response might differ.
这里需要强调的一点是:尽管RFC7231对HTTP Method的幂等性做了规范要求,但是这并不意味着,使用了特定HTTP Method的接口,就自然具有了幂等性。幂等的保证,依赖正确地理解及实现幂等性。
API本身要做到幂等,需要特定机制来实现。常见的幂等机制有如下几种:
由API的使用方负责生成,由API的提供方负责校验的,与每个独立请求对应的唯一ID。常见有代码及文档介绍,可以用UUID来生成幂等键[2],却没有强调,最重要的是,API调用方需要保证,在重试同一个请求时,幂等键必须不变(即使在服务器宕机后再重试也要能保证)。 否则根本起不到幂等的做用。而这一点,往往需要把请求先行以某种形式记住才能做到。这里“记住”的形式如:持久化或同步到另一台服务器。
由客户端生的叫幂等键,由服务器端生成的是幂等指纹[3]。但是幂等指纹一是由服务器端,通过对请求体本身的数据进行摘要,来推断生成的唯一标识,所以“幂等指纹”一般并不能单独用来做请求的幂等。而更多的是,在客户端对于自身请求的唯一性不确定的情况下,由服务器端进行验证的一种机制。
有时请求体的语义本身,就可以做到幂等。考虑对一个现有余额900元的账号进行充值100元。有两种方式:
方案一:
{
"account_id": "acct-123456"
"set_balance": "1000"
}
或方案二:
{
"account_id": "acct-123456"
"add_balance": "100"
}
方案一,从语义角度就是幂等的。无论执行多少次,结果都是一样的。方案二,多次执行结果会不同,于是需要有幂等键来保证幂等性。
方案一只能用于单机的本地应用或每个用户有独立数据空间的在线应用。对于数据共享或存在交互的在线应用中,:方案一会要求所有的查询,都要加意向更新锁,读与读之间都相互阻塞,而且所有的更新请求全部线性顺序执行的情况下,才能保证正确其正确性。在分布式微服务架构体系下,是完全不可行的方案。
方案二虽然也对执行顺序敏感,但是无论执行顺序如何,只要没有重复执行(做好幂等),最终结果都是一样的。对于追求最终一致性的服务来讲,会是更合理的设计方案。
本质上,原不支持幂等的CRUD类型的API,提供了具有如下语义的接口:
而具有幂等待语义的接口则是:
这样,从接口的语义上就已经做到了幂等。但是这种接口设计风格,在某些的架构风格上并不适用。比如,CQRS的架构下,会要求一个操作,要么是查询,要么是命令(数据变更),不能都是。
2020年12月,Paypal起草了一分关于Idempotent实现细节的IETF草案[3:1],对HTTP Header和双方责任进行了界定。但是在这份文档仍然是内部草案(Internet-Draft),其中的具体要求,可能并不一定合适。比如在处理并发请求时,返回409的合理性,在文中并没有得到充分的解释(个人猜想,如果返回200可能会影响到负载均衡器的性能)。但是草案中的诸多建议还是非常可取的。
Google的Standard Payments API[4]中,也详细描述了API幂等的实现。其中强调到:
Error results must never be cached or idempotently returned. And, automated retries for errors must use an exponential backoff strategy.
意思是,不要缓存错误。
假设第一次调用响应失败(但是实际上接收方已经完成了请求),调用方重试,重试时必须使用含义完全一样的请求体,接收方在检测到这个重复请求后,应当返回与第一次响应得到正常处理一样的的响应。(除时间戳等非业务相关字段外)而不应该返回类似409(Conflicts)或DuplicatedRequestException之类的错误。
原因与上述“幂等的用途”一节中分析的原因相同。主要是减少不必要的复杂度。同时保证了,服务的功能和设计思路在逻辑上更加一致:接口幂等,其内涵就是,多次请求,对调用方的效用相同。在第二次响应中,返回409之类,实质上违反了“对调用方效用相同”这个幂等的基本定义。
同时,服务器端应当对Idempotent Key的不合理使用进行检测。比如,当同一个Idempotent Key被用于不同的请求时,服务器端应当返回422,并在返回的响应体中明确解释是由于Idempotent Key重复使用造成。
幂等可以通过额外的缓存层来实现。在Cache中保存所有成功的请求的响应结果。如下图所示:
这种实现的优势是无业务代码侵入性,而且能一次性覆盖所有的API。但是劣势是:
是否要使用基于缓存的幂等实现,还是在业务层实现,要根据实际情况谨慎选择。
如果你发现,正确处理响应的幂等性变成了一个非常复杂的问题,可能要回过头去思考一下,这个API本身是否承载了太多的职责。比如把命令与查询放在了一起。如果把查询分出单独的接口,把命令的返回简化为只有成功与失败等几种情况,问题是否更简单?
有人认为,幂等API,对于相同请求,无论请求多少次,只要正常执行,那么每次返回的响应体,都必须一样,才是幂等。但是事实上,这并不是幂等性的关注点,也不是使用幂等所需要解决的核心问题。
HTTP协议本身并不要求POST支持幂等,甚至从语义上讲,POST用来创建,语义上就不幂等。于是有人会以HTTP官方协议之名说,POST方法不需要支持幂等。这其实是对HTTP协议的误读。
HTTP不要求POST幂等,是从HTTP协议本身来讲的。然而真正重要的是Body内容,Body内是更上层的通信协议,比如JsonRPC会有方法名,真正重要的,需要规范的,是这个方法。而非HTTP Method的幂等性。
Idempotent Key(或用于幂等的RequestId),放在Header还是请求体中,其实各有优劣。但是一般建议将与业务无关的,通用能力放在Header中,Idempotent是从业务角度考虑幂等,但是其实际又是可以通用化的,所以放在Header和Body中都是可行的方案。至于哪种更好,需要结合实际场景分析。
当服务提供方没有提供幂等性时,则需要调用方在请求超时的情况下,先通过查询接口获知之前请求的处理结果,只有在处理失败或确认没有收到原请求的情况下,才重新发起请求。
这对上游系统是额外的、不必要的负担。但是由于这种方案,对于接口的提供方而言,实现相对比较简单,所以并不罕见。
当不确定上一次请求是否成功的情况下,可以总是先撤销上一次操作,然后撤销成功后,再进行重试。本质上,与轮询执行结果的效果是等同的,但是对于服务提供方而言,实现复杂度更高。除非原本就有撤销(补偿)的需要(如在Saga模式中),一般并不常见。