admin管理员组

文章数量:1037775

幂等方案的设计问题

1. 前言

幂等的词典定义:幂等是指同一操作进行多次执行所产生的结果和执行一次的结果是相同的。即无论执行多少次相同的操作,最终的结果都是一致的。幂等在软件行业的定义应当是不论操作执行多少次,其对资源的影响都是一样的。在上图中客户手里的资金是一个资源,预期是不论执行多少次,客户的资金只减少一次。

在上述案例中,孔乙己的预期是:自己同一笔交易,即在同一家酒行购买的同一碗酒,孔乙己只能付款成功一次。孔乙己是个健忘的人,在他尝试重新付款的时候,酒店的店员应当提醒孔乙己,你已经付过款了。

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

想要做好幂等,最重要的是要解决三个问题:

  1. 唯一标识一次资源操作:像上述的孔乙己那种说法,”我想还03.07上午我欠的酒钱“,”03.07上午我欠的酒钱“这就是一个不准确的描述,这个描述不能作为一个唯一标识。通常的做法是设定一个单号作为唯一标识。
  2. 防重:有了唯一标识,内部设计的计算机系统就需要对这个唯一标识进行防重,以防止某个标识驱动了多次资源操作。注意,在系统设计中防重是有必要的,外部唯一标识和内部唯一标识的映射不是必须要做的,但是做了映射更方便内部系统逻辑的流转,所以大部分系统都做了内外部映射。
  3. 有技巧的操作资源:有了唯一标识和唯一标识防重,就能保证资源操作的唯一性,但是资源往往是被并发处理的,非独占的,这就需要一定的技巧保证资源操作符合预期。

后面的文章都围绕解决上述三个问题展开

2. 唯一标识单号

孔乙己和一众吃酒客喝酒经常赊账,在这种情况下咸亨酒店就不得不进行记账,以孔乙己为例,假设他在2025.02.04分两次喝酒,一次喝了1碗(欠100元),一次喝了2碗(欠100元),咸亨酒店应当记账,设孔乙己为kyj,对应的两笔记录是:kyj+20250204+001 → 欠100元,kyj+20250204+002 → 欠100元

这时候就可以发现,如果不使用具体单号区分具体的交易,就很难分辨出上述两笔。

孔乙己下次有钱了先计划还第一次的钱(100元),则孔乙己会拿出对应单号的借据:kyj20250204001,这时咸亨酒店找出对应的账单记录,并将该笔账的状态从待支付转为已支付。如果孔乙己健忘了,再还一次kyj20250204001的钱,这时咸亨酒店找出对应的账单记录,发现状态是已支付,就会提示孔乙己,你已经还过钱了。

  • 孔乙己第一次付款:在上述流程中,孔乙己尝试发起一笔付款,并且提供了单号:kyj20250204001,咸亨酒店使用商户单号查其账本(数据库),查到记录后,咸亨酒店独占了当前记录(意思是当前记录被锁定,不能被其他人修改),在收到孔乙己的钱后,将账单记录的状态从待支付转为已支付。
  • 孔乙己第二次付款:咸亨酒店通过kyj20250204001查找账本内的记录的过程和上述流程一样,这一次咸亨酒店发现当前记录的状态是已支付所以提示孔乙己你已经付过款了,无需重复支付

注意:使用唯一标识单号唯一标识一次操作资源的请求,是因为唯一标识单号更容易被理解,使用范围也更广泛。在实际业务场景中也可以使用其他信息作为唯一标识。

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

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

3. 使用第三方支付收款

当咸亨酒店接入支付机构(例如微信支付)后,孔乙己如果也想使用微信支付付款就必须先在微信支付开户并充值(本文不阐述快捷支付的场景)。孔乙己尝试付款后,咸亨酒店传输商户单号到支付机构,支付机构根据商户传入的单号做一定时间范围内的幂等保证。

整个流程变长了,但是保证幂等的套路没有变化,使用第三方支付收款超出了本文的讨论范围,就不详述了。

4. 技术实现

我们再回顾一下幂等的定义:幂等是指同一操作进行多次执行所产生的结果和执行一次的结果是相同的。即无论执行多少次相同的操作,最终的结果都是一致的。

现在拿支付业务举例,由于支付要素中的金额、时间、商品都无法唯一的标识出一笔交易,所以在支付场景里一般要求请求方请求支付的时候必传唯一单号对交易进行标记,以保证交易过程中的幂等性。而在非支付场景,如果其交互要素中的信息已经能够唯一标识出一次交互,那就不一定要传唯一单号对交互进行标识。

幂等是针对重复请求的,支付系统一般会面临以下几个重复请求的场景:

  1. 用户多次点击支付按钮:在网络较差或系统过载情况下,用户由于不确定交易是否完成而重复点击。
  2. 自动重试机制:系统在超时或失败时重试请求,可能导致同一支付多次尝试。
  3. 网络数据包重复:数据包在网络传输过程中,复制出了多份,导致支付平台收到多次一模一样的请求。
  4. 异常恢复:在系统升级或崩溃后,未决事务需要根据已有记录恢复和完成。内部系统重新操作。

4.1 唯一单号的生成方法

4.1.1 UUID

UUID格式规范:.html

UUID生成算法:.txt

java的随机UUID是基于SecureRandom随机算法生成的。

SecureRandom就是一种真随机数!

从原理来看,SecureRandom内部使用了RNG (Random Number Generator,随机数生成)算法,来生成一个不可预测的安全随机数。但在JDK的底层,实际上SecureRandom也有多种不同的具体实现。有的是使用安全随机种子加上伪随机数算法来生成安全的随机数,有的是使用真正的随机数生成器来生成随机数。实际使用时,我们可以优先获取高强度的安全随机数生成器;如果没有提供,再使用普通等级的安全随机数生成器。但不管哪种情况,我们都无法指定种子。

因为这个种子是通过CPU的热噪声、读写磁盘的字节、网络流量等各种随机事件产生的“熵”,所以这个种子理论上是不可能会重复的。这也就保证了SecureRandom的安全性,所以最终生成的随机数就是安全的真随机数。

尤其是在密码学中,安全的随机数非常重要。如果我们使用不安全的伪随机数,所有加密体系都将被攻破。因此,为了保证系统的安全,我们尽量使用SecureRandom来产生安全的随机数。

但是上述方法生成的随机UUID不能保证趋势递增,如果直接用作数据库的主键,会带来性能问题。

4.1.2 数据库主键自增

优点:1. 实现简单,2. 未引入外部生成单号的组件依赖

缺点:1. 自增主键无业务含义,用作单号不易辨识;2. 如果同一个系统内部有多张表需要唯一单号,自增主键的设计复杂度会变高,不一定合适

4.1.3 雪花算法

上述的雪花算法单号各个标识位的规划只是个示例,在实际业务场景中各个号段的规划不一定严格按照上述示例来。雪花算法生成的单号是趋势递增的,可以直接用作数据库的主键,无性能问题。

例如:标识位一共10 bit,如果全部表示机器,那么可以表示1024台机器,如果拆分,5 bit 表示机房,5bit表示机房里面的机器,那么可以有32个机房,每个机房可以用32台机器。也可以前4bit表示机器,后6bit表示机房里的机器

41bit,存储毫秒级时间戳(41 位的长度可以使用 69 年)。

雪花算法时钟回拨的问题如何解决?该问题指的是生成雪花算法单号的机器的时间发生回拨,如何保证生成单号的唯一性

  1. 使用全局时钟服务器里的时钟
  2. 在雪花算法号段中记录一个”纪元“位,每次时钟回拨或者机器重启的时候”纪元“位+1

此外数据模型也要设计的能够防重,在雪花算法时钟回拨生成重复单号的时候能够识别出来

4.2 防重:唯一标识的存储

外部商户传入被调方(本例是支付机构)的商户单号,支付机构必然要对其进行存储,才能在外部商户使用同一个商户单号进行支付的时候保证幂等性。由于存储资源是有限的,映射关系不可能永远被存储下来,一般情况下支付机构会承诺在一定时间内的幂等性。

4.2.1 Redis存储唯一标识

redis里的键值对可以设置过期时间,这种方式实现的映射关系无需被清理,性能优秀。使用范例:

在 Redis 2.6.12 版本开始,string的set命令增加了三个参数:

  • EX:设置键的过期时间(单位为秒)
  • PX:设置键的过期时间(单位为毫秒)
  • NX | XX:当设置为NX时,仅当 key 存在时才进行操作,设置为XX时,仅当 key 不存在才会进行操作

set key(外部单号) 内部单号 EX 过期时间 XX

如果这个操作返回false,说明 key 的添加不成功,也就是映射关系已经存在了,这时检索出来映射关系中的内部单号,然后查询内部单号对应的操作结果即可。而如果返回true,则说明得设置成功了映射关系,继续使用当前的内部单号进行操作即可。由于设置了过期时间,该映射关系会在过期时间后自动释放,不会占用过多的存储空间。

在这种情况下redis里只能存储成功一个以外部单号为 key 的映射关系。缓存有可能失效分布式锁只是用于防并发操作的一种手段,无法根本性解决幂等问题,幂等一定是依赖数据库的唯一索引解决。

4.2.2 MySQL存储唯一标识

一般小业务会存在永不过期的映射表,会承诺给外部商户永远都能保证幂等性。

当请求量级极小,存储表空间不是问题的时候,这时候支付机构的映射表可以使用MySQL的一张单表进行存储。永不过期的映射表无需过期时间的字段

带过期时间的映射关系表一般需要定期清理旧数据,也有些设计方案使用按时间分表的方式以简化数据清理流程。这种情况需要过期时间的字段。

其中幂等ID字段一定要设置为唯一索引,以从数据约束层面防重

上述存储的操作流程和Redis的流程类似,先尝试尝试插入映射关系,如果插入失败,则说明映射关系已经存在了,这时检索出来映射关系中的内部单号,然后查询内部单号对应的操作结果即可。如果插入成功,则说明之前设置成功了映射关系,继续使用当前的内部单号进行操作即可。

4.3 防重技术架构设计技巧

上面讲的是系统内部的技术实现细节,现在讲讲防重的架构设计。由于唯一单号的生成和资源操作的技术架构较简单,本文不赘述了。

4.3.1 业务范围内的幂等

业务范围内的幂等指的是,各个领域只能保证自己业务范围内的幂等防重,不能够所有领域全局幂等防重。这是实际工作中最常见的一种情况,例如:收单的时候只要求收单系统内部幂等防重即可,不需要收单和支付两个系统全局保证幂等防重。

上图示例中的DB是收单系统内部的库表,某个字段或者某些字段的组合是唯一索引,用于保证幂等。

4.3.2 通用幂等服务

我之前所在的一个部门的一个模块的设计思路就是上述的,幂等服务是一个一个独立模块,可以理解为上图的独立幂等服务,做全局防重,新幂等防重的需求的执行流程是:

  1. 向独立幂等服务申请业务类型,业务类型用于全局防重,避免和其他业务混淆
  2. 使用独立幂等服务将外部唯一标识单号换成内部唯一标识单号
  3. 使用内部唯一标识单号作为业务数据库的主键防重

这样的坏处就是复杂度和耗时RT都会增加,而且幂等服务有可能成为瓶颈,在业务量级较小的时候是没有问题的,一旦业务量级变大,独立幂等服务肯定会成为瓶颈

4.3.3 通用幂等组件

每个域都要做幂等处理,那就单独出一个独立的幂等组件,各子业务系统通过引用公共库解决。

适用场景:应用部署不太多的时候。如果应用非常多,独立幂等DB的连接池就不够用。

独立幂等DB的连接池成为瓶颈的时候,可以把幂等组件的代码共用,但是幂等数据库表使用各个业务系统的DB资源。解决独立幂等DB导致的连接数不够用的场景。

各个业务的数据库表设计不完全一样,个人认为这种通用幂等组件用处不是很大。而且在实际业务的库表设计中,为了简化设计,业务库表通常兼有保证幂等的职责。

4.4 资源操作的技巧

4.4.1 不在内存计算资源

金额扣减操作:update 金额表 set num=num-待扣减金额。不要select出来金额,在内存中计算出结果后更新到金额表里。

库存扣减操作:update 库存表 set num=num-待扣减库存。不要select出来库存,在内存中计算出结果后更新到库存表里。

其他资源操作都比较类似,要尽量避免在内存中计算资源的消耗或者增加。

4.4.2 原资源乐观锁

上述操作资源的方式还是有可能有问题,例如:update 金额表 set num=num-待扣减金额,执行的时候,可能资金已经被扣减过一次了,那么就有可能造成资金多扣。解决的方法是:update 金额表 set num=num-待扣减金额 where num = 原金额;这样就能保证每次扣减的金额都是在原金额逾期金额上扣减

4.4.3 版本号乐观锁

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;就会执行不成功,报系统异常,增加了系统设计的健壮性。

4.4.4 悲观锁操作资源

最笨也是最安全的做法是每次操作资源前,将当前资源锁定住,独占当前资源,这样就能保证资源操作符合预期了。具体SQL的操作是:select ... for update; 锁定住一行。

4.4.5 操作外部资源

如果被操作的资源不归属本系统,那么操作外部资源的时候需要传入幂等防重键防重,以避免操作外部资源的时候发生重复操作。这一点很重要!

如果外部资源的调用非常危险的时候(例如:动账流程,操作客户余额),可能还需要在本层使用分布式事务保证外部资源操作的唯一性。

常见的实现方式有两阶段提交(2PC):MySQL数据库的事务就是使用两阶段提交实现的。

5. 总结

幂等设计必须要解决两个关键问题,一个技巧性问题,一个可选操作

  • 两个关键问题是:1. 如何唯一标识某一次资源操作;2. 如何防重。
  • 一个技巧性问题:如何保证操作资源的时候符合自己的操作预期。
  • 一个可选操作:预下单

最后我将网络上广为流传的幂等性解决方案和上述四个需要解决问题做一个映射:

预下单

唯一标识

防重

操作资源技巧

唯一性约束

✔️

乐观锁

✔️

✔️

悲观锁

✔️

✔️

分布式锁

✔️

Token令牌机制

✔️

✔️

状态机

✔️

去重表

✔️

全局唯一ID

✔️

可以看出,网上大部分的幂等讲解都是围绕防重展开,但是其他的操作其实也非常重要,不能忽视。

6. 参考文档

避免重复扣款:分布式支付系统的幂等性原理与实践

一文读懂“Snowflake(雪花)”算法-腾讯云开发者社区-腾讯云

七种分布式事务的解决方案,一次讲给你听!-腾讯云开发者社区-腾讯云

文中部分图片来源自互联网,如涉侵权,请联系作者删除

幂等方案的设计问题

1. 前言

幂等的词典定义:幂等是指同一操作进行多次执行所产生的结果和执行一次的结果是相同的。即无论执行多少次相同的操作,最终的结果都是一致的。幂等在软件行业的定义应当是不论操作执行多少次,其对资源的影响都是一样的。在上图中客户手里的资金是一个资源,预期是不论执行多少次,客户的资金只减少一次。

在上述案例中,孔乙己的预期是:自己同一笔交易,即在同一家酒行购买的同一碗酒,孔乙己只能付款成功一次。孔乙己是个健忘的人,在他尝试重新付款的时候,酒店的店员应当提醒孔乙己,你已经付过款了。

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

想要做好幂等,最重要的是要解决三个问题:

  1. 唯一标识一次资源操作:像上述的孔乙己那种说法,”我想还03.07上午我欠的酒钱“,”03.07上午我欠的酒钱“这就是一个不准确的描述,这个描述不能作为一个唯一标识。通常的做法是设定一个单号作为唯一标识。
  2. 防重:有了唯一标识,内部设计的计算机系统就需要对这个唯一标识进行防重,以防止某个标识驱动了多次资源操作。注意,在系统设计中防重是有必要的,外部唯一标识和内部唯一标识的映射不是必须要做的,但是做了映射更方便内部系统逻辑的流转,所以大部分系统都做了内外部映射。
  3. 有技巧的操作资源:有了唯一标识和唯一标识防重,就能保证资源操作的唯一性,但是资源往往是被并发处理的,非独占的,这就需要一定的技巧保证资源操作符合预期。

后面的文章都围绕解决上述三个问题展开

2. 唯一标识单号

孔乙己和一众吃酒客喝酒经常赊账,在这种情况下咸亨酒店就不得不进行记账,以孔乙己为例,假设他在2025.02.04分两次喝酒,一次喝了1碗(欠100元),一次喝了2碗(欠100元),咸亨酒店应当记账,设孔乙己为kyj,对应的两笔记录是:kyj+20250204+001 → 欠100元,kyj+20250204+002 → 欠100元

这时候就可以发现,如果不使用具体单号区分具体的交易,就很难分辨出上述两笔。

孔乙己下次有钱了先计划还第一次的钱(100元),则孔乙己会拿出对应单号的借据:kyj20250204001,这时咸亨酒店找出对应的账单记录,并将该笔账的状态从待支付转为已支付。如果孔乙己健忘了,再还一次kyj20250204001的钱,这时咸亨酒店找出对应的账单记录,发现状态是已支付,就会提示孔乙己,你已经还过钱了。

  • 孔乙己第一次付款:在上述流程中,孔乙己尝试发起一笔付款,并且提供了单号:kyj20250204001,咸亨酒店使用商户单号查其账本(数据库),查到记录后,咸亨酒店独占了当前记录(意思是当前记录被锁定,不能被其他人修改),在收到孔乙己的钱后,将账单记录的状态从待支付转为已支付。
  • 孔乙己第二次付款:咸亨酒店通过kyj20250204001查找账本内的记录的过程和上述流程一样,这一次咸亨酒店发现当前记录的状态是已支付所以提示孔乙己你已经付过款了,无需重复支付

注意:使用唯一标识单号唯一标识一次操作资源的请求,是因为唯一标识单号更容易被理解,使用范围也更广泛。在实际业务场景中也可以使用其他信息作为唯一标识。

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

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

3. 使用第三方支付收款

当咸亨酒店接入支付机构(例如微信支付)后,孔乙己如果也想使用微信支付付款就必须先在微信支付开户并充值(本文不阐述快捷支付的场景)。孔乙己尝试付款后,咸亨酒店传输商户单号到支付机构,支付机构根据商户传入的单号做一定时间范围内的幂等保证。

整个流程变长了,但是保证幂等的套路没有变化,使用第三方支付收款超出了本文的讨论范围,就不详述了。

4. 技术实现

我们再回顾一下幂等的定义:幂等是指同一操作进行多次执行所产生的结果和执行一次的结果是相同的。即无论执行多少次相同的操作,最终的结果都是一致的。

现在拿支付业务举例,由于支付要素中的金额、时间、商品都无法唯一的标识出一笔交易,所以在支付场景里一般要求请求方请求支付的时候必传唯一单号对交易进行标记,以保证交易过程中的幂等性。而在非支付场景,如果其交互要素中的信息已经能够唯一标识出一次交互,那就不一定要传唯一单号对交互进行标识。

幂等是针对重复请求的,支付系统一般会面临以下几个重复请求的场景:

  1. 用户多次点击支付按钮:在网络较差或系统过载情况下,用户由于不确定交易是否完成而重复点击。
  2. 自动重试机制:系统在超时或失败时重试请求,可能导致同一支付多次尝试。
  3. 网络数据包重复:数据包在网络传输过程中,复制出了多份,导致支付平台收到多次一模一样的请求。
  4. 异常恢复:在系统升级或崩溃后,未决事务需要根据已有记录恢复和完成。内部系统重新操作。

4.1 唯一单号的生成方法

4.1.1 UUID

UUID格式规范:.html

UUID生成算法:.txt

java的随机UUID是基于SecureRandom随机算法生成的。

SecureRandom就是一种真随机数!

从原理来看,SecureRandom内部使用了RNG (Random Number Generator,随机数生成)算法,来生成一个不可预测的安全随机数。但在JDK的底层,实际上SecureRandom也有多种不同的具体实现。有的是使用安全随机种子加上伪随机数算法来生成安全的随机数,有的是使用真正的随机数生成器来生成随机数。实际使用时,我们可以优先获取高强度的安全随机数生成器;如果没有提供,再使用普通等级的安全随机数生成器。但不管哪种情况,我们都无法指定种子。

因为这个种子是通过CPU的热噪声、读写磁盘的字节、网络流量等各种随机事件产生的“熵”,所以这个种子理论上是不可能会重复的。这也就保证了SecureRandom的安全性,所以最终生成的随机数就是安全的真随机数。

尤其是在密码学中,安全的随机数非常重要。如果我们使用不安全的伪随机数,所有加密体系都将被攻破。因此,为了保证系统的安全,我们尽量使用SecureRandom来产生安全的随机数。

但是上述方法生成的随机UUID不能保证趋势递增,如果直接用作数据库的主键,会带来性能问题。

4.1.2 数据库主键自增

优点:1. 实现简单,2. 未引入外部生成单号的组件依赖

缺点:1. 自增主键无业务含义,用作单号不易辨识;2. 如果同一个系统内部有多张表需要唯一单号,自增主键的设计复杂度会变高,不一定合适

4.1.3 雪花算法

上述的雪花算法单号各个标识位的规划只是个示例,在实际业务场景中各个号段的规划不一定严格按照上述示例来。雪花算法生成的单号是趋势递增的,可以直接用作数据库的主键,无性能问题。

例如:标识位一共10 bit,如果全部表示机器,那么可以表示1024台机器,如果拆分,5 bit 表示机房,5bit表示机房里面的机器,那么可以有32个机房,每个机房可以用32台机器。也可以前4bit表示机器,后6bit表示机房里的机器

41bit,存储毫秒级时间戳(41 位的长度可以使用 69 年)。

雪花算法时钟回拨的问题如何解决?该问题指的是生成雪花算法单号的机器的时间发生回拨,如何保证生成单号的唯一性

  1. 使用全局时钟服务器里的时钟
  2. 在雪花算法号段中记录一个”纪元“位,每次时钟回拨或者机器重启的时候”纪元“位+1

此外数据模型也要设计的能够防重,在雪花算法时钟回拨生成重复单号的时候能够识别出来

4.2 防重:唯一标识的存储

外部商户传入被调方(本例是支付机构)的商户单号,支付机构必然要对其进行存储,才能在外部商户使用同一个商户单号进行支付的时候保证幂等性。由于存储资源是有限的,映射关系不可能永远被存储下来,一般情况下支付机构会承诺在一定时间内的幂等性。

4.2.1 Redis存储唯一标识

redis里的键值对可以设置过期时间,这种方式实现的映射关系无需被清理,性能优秀。使用范例:

在 Redis 2.6.12 版本开始,string的set命令增加了三个参数:

  • EX:设置键的过期时间(单位为秒)
  • PX:设置键的过期时间(单位为毫秒)
  • NX | XX:当设置为NX时,仅当 key 存在时才进行操作,设置为XX时,仅当 key 不存在才会进行操作

set key(外部单号) 内部单号 EX 过期时间 XX

如果这个操作返回false,说明 key 的添加不成功,也就是映射关系已经存在了,这时检索出来映射关系中的内部单号,然后查询内部单号对应的操作结果即可。而如果返回true,则说明得设置成功了映射关系,继续使用当前的内部单号进行操作即可。由于设置了过期时间,该映射关系会在过期时间后自动释放,不会占用过多的存储空间。

在这种情况下redis里只能存储成功一个以外部单号为 key 的映射关系。缓存有可能失效分布式锁只是用于防并发操作的一种手段,无法根本性解决幂等问题,幂等一定是依赖数据库的唯一索引解决。

4.2.2 MySQL存储唯一标识

一般小业务会存在永不过期的映射表,会承诺给外部商户永远都能保证幂等性。

当请求量级极小,存储表空间不是问题的时候,这时候支付机构的映射表可以使用MySQL的一张单表进行存储。永不过期的映射表无需过期时间的字段

带过期时间的映射关系表一般需要定期清理旧数据,也有些设计方案使用按时间分表的方式以简化数据清理流程。这种情况需要过期时间的字段。

其中幂等ID字段一定要设置为唯一索引,以从数据约束层面防重

上述存储的操作流程和Redis的流程类似,先尝试尝试插入映射关系,如果插入失败,则说明映射关系已经存在了,这时检索出来映射关系中的内部单号,然后查询内部单号对应的操作结果即可。如果插入成功,则说明之前设置成功了映射关系,继续使用当前的内部单号进行操作即可。

4.3 防重技术架构设计技巧

上面讲的是系统内部的技术实现细节,现在讲讲防重的架构设计。由于唯一单号的生成和资源操作的技术架构较简单,本文不赘述了。

4.3.1 业务范围内的幂等

业务范围内的幂等指的是,各个领域只能保证自己业务范围内的幂等防重,不能够所有领域全局幂等防重。这是实际工作中最常见的一种情况,例如:收单的时候只要求收单系统内部幂等防重即可,不需要收单和支付两个系统全局保证幂等防重。

上图示例中的DB是收单系统内部的库表,某个字段或者某些字段的组合是唯一索引,用于保证幂等。

4.3.2 通用幂等服务

我之前所在的一个部门的一个模块的设计思路就是上述的,幂等服务是一个一个独立模块,可以理解为上图的独立幂等服务,做全局防重,新幂等防重的需求的执行流程是:

  1. 向独立幂等服务申请业务类型,业务类型用于全局防重,避免和其他业务混淆
  2. 使用独立幂等服务将外部唯一标识单号换成内部唯一标识单号
  3. 使用内部唯一标识单号作为业务数据库的主键防重

这样的坏处就是复杂度和耗时RT都会增加,而且幂等服务有可能成为瓶颈,在业务量级较小的时候是没有问题的,一旦业务量级变大,独立幂等服务肯定会成为瓶颈

4.3.3 通用幂等组件

每个域都要做幂等处理,那就单独出一个独立的幂等组件,各子业务系统通过引用公共库解决。

适用场景:应用部署不太多的时候。如果应用非常多,独立幂等DB的连接池就不够用。

独立幂等DB的连接池成为瓶颈的时候,可以把幂等组件的代码共用,但是幂等数据库表使用各个业务系统的DB资源。解决独立幂等DB导致的连接数不够用的场景。

各个业务的数据库表设计不完全一样,个人认为这种通用幂等组件用处不是很大。而且在实际业务的库表设计中,为了简化设计,业务库表通常兼有保证幂等的职责。

4.4 资源操作的技巧

4.4.1 不在内存计算资源

金额扣减操作:update 金额表 set num=num-待扣减金额。不要select出来金额,在内存中计算出结果后更新到金额表里。

库存扣减操作:update 库存表 set num=num-待扣减库存。不要select出来库存,在内存中计算出结果后更新到库存表里。

其他资源操作都比较类似,要尽量避免在内存中计算资源的消耗或者增加。

4.4.2 原资源乐观锁

上述操作资源的方式还是有可能有问题,例如:update 金额表 set num=num-待扣减金额,执行的时候,可能资金已经被扣减过一次了,那么就有可能造成资金多扣。解决的方法是:update 金额表 set num=num-待扣减金额 where num = 原金额;这样就能保证每次扣减的金额都是在原金额逾期金额上扣减

4.4.3 版本号乐观锁

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;就会执行不成功,报系统异常,增加了系统设计的健壮性。

4.4.4 悲观锁操作资源

最笨也是最安全的做法是每次操作资源前,将当前资源锁定住,独占当前资源,这样就能保证资源操作符合预期了。具体SQL的操作是:select ... for update; 锁定住一行。

4.4.5 操作外部资源

如果被操作的资源不归属本系统,那么操作外部资源的时候需要传入幂等防重键防重,以避免操作外部资源的时候发生重复操作。这一点很重要!

如果外部资源的调用非常危险的时候(例如:动账流程,操作客户余额),可能还需要在本层使用分布式事务保证外部资源操作的唯一性。

常见的实现方式有两阶段提交(2PC):MySQL数据库的事务就是使用两阶段提交实现的。

5. 总结

幂等设计必须要解决两个关键问题,一个技巧性问题,一个可选操作

  • 两个关键问题是:1. 如何唯一标识某一次资源操作;2. 如何防重。
  • 一个技巧性问题:如何保证操作资源的时候符合自己的操作预期。
  • 一个可选操作:预下单

最后我将网络上广为流传的幂等性解决方案和上述四个需要解决问题做一个映射:

预下单

唯一标识

防重

操作资源技巧

唯一性约束

✔️

乐观锁

✔️

✔️

悲观锁

✔️

✔️

分布式锁

✔️

Token令牌机制

✔️

✔️

状态机

✔️

去重表

✔️

全局唯一ID

✔️

可以看出,网上大部分的幂等讲解都是围绕防重展开,但是其他的操作其实也非常重要,不能忽视。

6. 参考文档

避免重复扣款:分布式支付系统的幂等性原理与实践

一文读懂“Snowflake(雪花)”算法-腾讯云开发者社区-腾讯云

七种分布式事务的解决方案,一次讲给你听!-腾讯云开发者社区-腾讯云

文中部分图片来源自互联网,如涉侵权,请联系作者删除

本文标签: 幂等方案的设计问题