草庐IT

幂等性设计:震惊!吃一碗粉竟付了两碗的钱?

赵志强 2023-03-28 原文
​这是一篇绝对细节的避坑指南,是可以救命的那种,极富实践意义。一共有十多张图,强烈推荐你收藏、细读。

我们从一个故事开始:

话说有一天,支付组的小王开了一上午的会,终于在12点半的时候结束了。饥肠辘辘的他掏出了手机准备点外卖,突然,他想起半个小时后还有个会。得了,外卖肯定来不及了,只能下楼随便吃点了。

下楼的路上,小王想起前几天听同事说,马路对过开了一家新的嗦粉店。那家的粉不贵,也不好吃。小王一想,这家人肯定不多,满足我快速就餐的需求,就这家了!

刚到门口小王就震惊了,原以为只有一两个人,没想到,居然一个人都没有!小王咽了咽口水,看了看时间,咱们赌一把这东西吃了不拉肚子吧。于是就坐下了。

点了碗菜单上的招牌“招牌炒粉”。上菜果然很快,味道也是“名副其实”,没想到的是,这家店居然开通了小王公司研发的支付工具。吃完后,小王就用自己负责的支付工具做了支付。刚做完支付,小王收到两条银行扣款通知,各扣了18块钱。纳尼?!难道是银行重复发了消息?小王点进自己的支付账单,看到了毛骨悚然的一幕,居然扣了两次钱!

小王心想,完了,肯定是幂等性出问题了。于是顾不上退款,赶紧就跑回了公司。因为小王担心,明天他可能就一碗粉都吃不上了!

01什么是幂等性

所以,我们今天就来聊聊幂等性这个话题。幂等性设计可以说是系统设计中最重要的一点,设计不好分分钟就发生资损。轻则一年白干,重则卷铺盖走人,更重则公司倒闭。

我们先解释一下“幂等性”这个词。

用大白话来说就是:“同一个动作无论重复多少次,结果都是一样的”。这里要注意的是“结果”两个字。一个动作可能带来多个结果,所以幂等性是针对其中的一个结果的。

我们拿洗碗举例:你洗了一个碗,然后放在水池边,过一会儿忙完了回到水池边又看到这个碗,但是你忘记了之前是否洗过(或者你不确定中间是否又被人使用过),保险起见你就是再洗一次。

那么对于碗来说,洗碗就是具备幂等性的。一个碗你洗一次、两次、n次,结果都是一样的,就是变干净了。但对于洗洁精来说,洗碗就不具备幂等性。一个碗你洗的次数越多,洗洁精就越少。

用数学公式来说就是:f(x) = f(f(x))。比如,计算绝对值就具备幂等性,abs(x) = abs(abs(x))。​

回到开头的例子。你吃了一碗粉,然后使用某支付工具支付。app往后端服务器发起了一笔支付请求,但是因为超时,app没有拿到这个支付结果,于是重试了一次。假设两次请求都到达了服务器但是没有做好幂等设计,就会扣两次钱,就出现了“吃一碗粉,付两碗钱”的结果。

这种事情如果出现多了,各种投诉及举报分分钟就可以让公司闭门歇业。

你也许会说,只要不发起重试就好了!那如果你是提供了一个支付接口呢?如果支付系统是收到了上游订单系统的异步消息然后进行支付,消息重发了呢?

你也许想到了自己系统的幂等性设计,你也许想到了一些最耳熟能详的方法论,但是相信我,好的幂等性设计远没有你想象的那么简单。

很多的幂等性设计都是存在漏洞的。甚至在大厂,幂等性设计都是一个重点话题。

02操作分类与幂等性

在具体讲设计之前,我们先聊下操作的分类以及对应的幂等性问题。

所有的操作无外乎CURD四种类型(CURD = Create Update Read Delete)。

【Read】读操作一般来说是天然具备幂等性的。

【Delete】删除操作也是天然具备幂等性,无论你带不带where条件,执行一次和执行一百次结果是一样的。

【Update】更新操作不具备天然的幂等性。例如:UPDATE 余额表 SET 余额=余额-1 WHERE 用户=CodingBetterLife。这个语句执行一次扣一块钱,执行了多次就反复扣。但是Update的问题是很好解决的,只需要在where条件中加上原始值就可以了。比如把上面的语句改为:UPDATE 余额表 SET 余额=余额-1 WHERE 用户=CodingBetterLife and 余额=100。

【Create】新建操作也不具备天然幂等性。比如app重试支付请求,每次支付都会插入一条支付记录,需要有唯一键来控制(这个我们后面细说,仅仅唯一键是不够的)。

处理幂等性,最难的地方其实就在Create的部分。我们细细看来。

03幂等性如何设计

我们就拿开头吃粉的例子来看看如何设计幂等性。我们上面提到,幂等性是针对其中一个结果的,我们讨论的是针对支付结果的幂等性。因为结果幂等才是我们最关心的。

我们先一起确认下,幂等性设计的目标:

【目标1】无论是有意还是无意的重复支付请求,都不能出现扣两次钱的情况。

【目标2】要能够获得正确的支付结果(必须能获得,并且必须正确)。

开始我们的设计之旅:

(我们会从应对app支付的重复请求,过渡到一个支持重试的支付服务设计)

吃完粉以后,你掏出手机进行支付,整个过程如下所示:

99.99%的操作,都可以这样顺利地完成,但生活吧,意外总是不期而遇:

这种情况下,如果我们不做任何设计,自然就会重复支付。

要杜绝这种问题,最直接的思路就是:不要重试!不要重试!不要重试!(学一下三体)

针对【意外1】:app可以设计成点击后将按钮失效。

针对【意外2】和【意外3】:可以关闭相关的重试功能。

这是采用了“逃避”的思路,也就是不要让问题发生。但这真不是你能控制的。况且,一旦整个架构体系变得复杂,你很难评估是不是某个点会有重试的逻辑。

所以,解决幂等性问题,不能依赖别人“不重试”,而要以“肯定会重试”作为前提条件来设计。

但这并不是说所有的逻辑可以在后端完成,app侧起码要做一个基本的改造,那就是每次用户的点击请求,会生成唯一一个ID,并且把这个ID一路带下来。

然后,后端可以这样来设计:

注意:从这里开始,我们的后端设计不仅应对“不小心”的重复支付,更针对故意的调用方重试。你也可以理解为我们在做一个“支付服务”的设计。

(方案1)

此时,如果原始请求超时异常,然后重试的话,会被拦截,如下图:

据我了解,大部分幂等的设计都是这种方式,你可以对比下你的系统。

但这样设计会有个不容易想到的严重缺陷,看下图:

这种情况非常严重。你可以想象,如果调用方认为失败,但其实支付成功,会是什么结果?!

这里的关键问题在于:需要控制在任何时刻,任何一个唯一键请求,只有一个线程在执行。所以,我们需要在业务检验之前,就做一个分布式锁,保证只有一个线程处理支付。

这里我们有两个方案。

第一个方案是:将落支付流水的动作提到业务检验之前。如下图:

(方案2)

这个方案的问题在于,会有很多业务校验失败的流水在库中。这无论对检索的性能还是存储的成本来说,都是一个需要考虑的点。

另外,所有的请求直接落库,对数据库压力很大。例如有黑产用高并发扫你的接口,你不先做一次黑名单检查直接落库,对db来说风险极高,可能会横向影响其他业务。

如果你认为没有这种场景,并且有很多废流水没问题,这个方案是可以的。事实上,有些银行的接口就是这么设计的。

如果你不想有那么多废流水,你可以采用第二个方案,那就是在业务检验前加一个分布式锁。同时,如果分布式锁获取失败,则查一下流水库,返回流水状态。如下图:

(方案3)

上述方案采用的是redis分布式锁,也可以使用db的幂等表来实现。

但是,这个方案是有问题的。

如果原始请求在抢到分布式锁以后异常中断了(例如服务器重启)。重试的请求都只能获得“订单不存在”的状态。但是订单不存在有可能是因为中断,有可能是因为原始请求还没有走到落数据库这一步。对于调用方来说不敢直接认为失败。

我们看下图:

这种情况下,我们往往会给到调用方一个约定。约定:如果原始请求后超过一段时间(例如1小时,以下都以1小时举例)重试,依然获取到订单不存在,则可以认定为失败!服务端要保证1小时内,原始请求一定执行完(无论是成功、失败、还是异常终止)。​

到这里总该万事大吉了吧?

没错,到这里确实就可以了。很多大厂都是这么设计的。​

但是,这里有一个问题。那就是,对于调用方来说,如果服务端发生异常中断(例如机器重启)的情况,他只能等到约定的1小时后换号重新支付。

不要小看换号这个事情。调用方对一笔支付换号重试是高危操作,一旦换号,所有的幂等都失效。所以,如果调用方想要尽量保证支付成功,同时忌讳换号来做重试。该怎么办呢?

上面的方案中,之所以需要换号,是因为我们的分布式锁不会释放。那么,我们如果1小时后删除幂等,就可以做原号重试了。如下图:

(方案4)

不同于换号重试的是,原号重试依然在支付流水数据库层面有幂等控制,不会重复支付。这样,我们就实现了不换号重试的功能。

我们来总结一下,我们一共有三种方案来实现幂等,我们汇总如下图:

这三个方案有自己的使用场景,我最后来说一下:

【方案2】如果你确保没有恶意请求给数据库带来压力,并且接受大量废流水,可以直接使用这个方案。同时确保整个“从流水入库到支付完成”在一个事务中。如果不在一个事务中,会存在支付异常时支付流水悬挂的问题。需要通过补偿的方式推进。这个点我们此文不细讲了。如果有问题可以公众号给我留言。

【方案3】如果你可以要求调用方接受一段时间后换号重试。你可以使用这个方案。

【方案4】如果你的调用方无法接受换号重试,你可以选择这个方案。

事实上,【方案3】和【方案4】是大厂的最佳实践。你可以在设计自己系统时酌情参考。当然,有一些变种的实现,但原理上和核心环节上的设计是一致的。​

你现在再回头看看方案1,是不是就深刻体会到,幂等性设计并没有那么容易吧。

04结尾

到这里,我们就把幂等性问题讲完了。

在多年的工作过程中,我面试过很多候选人,我经常会结合候选人的工作,考察其在幂等性设计上的思考。因为幂等性是一个大家一定会碰到的点,其中的细节很能反映候选人的严谨性和技术能力。

对于架构来说,“异步”和“重试”是我们常用且重要的设计思路,而这两者都需要严格考虑“幂等性”。

所以,千万不要让你的用户发生“吃一碗粉付两碗钱”的情况,不然,也许没几天,你自己连一碗粉都付不起了。

建议你可以收藏本文,在你需要做系统或者架构设计的时候,拿出来做个参考。

本文转载自微信公众号「 CodingBetterLife​​」,作者「 赵志强 」,可以通过以下二维码关注。

转载本文请联系「 ​CodingBetterLife​​」公众号。

有关幂等性设计:震惊!吃一碗粉竟付了两碗的钱?的更多相关文章

  1. ruby-on-rails - 我的 Rails 4 应用程序上的每个 link_to 都被调用了两次 - 2

    我的Rails4应用程序遇到一些异常行为。每次我单击View中的link_to时,我的Controller操作都会被调用两次。例如:在我的root_url中,我对users_profile有这个标准调用:"logout-button")%>当我单击此链接时,我的控制台显示以下输出:StartedGET"/users/profile"for127.0.0.1at2013-11-2520:45:53-0200ProcessingbyUsers::SessionsController#profileasHTMLUserLoad(0.7ms)SELECT"users".*FROM"users"

  2. ruby - 为什么我的 RSpec 规范运行了两次? - 2

    我在我的Rakefile中定义了以下RSpec(1.3.0)任务:require'spec/rake/spectask'Spec::Rake::SpecTask.new(:spec)do|spec|spec.libs我在spec/spec_helper.rb中有以下内容:require'rubygems'require'spec'require'spec/autorun'require'rack/test'require'webmock/rspec'includeRack::Test::MethodsincludeWebMockrequire'omniauth/core'我在spec/

  3. ruby-on-rails - 由于 :remote=>true,表单提交了两次 - 2

    我的表单提交了两次,经过仔细检查,这是由':remote=>true'引起的。我删除了它,我的项目运行良好。谁能告诉我为什么?以及如何使用':remote=>true'?我的ruby代码:true,:id=>'new_product_group_form')do%>[:product_scopes,:groups,group_name]),scopes.keys.mapdo|scope_name|[t(:name,:scope=>[:product_scopes,:scopes,scope_name]),scope_name]end]end)%>"/>浏览器中的最终html代码。Add

  4. 震惊!原来查找论文这么简单?再也不用担心组会不知道汇报啥了!计算机硕士新生人手必备不可不看 - 2

    目录一种简单上手的暴力论文分析方法——以区块链为例【含项目源码】太长不看版本:最终成果:情况说明论文推荐方面论文投稿方面以下是具体的实现,有其他研究方向想自行确定的请仔细阅读,授人以鱼不如授人以渔第一章、确定对象——研究热点的中国计算机研究生第二章、思路——基于爬虫结合关键字过滤暴力获取所需论文信息第一步:从CCF推荐目录中获取网址01、背景介绍02、数据预处理03、数据写入表格第二步:从中科院分区中获取期刊对应分区第三步:从期刊/会议对应网址中爬取到子网页并进入,获取到其中的标题、年份等信息第四步:针对获取到的表格数据进行分析和整理实际爬取数据量【其实就论文的标题+对应年份】

  5. javascript - 为什么 ng-style 函数应用了两次? - 2

    我有一个像这样的Angular应用:angular.module('ngStyleApp',[]).controller('testCtrl',function($scope){$scope.list=[1,2,3];$scope.getStyles=function(index){console.log('gettingstylesforindex'+index);return{color:'red'};};});带有相应的标记:{{value}}正如预期的那样,可见输出是三个红色列表项。但是该语句总共被记录到控制台6次,这意味着View被渲染了两次:gettingstylesfor

  6. javascript - 为什么我的 React 组件渲染了两次? - 2

    我不知道为什么我的React组件会渲染两次。所以我从参数中提取一个电话号码并将其保存到状态,以便我可以搜索Firestore。一切似乎都工作正常,除了渲染两次......第一个渲染电话号码和零点。第二次渲染时所有数据都正确显示。有人可以指导我找到解决方案。classUpdateextendsComponent{constructor(props){super(props);const{match}=this.props;this.state={phoneNumber:match.params.phoneNumber,points:0,error:''}}getPoints=()=>{f

  7. javascript - 动态嵌套表单 link_to_add 调用了两次 - 2

    我正在使用ryanbatesnested_formgem将一些嵌套字段动态添加到表单。例如一切正常,除了每次单击链接时都会添加两个空字段。我在$('forma.add_nested_fields').live('click',function()上放置了一个断点并看到它被调用了两次...我在mac上使用chrome 最佳答案 查看标题。你会看到它在那里两次:只需删除第二个引用(可能在您的application.html.erb中)即可。 关于javascript-动态嵌套表单link_t

  8. javascript - 两个相等的 jQuery .on 事件是否绑定(bind)了两次? - 2

    假设我们有很多事情要做。我们使用$('body').on('click','.todo',do_stuff)而不是$('.todo').click(do_stuff)所以我们只会将一个事件监听器附加到DOM。但是,我使用的是小型MVC。每个待办事项View都有此代码$('body').on('click','.todo',do_stuff)。所以如果我们有20件事情要做,这是否意味着body有20个听众或只有一个?他们都会开火吗? 最佳答案 你应该杀掉之前的事件处理器:$('body').off('click','.todo',do

  9. javascript - jQuery $.ajax() 执行了两次? - 2

    这是一个按钮:和绑定(bind)事件:$("#addToCart").bind('click',function(){$.ajax({url:'/cartManager/add',data:{pictureId:currentImageId,printSize:$("#sizeoption:selected").val(),paperType:$("#paperTypeoption:selected").val(),quantity:1},success:function(){$("#modal").html("ОКClosinginasec").delay(1000);$("#mod

  10. javascript - js.erb 文件执行了两次。 Ruby on Rails - private_pub - 2

    我正在研究chating模块。为此,我使用了private_pubgem.在这个模块中,我制作了三个channel,但我不能在这里一一列举,因为它会显示一个非常非常大的页面。因此,让我们坚持一个channel。我单击具有channel""的链接然后ajax工作并转到"conversations/send_invitation"(只有一次-没关系)在我的"/conversations/send_invitation"我有defsend_invitation@conversation=Conversation.new(conversation_params)respond_todo|for

随机推荐