

幂等的词典定义:幂等是指同一操作进行多次执行所产生的结果和执行一次的结果是相同的。即无论执行多少次相同的操作,最终的结果都是一致的。幂等在软件行业的定义应当是不论操作执行多少次,其对资源的影响都是一样的。在上图中客户手里的资金是一个资源,预期是不论执行多少次,客户的资金只减少一次。
在上述案例中,孔乙己的预期是:自己同一笔交易,即在同一家酒行购买的同一碗酒,孔乙己只能付款成功一次。孔乙己是个健忘的人,在他尝试重新付款的时候,酒店的店员应当提醒孔乙己,你已经付过款了。

绘制上图的大佬也倾向重复请求的时候明确返回重复请求的信息,和我最上面的那张图的回复原则(重复付款:回复“你刚才付过了”)一样。

想要做好幂等,最重要的是要解决三个问题:
后面的文章都围绕解决上述三个问题展开


孔乙己和一众吃酒客喝酒经常赊账,在这种情况下咸亨酒店就不得不进行记账,以孔乙己为例,假设他在2025.02.04分两次喝酒,一次喝了1碗(欠100元),一次喝了2碗(欠100元),咸亨酒店应当记账,设孔乙己为kyj,对应的两笔记录是:kyj+20250204+001 → 欠100元,kyj+20250204+002 → 欠100元
这时候就可以发现,如果不使用具体单号区分具体的交易,就很难分辨出上述两笔。
孔乙己下次有钱了先计划还第一次的钱(100元),则孔乙己会拿出对应单号的借据:kyj20250204001,这时咸亨酒店找出对应的账单记录,并将该笔账的状态从待支付转为已支付。如果孔乙己健忘了,再还一次kyj20250204001的钱,这时咸亨酒店找出对应的账单记录,发现状态是已支付,就会提示孔乙己,你已经还过钱了。
注意:使用唯一标识单号唯一标识一次操作资源的请求,是因为唯一标识单号更容易被理解,使用范围也更广泛。在实际业务场景中也可以使用其他信息作为唯一标识。

其实上面要解决的三个问题少了一个步骤,那就是咸亨酒店给孔乙己提供“商户单号”的步骤,这个步骤在互联网系统设计中通常被称作预下单,如果资源的操作源是前端或者其他没有能力生成唯一标识的系统的系统的时候,整个操作流程会增加这一步。例如孔乙己或者前端就没有能力生成唯一标识,或者其生成的唯一标识不被信任。

最上面讲得孔乙己还款步骤就会升级成上图。

当咸亨酒店接入支付机构(例如微信支付)后,孔乙己如果也想使用微信支付付款就必须先在微信支付开户并充值(本文不阐述快捷支付的场景)。孔乙己尝试付款后,咸亨酒店传输商户单号到支付机构,支付机构根据商户传入的单号做一定时间范围内的幂等保证。
整个流程变长了,但是保证幂等的套路没有变化,使用第三方支付收款超出了本文的讨论范围,就不详述了。
我们再回顾一下幂等的定义:幂等是指同一操作进行多次执行所产生的结果和执行一次的结果是相同的。即无论执行多少次相同的操作,最终的结果都是一致的。
现在拿支付业务举例,由于支付要素中的金额、时间、商品都无法唯一的标识出一笔交易,所以在支付场景里一般要求请求方请求支付的时候必传唯一单号对交易进行标记,以保证交易过程中的幂等性。而在非支付场景,如果其交互要素中的信息已经能够唯一标识出一次交互,那就不一定要传唯一单号对交互进行标识。
幂等是针对重复请求的,支付系统一般会面临以下几个重复请求的场景:

UUID格式规范:https://www.rfc-editor.org/rfc/rfc9562.html
UUID生成算法:https://www.ietf.org/rfc/rfc4122.txt

java的随机UUID是基于SecureRandom随机算法生成的。
SecureRandom就是一种真随机数!
从原理来看,SecureRandom内部使用了RNG (Random Number Generator,随机数生成)算法,来生成一个不可预测的安全随机数。但在JDK的底层,实际上SecureRandom也有多种不同的具体实现。有的是使用安全随机种子加上伪随机数算法来生成安全的随机数,有的是使用真正的随机数生成器来生成随机数。实际使用时,我们可以优先获取高强度的安全随机数生成器;如果没有提供,再使用普通等级的安全随机数生成器。但不管哪种情况,我们都无法指定种子。
因为这个种子是通过CPU的热噪声、读写磁盘的字节、网络流量等各种随机事件产生的“熵”,所以这个种子理论上是不可能会重复的。这也就保证了SecureRandom的安全性,所以最终生成的随机数就是安全的真随机数。
尤其是在密码学中,安全的随机数非常重要。如果我们使用不安全的伪随机数,所有加密体系都将被攻破。因此,为了保证系统的安全,我们尽量使用SecureRandom来产生安全的随机数。
但是上述方法生成的随机UUID不能保证趋势递增,如果直接用作数据库的主键,会带来性能问题。
优点:1. 实现简单,2. 未引入外部生成单号的组件依赖
缺点:1. 自增主键无业务含义,用作单号不易辨识;2. 如果同一个系统内部有多张表需要唯一单号,自增主键的设计复杂度会变高,不一定合适

上述的雪花算法单号各个标识位的规划只是个示例,在实际业务场景中各个号段的规划不一定严格按照上述示例来。雪花算法生成的单号是趋势递增的,可以直接用作数据库的主键,无性能问题。
例如:标识位一共10 bit,如果全部表示机器,那么可以表示1024台机器,如果拆分,5 bit 表示机房,5bit表示机房里面的机器,那么可以有32个机房,每个机房可以用32台机器。也可以前4bit表示机器,后6bit表示机房里的机器
41bit,存储毫秒级时间戳(41 位的长度可以使用 69 年)。
雪花算法时钟回拨的问题如何解决?该问题指的是生成雪花算法单号的机器的时间发生回拨,如何保证生成单号的唯一性
此外数据模型也要设计的能够防重,在雪花算法时钟回拨生成重复单号的时候能够识别出来
外部商户传入被调方(本例是支付机构)的商户单号,支付机构必然要对其进行存储,才能在外部商户使用同一个商户单号进行支付的时候保证幂等性。由于存储资源是有限的,映射关系不可能永远被存储下来,一般情况下支付机构会承诺在一定时间内的幂等性。
redis里的键值对可以设置过期时间,这种方式实现的映射关系无需被清理,性能优秀。使用范例:
在 Redis 2.6.12 版本开始,string的set命令增加了三个参数:
set key(外部单号) 内部单号 EX 过期时间 XX
如果这个操作返回false,说明 key 的添加不成功,也就是映射关系已经存在了,这时检索出来映射关系中的内部单号,然后查询内部单号对应的操作结果即可。而如果返回true,则说明得设置成功了映射关系,继续使用当前的内部单号进行操作即可。由于设置了过期时间,该映射关系会在过期时间后自动释放,不会占用过多的存储空间。
在这种情况下redis里只能存储成功一个以外部单号为 key 的映射关系。缓存有可能失效,分布式锁只是用于防并发操作的一种手段,无法根本性解决幂等问题,幂等一定是依赖数据库的唯一索引解决。
一般小业务会存在永不过期的映射表,会承诺给外部商户永远都能保证幂等性。
当请求量级极小,存储表空间不是问题的时候,这时候支付机构的映射表可以使用MySQL的一张单表进行存储。永不过期的映射表无需过期时间的字段
带过期时间的映射关系表一般需要定期清理旧数据,也有些设计方案使用按时间分表的方式以简化数据清理流程。这种情况需要过期时间的字段。
其中幂等ID字段一定要设置为唯一索引,以从数据约束层面防重
上述存储的操作流程和Redis的流程类似,先尝试尝试插入映射关系,如果插入失败,则说明映射关系已经存在了,这时检索出来映射关系中的内部单号,然后查询内部单号对应的操作结果即可。如果插入成功,则说明之前设置成功了映射关系,继续使用当前的内部单号进行操作即可。
上面讲的是系统内部的技术实现细节,现在讲讲防重的架构设计。由于唯一单号的生成和资源操作的技术架构较简单,本文不赘述了。

业务范围内的幂等指的是,各个领域只能保证自己业务范围内的幂等防重,不能够所有领域全局幂等防重。这是实际工作中最常见的一种情况,例如:收单的时候只要求收单系统内部幂等防重即可,不需要收单和支付两个系统全局保证幂等防重。
上图示例中的DB是收单系统内部的库表,某个字段或者某些字段的组合是唯一索引,用于保证幂等。

我之前所在的一个部门的一个模块的设计思路就是上述的,幂等服务是一个一个独立模块,可以理解为上图的独立幂等服务,做全局防重,新幂等防重的需求的执行流程是:
这样的坏处就是复杂度和耗时RT都会增加,而且幂等服务有可能成为瓶颈,在业务量级较小的时候是没有问题的,一旦业务量级变大,独立幂等服务肯定会成为瓶颈

每个域都要做幂等处理,那就单独出一个独立的幂等组件,各子业务系统通过引用公共库解决。
适用场景:应用部署不太多的时候。如果应用非常多,独立幂等DB的连接池就不够用。

独立幂等DB的连接池成为瓶颈的时候,可以把幂等组件的代码共用,但是幂等数据库表使用各个业务系统的DB资源。解决独立幂等DB导致的连接数不够用的场景。
各个业务的数据库表设计不完全一样,个人认为这种通用幂等组件用处不是很大。而且在实际业务的库表设计中,为了简化设计,业务库表通常兼有保证幂等的职责。
金额扣减操作:update 金额表 set num=num-待扣减金额。不要select出来金额,在内存中计算出结果后更新到金额表里。
库存扣减操作:update 库存表 set num=num-待扣减库存。不要select出来库存,在内存中计算出结果后更新到库存表里。
其他资源操作都比较类似,要尽量避免在内存中计算资源的消耗或者增加。
上述操作资源的方式还是有可能有问题,例如:update 金额表 set num=num-待扣减金额,执行的时候,可能资金已经被扣减过一次了,那么就有可能造成资金多扣。解决的方法是:update 金额表 set num=num-待扣减金额 where num = 原金额;这样就能保证每次扣减的金额都是在原金额逾期金额上扣减

ABA问题。但是原资源乐观锁的方法无法解决ABA问题。假设1号线程中存在bug流程(重复扣款),金额被误修改后无法被感知。
解决方案是在资源表里增加一个版本号(版本号总是不断递增的),以上图1号线程为例在执行:update 金额表 set num=num-待扣减金额 where num = 原金额;之前先获取到金额表里的版本号,假设为X。则重新设计的SQL应当是:update 金额表 set num=num-待扣减金额, 版本号=X+1 where num = 原金额 AND 版本号=X;
在上述的设计下,bug流程的时候执行SQL:update 金额表 set num=num-待扣减金额, 版本号=X+1 where num = 原金额 AND 版本号=X;就会执行不成功,报系统异常,增加了系统设计的健壮性。
最笨也是最安全的做法是每次操作资源前,将当前资源锁定住,独占当前资源,这样就能保证资源操作符合预期了。具体SQL的操作是:select ... for update; 锁定住一行。
如果被操作的资源不归属本系统,那么操作外部资源的时候需要传入幂等防重键防重,以避免操作外部资源的时候发生重复操作。这一点很重要!
如果外部资源的调用非常危险的时候(例如:动账流程,操作客户余额),可能还需要在本层使用分布式事务保证外部资源操作的唯一性。

常见的实现方式有两阶段提交(2PC):MySQL数据库的事务就是使用两阶段提交实现的。
幂等设计必须要解决两个关键问题,一个技巧性问题,一个可选操作
最后我将网络上广为流传的幂等性解决方案和上述四个需要解决问题做一个映射:
预下单 | 唯一标识 | 防重 | 操作资源技巧 | |
|---|---|---|---|---|
唯一性约束 | ✔️ | |||
乐观锁 | ✔️ | ✔️ | ||
悲观锁 | ✔️ | ✔️ | ||
分布式锁 | ✔️ | |||
Token令牌机制 | ✔️ | ✔️ | ||
状态机 | ✔️ | |||
去重表 | ✔️ | |||
全局唯一ID | ✔️ |
可以看出,网上大部分的幂等讲解都是围绕防重展开,但是其他的操作其实也非常重要,不能忽视。
一文读懂“Snowflake(雪花)”算法-腾讯云开发者社区-腾讯云
七种分布式事务的解决方案,一次讲给你听!-腾讯云开发者社区-腾讯云
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。