这是一篇绝对细节的避坑指南,是可以救命的那种,极富实践意义。一共有十多张图,强烈推荐你收藏、细读。
我们从一个故事开始:
话说有一天,支付组的小王开了一上午的会,终于在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」公众号。