44 | 弹力设计:幂等性设计

你好,我是陈皓,网名左耳朵耗子。

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

比如,求绝对值的函数,abs(x) = abs(abs(x))。

为什么我们需要这样的操作?说白了,就是在我们把系统解耦隔离后,服务间的调用可能会有三个状态,一个是成功(Success),一个是失败(Failed),一个是超时(Timeout)。前两者都是明确的状态,而超时则是完全不知道是什么状态。

比如,超时原因是网络传输丢包的问题,可能是请求时就没有请求到,也有可能是请求到了,返回结果时没有正常返回等等情况。于是我们完全不知道下游系统是否收到了请求,而收到了请求是否处理了,成功/失败的状态在返回时是否遇到了网络问题。总之,请求方完全不知道是怎么回事。

举几个例子:

  • 订单创建接口,第一次调用超时了,然后调用方重试了一次。是否会多创建一笔订单?

  • 订单创建时,我们需要去扣减库存,这时接口发生了超时,调用方重试了一次。是否会多扣一次库存?

  • 当这笔订单开始支付,在支付请求发出之后,在服务端发生了扣钱操作,接口响应超时了,调用方重试了一次。是否会多扣一次钱?

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

在这种情况下,一般有两种处理方式。

  • 一种是需要下游系统提供相应的查询接口。上游系统在timeout后去查询一下。如果查到了,就表明已经做了,成功了就不用做了,失败了就走失败流程。

  • 另一种是通过幂等性的方式。也就是说,把这个查询操作交给下游系统,我上游系统只管重试,下游系统保证一次和多次的请求结果是一样的。

对于第一种方式,需要对方提供一个查询接口来做配合。而第二种方式则需要下游的系统提供支持幂等性的交易接口。

全局ID

要做到幂等性的交易接口,需要有一个唯一的标识,来标志交易是同一笔交易。而这个交易ID由谁来分配是一件比较头疼的事。因为这个标识要能做到全局唯一。

如果由一个中心系统来分配,那么每一次交易都需要找那个中心系统来。 这样增加了程序的性能开销。如果由上游系统来分配,则可能会出现ID分配重复的问题。因为上游系统可能会是一个集群,它们同时承担相同的工作。

为了解决分配冲突的问题,我们需要使用一个不会冲突的算法,比如使用UUID这样冲突非常小的算法。但UUID的问题是,它的字符串占用的空间比较大,索引的效率非常低,生成的ID太过于随机,完全不是人读的,而且没有递增,如果要按前后顺序排序的话,基本不可能。

在全局唯一ID的算法中,这里介绍一个Twitter 的开源项目 Snowflake。它是一个分布式ID的生成算法。它的核心思想是,产生一个long型的ID,其中:

  • 41bits作为毫秒数。大概可以用69.7年。
  • 10bits作为机器编号(5bits是数据中心,5bits的机器ID),支持1024个实例。
  • 12bits作为毫秒内的序列号。一毫秒可以生成4096个序号。


其他的像Redis或MongoDB的全局ID生成都和这个算法大同小异。我在这里就不多说了。你可以根据实际情况加上业务的编号。

处理流程

对于幂等性的处理流程来说,说白了就是要过滤一下已经收到的交易。要做到这个事,我们需要一个存储来记录收到的交易。

于是,当收到交易请求的时候,我们就会到这个存储中去查询。如果查找到了,那么就不再做查询了,并把上次做的结果返回。如果没有查到,那么我们就记录下来。


但是,上面这个流程有个问题。因为绝大多数请求应该都不会是重新发过来的,所以让100%的请求都到这个存储里去查一下,这会导致处理流程变得很慢。

所以,最好是当这个存储出现冲突的时候会报错。也就是说,我们收到交易请求后,直接去存储里记录这个ID(相对于数据的Insert操作),如果出现ID冲突了的异常,那么我们就知道这个之前已经有人发过来了,所以就不用再做了。比如,数据库中你可以使用 insert into … values … on DUPLICATE KEY UPDATE … 这样的操作。

对于更新的场景来说,如果只是状态更新,可以使用如下的方式。如果出错,要么是非法操作,要么是已被更新,要么是状态不对,总之多次调用是不会有副作用的。

update table set status = “paid” where id = xxx and status = “unpaid”;

当然,网上还有MVCC通过使用版本号等其他方式,我觉得这些都不标准,我们希望我们有一个标准的方式来做这个事,所以,最好还是用一个ID。

因为我们的幂等性服务也是分布式的,所以,需要这个存储也是共享的。这样每个服务就变成没有状态的了。但是,这个存储就成了一个非常关键的依赖,其扩展性和可用性也成了非常关键的指标。

你可以使用关系型数据库,或是key-value的NoSQL(如MongoDB)来构建这个存储系统。

HTTP的幂等性

HTTP GET方法用于获取资源,不应有副作用,所以是幂等的。比如:GET http://www.bank.com/account/123456,不会改变资源的状态,不论调用一次还是N次都没有副作用。请注意,这里强调的是一次和N次具有相同的副作用,而不是每次GET的结果相同。GET http://www.news.com/latest-news这个HTTP请求可能会每次得到不同的结果,但它本身并没有产生任何副作用,因而是满足幂等性的。

HTTP HEAD 和GET本质是一样的,区别在于HEAD不含有呈现数据,而仅仅是HTTP头信息,不应有副作用,也是幂等的。有的人可能觉得这个方法没什么用,其实不是这样的。想象一个业务情景:欲判断某个资源是否存在,我们通常使用GET,但这里用HEAD则意义更加明确。也就是说,HEAD方法可以用来做探活使用。

HTTP OPTIONS 主要用于获取当前URL所支持的方法,所以也是幂等的。若请求成功,则它会在HTTP头中包含一个名为“Allow”的头,值是所支持的方法,如“GET, POST”。

HTTP DELETE方法用于删除资源,有副作用,但它应该满足幂等性。比如:DELETE http://www.forum.com/article/4231,调用一次和N次对系统产生的副作用是相同的,即删掉ID为4231的帖子。因此,调用者可以多次调用或刷新页面而不必担心引起错误。

HTTP POST方法用于创建资源,所对应的URI并非创建的资源本身,而是去执行创建动作的操作者,有副作用,不满足幂等性。比如:POST http://www.forum.com/articles的语义是在http://www.forum.com/articles下创建一篇帖子,HTTP响应中应包含帖子的创建状态以及帖子的URI。两次相同的POST请求会在服务器端创建两份资源,它们具有不同的URI;所以,POST方法不具备幂等性。

HTTP PUT方法用于创建或更新操作,所对应的URI是要创建或更新的资源本身,有副作用,它应该满足幂等性。比如:PUT http://www.forum/articles/4231的语义是创建或更新ID为4231的帖子。对同一URI进行多次PUT的副作用和一次PUT是相同的;因此,PUT方法具有幂等性。

所以,对于POST的方式,很可能会出现多次提交的问题,就好比,我们在论坛中发帖时,有时候因为网络有问题,可能会对同一篇贴子出现多次提交的情况。对此,一般的幂等性的设计如下。

  • 首先,在表单中需要隐藏一个token,这个token可以是前端生成的一个唯一的ID。用于防止用户多次点击了表单提交按钮,而导致后端收到了多次请求,却不能分辨是否是重复的提交。这个token是表单的唯一标识。(这种情况其实是通过前端生成ID把POST变成了PUT。)

  • 然后,当用户点击提交后,后端会把用户提交的数据和这个token保存在数据库中。如果有重复提交,那么数据库中的token会做排它限制,从而做到幂等性。

  • 当然,更为稳妥的做法是,后端成功后向前端返回302跳转,把用户的前端页跳转到GET请求,把刚刚POST的数据给展示出来。如果是Web上的最好还把之前的表单设置成过期,这样用户不能通过浏览器后退按钮来重新提交。这个模式又叫做 PRG模式(Post/Redirect/Get)。

小结

好了,我们来总结一下今天分享的主要内容。首先,幂等性的含义是,一个调用被发送多次所产生的副作用和被发送一次所产生的副作用是一样的。而服务调用有三种结果:成功、失败和超时,其中超时是我们需要解决的问题。

解决手段可以是超时后查询调用结果,也可以是在被调用的服务中实现幂等性。为了在分布式系统中实现幂等性,我们需要实现全局ID。Twitter的Snowflake就是一个比较好用的全局ID实现。最后,我给出了幂等性接口的处理流程。

下节课,我们讲述服务的状态。希望对你有帮助。

也欢迎你分享一下你的分布式服务中所有交易接口是否都实现了幂等性?你所使用的全局ID算法又是什么呢?

文末给出了《分布式系统设计模式》系列文章的目录,希望你能在这个列表里找到自己感兴趣的内容。

精选留言

  • halfamonk

    2018-03-11 10:20:25

    私以为f(x) = f(f(x)) 这个数学公式表达幂等性不太对。因为f(f(x))应该是代表把f(x)的“结果”当作参数重新传入f()。这和文字的表述还是有区别的
    作者回复

    谢谢回复,我理解你的意思。不过数学上的幂等的确是这样描述的。参看:https://en.wikipedia.org/wiki/Idempotence

    2018-03-11 21:50:44

  • macworks

    2018-03-01 09:24:25

    等幂性讲的很清楚
  • study

    2018-06-16 14:55:20

    没有理解,感觉应该用业务内容做id,比如参数,要不然下游超时,上游再次发送请求,生成的id是不同的
  • Sam_Deep_Thinking

    2018-03-05 12:13:50

    皓哥,这个专题能顺便说下分布式锁吗?我最近刚用db实现一个分布式锁。感觉不太满意。能否大概介绍一下这个主题?
  • 邓呵呵

    2018-04-27 16:59:57

    以前对重复提交总觉得应该放前端实现,原来后端处理就是幂等性,受教了
  • 心宿二

    2018-04-13 07:42:27

    这篇文章ID生成的讲解,解开了我一个长久的疑问,就这一段,付费199我也是愿意的。
    作者回复

    谢谢

    2018-04-19 10:35:02

  • 酱了个油

    2018-03-25 22:49:26

    皓哥,由客户端如何生存唯一id呀,感觉twitter的算法适合服务器,有1024个服务器限制,可以给点提示吗
    作者回复

    你什么集群?1024个不够?如果实在不够加几个bit给机器id吧

    2018-03-27 22:21:46

  • MC

    2018-03-13 11:06:58

    问题:上游(upstream)和下游(downstream)两个词是不是用反了?如果不是,这两个术语的在这篇文章上下文中的具体意思是什么?
    作者回复

    有可能是我用错了。我的“上游”偏请求方,“下游”偏响应方。

    2018-03-22 15:22:46

  • Sdylan

    2019-12-27 08:18:52

    ## 什么是幂等性
    一次和多次请求某一个资源应该具有同样的副作用(对资源变更带来连锁反应或影响):f(x) = f(f(x))。

    ## 为什么要幂等性设计
    系统解耦后,系统间服务调用存在三种状态:
    * 成功
    * 失败
    * 超时(中间状态)
    前面两种是明确的,超时是不知道什么状态,一般引起原因:
    * 请求没有到达服务方(网络延时或丢失)
    * 请求达到了服务方,服务方处理超时
    * 请求到达了服务方并且处理完返回结果,但接收方没有收到
    相关例子:订单创建、库存扣减、订单支付
    ## 怎么做幂等性设计
    * 下游提供查询接口,上游对于状态疑异订单进行查询
    * 下游系统坐幂等性设计:确保不会重复
    * 全局ID:Twitter的Snowflake算法/UUID
    * 存储冲突来解决(唯一约束)
    * 插入重复无效,`insert into … values … on DUPLICATE KEY UPDATE …`
    * 更新状态:`update table set status = “paid” where id = xxx and status = “unpaid”;`
    - HTTP幂等性
    - 只有POST需要特殊处理,其他都具有幂等性:
    - 前端生成token,后端存(唯一约束)
    - PRG模式
  • 一黑到底

    2018-05-14 08:57:13

    请问一下,第一次请求因超时失败了,然后再次请求,怎么做到全局id是一样的?因为两次请求的时间点变化了。
    作者回复

    重试的时候不用重新获取新的id,用上次的就好了。

    2018-05-14 13:11:09

  • 起司猫

    2018-05-09 13:21:01

    我们现在的系统设计的时候都没考虑到这些😂比如做幂等性接口的时候,下游每次收到订单都先查询一次,的确有点慢了。果然需要学习的地方还有很多呀。付费学习是值得的。
  • fishcat

    2018-03-03 17:56:13

    一次和多次请求某一个资源应该具有同样的副作用
    这里说的副作用是什么意思?
    作者回复

    所谓副作用,就是对这个资源
    的变更所带来的连锁反应和影响。

    2018-03-08 09:12:57

  • 常清静

    2020-03-29 22:48:34

    服务中采取的幂等操作,在于库存的交互处理中,由业务的id 加 uuid来实现,在唯一的基础上,在带一定的反解性,并通过订阅消息,和接口反查,来做一次反向ack机制,确保无资源占用处理
  • 知行合一

    2020-01-03 13:44:23

    实现幂等性的两种方式:1 服务提供方提供查询接口供调用方使用,2使用全局性唯一ID检验来实现幂等性。页面防止重复提交使用token其实就是方式二。我们在消费消息时,是用组合业务属性唯一来实现幂等的。
  • 灯火可亲

    2019-04-01 02:28:22

    请问全局ID生成策略是什么了 怎么让下游系统判断发过来的请求是同一个请求了
  • neohope

    2018-06-21 14:47:47

    说到全局id的必要性,感触很深,比如皓哥在本文章里说到的幂等性;比如有了全局id,就能很便利的监控数据流向;再比如有了全局id,查看数据日志和排错会很方便,没有的话排错就很难。

    在实际业务中,遇到了很多客户端重复提交的问题,造成的问题其实也很大。举一个很极端的例子,在一个项目中我们的系统替换另一个厂商的系统,新旧两个系统同时在线,有服务不断从旧系统搬数据到新系统。有一天系统性能下降比较严重,新系统变慢,用户就做了一件事情,把同一批数据,同时上传到了新系统和旧系统中,然后新系统提交的时候,产生了很多数据库提交主键冲突的问题,最终导致了丢失了一些数据,搞了一个通宵才完全恢复。然后才有精力开始排查性能下降的问题。

    现在回头看一下,虽然问题比较极端,很多环节上出现了不该出现的操作,但当时我们的设计从来没有考虑去应对过这种极端情况,问题真的很大。
  • SourciantC

    2020-04-28 19:16:44

    在很多时候,初始设计系统的时候并没有考虑幂等性,这就导致网络卡顿的时候,用户插入数据会无响应或者插入错误,引以为戒
  • 天问

    2019-12-24 03:21:13

    请问皓叔,处理流程-“要做到这个事,我们需要一个存储来记录收到的交易。”这个存储,指的是一个独立于业务存储的存储服务吗?是不是还得保证业务存储与幂等服务的存储事务一致性?
  • 文刂 氵共 超

    2019-12-23 17:18:53

    学习笔记 https://mubu.com/colla/Nj0F5l5PUM
  • 陈杨

    2018-10-28 21:47:26

    皓哥,在保存的地方,假设db保存,一个表可以使用数据库唯一索引来报错,但是在分库分表后,数据库唯一索引就不能工作了,只有把同一个这样的请求路由到同一个库,才可以,但是这样可能跟分库分表的依据冲突,这样情况下怎么让他报错?