CategoryResourceRepost/极客时间专栏/左耳听风/弹力设计/44 | 弹力设计篇之“幂等性设计”.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

12 KiB
Raw Blame History

所谓幂等性设计就是说一次和多次请求某一个资源应该具有同样的副作用。用数学的语言来表达就是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模式](https://en.wikipedia.org/wiki/Post/Redirect/Get)Post/Redirect/Get
  • 小结

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

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

    下篇文章中,我们讲述服务的状态。希望对你有帮助。

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

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

  • 弹力设计篇