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

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

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

我们从一个故事开始:

话说有一天,支付组的小王开了一上午的会,终于在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​​」公众号。

责任编辑:武晓燕 来源: CodingBetterLi
相关推荐

2015-10-29 09:16:34

寒冬创业投资

2018-01-23 10:52:50

程序员技能互联网

2022-04-08 14:48:51

运营商5G移动通信

2017-10-27 13:56:08

无人新零售人工智能

2020-11-04 10:19:37

流量防控阿里

2022-05-23 11:35:16

jiekou幂等性

2021-04-14 17:18:27

幂等性数据源MySQL

2024-03-13 15:18:00

接口幂等性高并发

2021-01-20 07:16:07

幂等性接口token

2022-01-04 12:08:46

设计接口

2009-03-05 10:53:00

2018-09-27 10:26:12

物联网

2022-05-01 21:43:38

SQL设计模式

2020-05-25 09:22:57

Linux 命令行

2021-01-18 14:34:59

幂等性接口客户端

2023-08-29 13:53:00

前端拦截HashMap

2022-02-14 16:40:36

CTOIT主管CIO

2023-05-09 09:35:22

2023-09-01 15:27:31

点赞
收藏

51CTO技术栈公众号