幂等性
<header class="article-header">
<h1 class="article-title" itemprop="name">
幂等性
</h1>
<a href="/2019/01/05/幂等性/" class="archive-article-date">
<time datetime="2019-01-05T03:32:02.000Z" itemprop="datePublished"><i class="icon-calendar icon"></i>2019-01-05</time>
</header>
<div class="article-entry" itemprop="articleBody">
<p>现在稍具规模的网站和大型应用都不再是单机模式,而是分布式应用,基于多机的集群构建的应用,这样服务能力就可以基本实现横向扩容(scale out),不会像单机模式下的纵向扩容(scale up)会受到单机服务能力上限的限制。另外,随着“微服务”概念的火爆,很多应用在构建之初就已经走在了分布式的路线上了,所以就目前行业的发展来看,基于分布式的应用会越来越普遍,甚至变成常态。加上docker这些容器技术的出现,应用分布式化的工具也越来越成熟。</p>
分布式的复杂性
众所周知,构建分布式应用所面临的复杂度远远超出集中式的单一应用,导致复杂性的因素有很多,在此只提其中一点:网络的不可靠性。在单一进程内部,对一个函数的调用,结果只有两种——成功和失败,失败的情况下,调用者可以决定做一些事情弥补。但是在跨进程的调用中,对一个远程(也可以在同一个节点上)进程上运行的函数调用除了会得到成功和失败,还会有第三种的情况——超时,这个现象被称为分布式的三态。这也是困扰分布式应用构建的最核心因素之一,很多分布式应用的复杂度之所以上升这么多也是因为三态之中的超时引起的。
简单看看超时给我们带来的困扰,进程A调用进程B上的函数f,对于成功和失败的结果,相信和单机下一样,进程A都可以进行很好地的处理,因为结果是很明确的。如果进程A调用f之后,在允许的等待最大时间内没有返回结果,就是调用超时了,此时进程A能做什么?其实进程A什么都做不了,因为超时是一个不明确的结果——成功和失败都有可能。详细解释下可能的情况:
成功的情况:进程A把数据通过网络传输到进程B上,f执行成功,通 网络返回执行结果给进程A,可是网络不太好,传输失败了,进程A并 未在指定时间内收到结果,认为超时了。 失败的情况:情况和成功的情况差不多,只是f执行失败了,但是结 果依然传输失败,进程A也认为执行超时了。 未执行的情况:进程A的数据发送到进程B所在的节点过程中网络失败 了,或者发送到了进程B所在的机器上,但是进程B没有消费掉在TCP 网络层的数据等等 由此可见,进程A对于超时确实无能为力,有太多的可能存在的情况了。但是分布式协作过程中又必须解决这个问题,不然分布式应用是没意义的,这种情况下,一般会采用让进程A尝试重试——即重复发起之前的调用。但是这样也可能会带来问题,因为超时的那次调用可能已经成功了,再次以同样的参数调用f会不会带来额外的问题?这就引出本文的主角——幂等性。
幂等
幂等(idempotent、idempotence)是一个数学与计算机学概念,常见于抽象代数中。
在编程中,一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。所谓“影响相同”,不是要求返回值完全相同,而且是指后续多余的调用对系统的数据一致性不造成破坏。对于写入类操作,如果第一次写入是成功的,后续的写入应该抛出异常或者空操作,或者执行了写入但是未对数据造成变化。对于读取类操作,需要保证其实现上是真正的读取,不能在读操作中夹带写操作。
幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。例如,“getUsername()和setTrue()”函数就是一个幂等函数。
用通俗的话讲:就是针对一个操作,不管做多少次,产生效果或返回的结果都是一样的。接口的幂等性实际上就是接口可重复调用,在调用方多次调用的情况下,接口最终得到的结果是一致的。有些接口可以天然的实现幂等性,比如查询接口,对于查询来说,你查询一次和两次,对于系统来说,没有任何影响,查出的结果也是一样。
举几个例子:
1 | 1.比如前端对同一表单数据的重复提交,后台应该只会产生一个结果 |
幂等场景
需要实现幂等性的典型场景有以下两种:
- 客户端发起的请求可能需要重试,请求的后端处理需要保证幂等
- 后端系统使用同步RPC调用或异步消息实现分布式事务,消息的消费者需要保证幂等
可能会发生重复请求或消费的场景,在微服务架构中是随处可见的。以下是几个常见场景:
- 网络波动:因网络波动,可能会引起重复请求
- 分布式消息消费:任务发布后,使用分布式消息服务来进行消费
- 用户重复操作:用户在使用产品时,可能会无意的触发多笔交易,甚至没有响应而有意触发多笔交易
- 未关闭的重试机制:因开发人员、测试人员或运维人员没有检查出来,而开启的重试机制(如Nginx重试、RPC通信重试或业务层重试等)
广义上的RPC,包括客户端对服务端的api调用、后端系统的内网调用、跨机房调用等。一次RPC大体上包括三个步骤:发送请求、执行过程、接收响应。由于网络传输存在不确定性,导致RPC调用存在一个陷阱,即有可能出现第一、第二步都成功、第三步失败的情况,此时RPC的调用方由于接收不到结果,无法判断被调用方是否已经完成过程调用,只能按失败处理。
通常RPC调用方会针对网络失败进行重试。在上述情况下,如果远端代码不具备幂等性,却进行了重试,将导致系统的数据一致性遭到破坏,本该只执行一次的事务被执行了两次。
对于异步消息的消费者来讲,也有同样的问题。在手动ACK的情况下,消息的处理需要接收消息、处理消息、ACK三步,ACK的失败也会导致相同的问题。
在交易类的系统(比如电商、证券等)中,对非幂等的远程过程进行重试,可能会导致超买超卖,对客户造成经济损失。
互联网应用一般都是提供7*24服务的,而互联网应用本身又是快速迭代,后端系统是随时有可能需要进行发布的。发布等同于一次宕机(进程被kill),这意味着对于互联网应用的后端系统,宕机是常态而非特例。这也是幂等性和重试的必要性来源之一。
幂等性适用领域
试想这样的一种场景:在电商平台上支付后,因为网络原因导致系统提示你支付失败,于是你又重新付款了一次,等完成后检查网银发现被系统扣了两次款,这是一种什么样的体验?
造成上述问题的原因可能有很多,比如第一次付款时实际支付成功,但是信息返回时网络中断导致系统误判;又比如第一次付款的确失败了,但第二次付款时发生意外,导致支付请求被重复发送等等。在一次支付的过程中,每个环节都有可能会发生问题,我们要如何规避这类问题引发的分险?
幂等性是解决这类问题的方案之一,所以在电商,银行,互联网金融等对数据准确性要求很高的领域中,这一特性具有十分重要的地位。
假设有一个从账户取钱的远程API:
1 | boolean withdraw(long account_id, double amount); |
withdraw的语义是从account_id对应的账户中扣除amount数额的钱;如果扣除成功则返回true,账户余额减少amount;如果扣除失败则返回false,账户余额不变。
需要注意的是:和本地环境相比,我们不能轻易假设分布式环境的可靠性。
所以问题来了,一种典型的情况是withdraw请求已经被服务器端正确处理,但服务器端的返回结果由于网络等原因被掉丢了,导致客户端无法得知处理结果。如果是在网页上,一些不恰当的设计可能会使用户认为上一次操作失败了,然后刷新页面,这就导致了withdraw被调用两次,账户也被多扣了一次钱。如图所示:
解决方案一:采用分布式事务,通过引入支持分布式事务的中间件来保证withdraw功能的事务性。分布式事务的优点是对于调用者很简单,复杂性都交给了中间件来管理。缺点则是一方面架构太重量级,容易被绑在特定的中间件上,不利于异构系统的集成;另一方面分布式事务虽然能保证事务的ACID性质,而但却无法提供性能和可用性的保证。
解决方案二:幂等设计。我们可以通过一些技巧把withdraw变成幂等的,比如:
1 | int createTicket() |
createTicket的语义是获取一个服务器端生成的唯一的处理号ticket_id,它将用于标识后续的操作。idempotentWithdraw和withdraw的区别在于关联了一个ticket_id,一个ticket_id表示的操作至多只会被处理一次,每次调用都将返回第一次调用时的处理结果。这样,idempotentWithdraw就符合幂等性了,客户端就可以放心地多次调用。
基于幂等性的解决方案中一个完整的取钱流程被分解成了两个步骤:1.调用createTicket()获取ticket_id;2.调用idempotentWithdraw(ticket_id, account_id, amount)。虽然createTicket不是幂等的,但在这种设计下,它对系统状态的影响可以忽略,加上idempotentWithdraw是幂等的,所以任何一步由于网络等原因失败或超时,客户端都可以重试,直到获得结果。如图所示:
除了查询功能具有天然的幂等性之外,增加、更新、删除都要保证幂等性。那么如何来保证幂等性呢?
CRUD分析
新增类请求(C)
- 数据库自增主键,不具备幂等性
查询类动作(R)
- 重复查询不会产生或变更新的数据,因此查询是天然具备幂等性
更新类请求(U)
- 基于主键的计算式Update,不具备幂等性,即:UPDATE goods SET number=number-1 WHERE id=1
- 基于主键的非计算式Update,具备幂等性,即:UPDATE goods SET number=newNumber WHERE id=1
- 基于条件查询的Update,不一定具有幂等性(需要根据实际情况进行分析判断)
删除类请求(D)
- 基于主建的Delete具备幂等性
- 一般业务层面都是逻辑删除(即Update操作),而基于主键的逻辑删除操作也是具有幂等性的
实现幂等性的技术方案
查询操作
查询一次和查询多次,在数据不变的情况下,查询结果是一样的,select是天然的幂等操作。
删除操作
删除操作也是幂等的,删除一次和多次删除都是把数据删除。(注意可能返回结果不一样,删除的数据不存在,返回0,删除的数据多条,返回结果多个)。
去重表(唯一索引,防止新增脏数据)
利用数据库表单的特性来实现幂等,常用的一个思路是在表上构建唯一性索引,保证某一类数据一旦执行完毕,后续同样的请求再也无法成功写入。
博客点赞的例子,要想防止一个人重复点赞,可以设计一张表,将博客id与用户id绑定建立唯一索引,每当用户点赞时就往表中写入一条数据,这样重复点赞的数据就无法写入。
拿资金账户和用户账户来说,每个用户只能有一个资金账户,怎么防止给用户创建资金账户多个,那么给资金账户表中的用户ID加唯一索引,在新增的时候只有一个能请求成功,剩下都会抛出唯一索引重复异常。比如org.springframework.dao.DuplicateKeyException
,这时候再查询一次就可以了,数据存在,返回结果。
token机制,防止页面重复提交
这种机制就比较重要了,适用范围较广,有多种不同的实现方式。其核心思想是为每一次操作生成一个唯一性的凭证,也就是token。一个token在操作的每一个阶段只有一次执行权,一旦执行成功则保存执行结果。对重复的请求,返回同一个结果。
以电商平台为例子,电商平台上的订单id就是最适合的token。当用户下单时,会经历多个环节,比如生成订单,减库存,减优惠券等等。
每一个环节执行时都先检测一下该订单id是否已经执行过这一步骤,对未执行的请求,执行操作并缓存结果,而对已经执行过的id,则直接返回之前的执行结果,不做任何操作。这样可以在最大程度上避免操作的重复执行问题,缓存起来的执行结果也能用于事务的控制等。
要求:页面的数据只能被点击提交一次
发生原因:由于重复点击或者网络重发,或者nginx重发等情况会导致数据被重复提交
解决办法:
- 集群环境:采用token加redis
- 单JVM环境:采用token加redis或token加jvm内存
处理流程:
- 数据提交前要向服务的申请token,token放到redis或jvm内存,token有效时间
- 提交后后台校验token,同时删除token,生成新的token返回
token特点:要申请,一次有效性,可以限流
注意:redis要用删除操作来判断token,删除成功代表token校验通过,如果用select+delete来校验token,存在并发问题,不建议使用
悲观锁
获取数据的时候加锁获取
1 | select * from table_xxx where id=’xxx’ for update; |
注意:id字段一定是主键或者唯一索引,不然是锁表,会出事的。
悲观锁使用时一般伴随事务一起使用,数据锁定时间可能会很长,根据实际情况选用。
乐观锁
乐观锁只是在更新数据那一刻锁表,其他时间不锁表,所以相对于悲观锁,效率更高。
乐观锁的实现方式多种多样可以通过version或者其他状态条件:
1.通过版本号实现
MVCC,多版本并发控制,乐观锁的一种实现,在数据更新时需要去比较持有数据的版本号,版本号不一致的操作无法成功。
1 | update table_xxx set name=#name#,version=version+1 where version=#version# |
例如博客点赞次数自动+1的接口:
1 | public boolean addCount(Long id, Long version); |
1 | update blogTable set count= count+1,version=version+1 where id=321 and version=123 |
每一个version只有一次执行成功的机会,一旦失败必须重新获取。
2.通过条件限制
1 | update table_xxx set avai_amount=avai_amount-#{subAmount} where avai_amount-#{subAmount} >= 0 |
要求:avai_amount-subAmount >=0
这个情景适合不用版本号,只更新是做数据安全校验,适合库存模型,扣份额和回滚份额,性能更高。
注意:乐观锁的更新操作,最好用主键或者唯一索引来更新,这样是行锁,否则更新时会锁表,上面两个sql改成下面的两个更好。
1 | update table_xxx set name=#name#,version=version+1 where id=#{id} and version=#{version} |
分布式锁
还是拿插入数据的例子,如果是分布是系统,构建全局唯一索引比较困难,例如唯一性的字段没法确定,这时候可以引入分布式锁,通过第三方的系统(redis或zookeeper),在业务系统插入数据或者更新数据,获取分布式锁,然后做操作,之后释放锁,其实就是为了控制多线程并发的操作,也是分布式系统中经常用到的解决思路。
select + insert
并发不高的后台系统,或者一些任务JOB,为了支持幂等,支持重复执行,简单的处理方法是,先查询下一些关键数据,判断是否已经执行过,在进行业务处理,就可以了。
注意:核心高并发流程不要用这种方法。
状态机幂等
在设计单据相关的业务,或者是任务相关的业务,肯定会涉及到状态机(状态变更图),就是业务单据上面有个状态,状态在不同的情况下会发生变更,一般情况下存在有限状态机,这时候,如果状态机已经处于下一个状态,这时候来了一个上一个状态的变更,理论上是不能够变更的,这样的话,保证了有限状态机的幂等。
注意:订单等单据类业务,存在很长的状态流转,一定要深刻理解状态机,对业务系统设计能力提高有很大帮助。
对外提供接口的api如何保证幂等
如银联提供的付款接口:需要接入商户提交付款请求时附带:source来源,seq序列号
source+seq在数据库里面做唯一索引,防止多次付款,(并发时,只能处理一个请求)。
重点:
对外提供接口为了支持幂等调用,接口有两个字段必须传,一个是来源source,一个是来源方序列号seq,这个两个字段在提供方系统里面做联合唯一索引,这样当第三方调用时,先在本方系统里面查询一下,是否已经处理过,返回相应处理结果;没有处理过,进行相应处理,返回结果。注意,为了幂等友好,一定要先查询一下,是否处理过该笔业务,不查询直接插入业务系统,会报错,但实际已经处理了。
HTTP的幂等性
HTTP协议本身是一种面向资源的应用层协议,但对HTTP协议的使用实际上存在着两种不同的方式:一种是RESTful的,它把HTTP当成应用层协议,比较忠实地遵守了HTTP协议的各种规定;另一种是SOA的,它并没有完全把HTTP当成应用层协议,而是把HTTP协议作为了传输层协议,然后在HTTP之上建立了自己的应用层协议。本文所讨论的HTTP幂等性主要针对RESTful风格的,不过正如上一节所看到的那样,幂等性并不属于特定的协议,它是分布式系统的一种特性;所以,不论是SOA还是RESTful的Web API设计都应该考虑幂等性。下面将介绍HTTP GET、DELETE、PUT、POST四种主要方法的语义和幂等性。
HTTP GET方法用于获取资源,不应有副作用,所以是幂等的。比如:GET http://www.bank.com/account/123456
,不会改变资源的状态,不论调用一次还是N次都没有副作用。请注意,这里强调的是一次和N次具有相同的副作用,而不是每次GET的结果相同。GET http://www.news.com/latest-news
这个HTTP请求可能会每次得到不同的结果,但它本身并没有产生任何副作用,因而是满足幂等性的。
HTTP DELETE方法用于删除资源,有副作用,但它应该满足幂等性。比如:DELETE http://www.forum.com/article/4231
,调用一次和N次对系统产生的副作用是相同的,即删掉id为4231的帖子;因此,调用者可以多次调用或刷新页面而不必担心引起错误。
比较容易混淆的是HTTP POST和PUT。POST和PUT的区别容易被简单地误认为“POST表示创建资源,PUT表示更新资源”;而实际上,二者均可用于创建资源,更为本质的差别是在幂等性方面。在HTTP规范中对POST和PUT是这样定义的:
1 | The POST method is used to request that the origin server accept the entity enclosed in the request as a new subordinate of the resource identified by the Request-URI in the Request-Line ...... If a resource has been created on the origin server, the response SHOULD be 201 (Created) and contain an entity which describes the status of the request and refers to the new resource, and a Location header. |
POST所对应的URI并非创建的资源本身,而是资源的接收者。比如:POST http://www.forum.com/articles
的语义是在http://www.forum.com/articles
下创建一篇帖子,HTTP响应中应包含帖子的创建状态以及帖子的URI。两次相同的POST请求会在服务器端创建两份资源,它们具有不同的URI;所以,POST方法不具备幂等性。而PUT所对应的URI是要创建或更新的资源本身。比如:PUT http://www.forum/articles/4231
的语义是创建或更新ID为4231的帖子。对同一URI进行多次PUT的副作用和一次PUT是相同的;因此,PUT方法具有幂等性。
在介绍了几种操作的语义和幂等性之后,我们来看看如何通过Web API的形式实现前面所提到的取款功能。很简单,用POST /tickets
来实现createTicket;用PUT /accounts/account_id/ticket_id?amount=xxx
来实现idempotentWithdraw。值得注意的是严格来讲amount参数不应该作为URI的一部分,真正的URI应该是/accounts/account_id/ticket_id
,而amount应该放在请求的body中。这种模式可以应用于很多场合,比如:论坛网站中防止意外的重复发帖。
扩展案例
如何防范表单 (POST) 重复提交
HTTP POST 操作既不是安全的,也不是幂等的(至少在HTTP规范里没有保证)。当我们因为反复刷新浏览器导致多次提交表单,多次发出同样的POST请求,导致远端服务器重复创建出了资源。所以,对于电商应用来说,第一,对应的后端 WebService 一定要做到幂等性;第二,服务器端收到 POST 请求,在操作成功后必须302跳转到另外一个页面,这样即使用户刷新页面,也不会重复提交表单。
把分布式事务分解为具有幂等性的异步消息处理
电商的很多业务,考虑更多的是 BASE(即Basically Available、Soft state、和Eventually consistent),而不是 ACID(Atomicity、Consistency、Isolation和 Durability)。即为了满足高负载的用户访问,我们可以容忍短暂的数据不一致。那怎么做呢?
- 不做分布式事务,代价太大
- 不一定需要实时一致性,只需要保证最终的一致性即可
- 通过状态机和严格的有序操作,来最大限度地降低不一致性
- 最终一致性(Eventually Consistent)通过异步事件做到
如果消息具有操作幂等性,也就是一个消息被应用多次与应用一次产生的效果是一样的话,那么把不需要同步执行的事务交给异步消息推送和订阅者集群来处理即可。假如消息处理失败,那么就消息重播,由于幂等性,应用多次也能产生正确的结果。
实际情况下,消息很难具有幂等性,解决方法是使用另一个表记录已经被成功应用的消息,即消息队列和消息应用状态表一起来解决问题。
最后总结:
和分布式事务相比,幂等设计的优势在于它的轻量级,容易适应异构环境,以及性能和可用性方面。在某些性能要求比较高的应用,幂等设计往往是唯一的选择。
幂等性应该是合格程序员的一个基因,在设计系统时,是首要考虑的问题,尤其是在像第三方支付平台,银行,互联网金融公司等涉及的网上资金系统,既要高效,数据也要准确,所以不能出现多扣款,多打款等问题,这样会很难处理,并会大大降低用户体验。
业务系统实现幂等性的方式基本确定。系统关键接口的幂等性为以后系统的长期发展,特别是往分布式方向发展打下了很好的根基,可以大大简化分布式应用的构建复杂度。
</div>
<div class="article-info article-info-index">
<div class="article-tag tagcloud">
<i class="icon-price-tags icon"></i>
<ul class="article-tag-list">
<li class="article-tag-list-item">
<a href="javascript:void(0)" class="js-tag article-tag-list-link color1">计算机基础</a>
</li>
</ul>
</div>
<div class="article-category tagcloud">
<i class="icon-book icon"></i>
<ul class="article-tag-list">
<li class="article-tag-list-item">
<a href="/categories/计算机基础//" class="article-tag-list-link color1">计算机基础</a>
</li>
</ul>
</div>