本文 的 原文 地址
原始的内容,请参考 本文 的 原文 地址
10Wqps 幂等性 AOP组件,来一个 最系统最透彻的幂等性方案
尼恩说在前面
在40岁老架构师 尼恩的读者交流群(50+)中,最近有小伙伴拿到了一线互联网企业如阿里、滴滴、极兔、有赞、希音、百度、网易、美团的面试资格,遇到很多很重要的面试题:
你们项目,怎么做幂等设计的?
接口的幂等性,怎么设计?
业务订单的幂等性,怎么设计?
付款请求的幂等性,怎么设计?
前端重复提交选中的数据,后台只产生一次有效操作,怎么设计?
10wqps高并发,如何防止重复提交/支付订单?
10wqps高并发,如何防止重复下单?
10wqps高并发,如何防止重复支付?
10wqps高并发,如何解决重复操作问题?
最近又有小伙伴在面试阿里、网易,都遇到了相关的面试题。
很多小伙伴回答了一些边边角角,但是回答不全面不体系,面试官不满意,面试挂了。
借着此文,尼恩给大家做一下系统化、体系化的梳理,使得大家内力猛增,展示一下雄厚的 “技术肌肉、技术实力”,让面试官爱到 “不能自已、口水直流”,然后实现”offer直提,offer自由”。
当然,这道面试题,以及参考答案,也会收入咱们的 《尼恩Java面试宝典PDF》V170版本,供后面的小伙伴参考,提升大家的 3高 架构、设计、开发水平。
什么是幂等性?
所谓幂等性,就是一次操作和多次操作同一个资源,所产生的影响均与一次操作的影响相同。
"幂等(idempotent、idempotence)是一个数学与计算机学概念,常见于抽象代数中。
幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。
幂等性,用数学语言表达就是:
f(x)=f(f(x))
维基百科的幂等性定义如下:
幂等(idempotent、idempotence)是一个数学与计算机学概念,常见于抽象代数中。
幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。
这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。
例如,“setTrue()”函数就是一个幂等函数,无论多次执行,其结果都是一样的,更复杂的操作幂等保证是利用唯一交易号(流水号)实现.
在软件或者系统中,重复使用幂等函数或幂等方法不会影响系统状态,也不用担心重复执行会对系统造成改变。
通俗点说:
一个接口如果幂等,不管被调多少次,只要参数不变,结果也不变。
幂等性是对于写操作来说的,一个写操作,一般都需要保证:
- 幂等性
- 可用性
- ACID事务属性。
当然,这里仅仅聚焦 幂等。
为什么需要幂等性?
如果客户端重复调用,服务端会遇到如下的很多问题:
(1) 创建订单时,重复调用是否产生两笔订单?
(2) 扣减库存时,重复调是否会多扣一次?
这就是出现了幂等性问题。
按照幂等性要求,需要保证一次请求和多次请求同一个资源产生相同的副作用。
所以:创建订单时,重复调用是否产生两笔订单? 当然不能。
所以:扣减库存时,重复调是否会多扣一次? 当然不能。
这些,都是需要幂等性机制去保障。如果不支持幂等操作,那将会出现以下情况:
- 电商超卖现象
- 重复转账、扣款或付款
- 重复增加金币、积分或优惠券
等等,非常惨的。
什么样的原因导致幂等性问题?
原因之一:底层网络阻塞和延迟的问题
在系统高并发的环境下,很有可能因为网络阻塞等等问题,导致客户端不能及时的收到服务端响应,甚至是调用超时。 这时候用户会重复点击,重复请求。
在消息队列组件中,客户端也有重试机制,如果投递失败/投递超时,则会重新投递。 对于服务端来说,可能会收到重复投递的一份消息。
在RPC组件中,客户端也有重试机制,如果投递失败/投递超时,则会重试调用。 对于服务端来说,可能会重复收到通用的调用。
原因之二:用户层面的重复操作
比如下单的按键在点按之后,在没有收到服务器请求之前,用户还可以被按。
或者,用户的App闪退/人工强退,之后重新打开重新下单
需要幂等性的 两大场景
可能会发生重复请求或重试操作的场景,在分布式、微服务架构中是随处可见的。
- 网络波动:因网络波动,可能会引起重复请求
- 分布式消息消费:任务发布后,使用分布式消息服务来进行消费
- 用户重复操作:用户在使用产品时,可能无意地触发多笔交易,甚至没有响应而有意触发多笔交易
- 未关闭的重试机制:因开发人员、测试人员或运维人员没有检查出来,而开启的重试机制(如Nginx重试、RPC通信重试或业务层重试等)
大致可以分为两大类:
- 第一类:单数据CRUD操作的幂等性保证方案
- 第二类:多数据并发操作的幂等性保证方案
第一类:单数据CRUD操作的幂等性保证方案
首先,来看看单数据CRUD操作的幂等性保证方案
对于单数据CRUD操作,很多具备天然幂等性
- 新增类动作:不具备幂等性
- 查询类动作:重复查询不会产生或变更新的数据,查询具有天然幂等性
- 更新类动作:
- 基于主键的计算式Update,不具备幂等性,即
UPDATE goods SET number=number-1 WHERE id=1 - 基于主键的非计算式Update:具备幂等性,即
UPDATE goods SET number=newNumber WHERE id=1 - 基于条件查询的Update,不一定具备幂等性(需要根据实际情况进行分析判断)
- 基于主键的计算式Update,不具备幂等性,即
- 删除类动作:
- 基于主键的Delete具备幂等性
- 一般业务层面都是逻辑删除(即update操作),而基于主键的逻辑删除操作也是具有幂等性的
大家看到,对于单数据CRUD操作, 只有在下面的三个场景,保证幂等即可:
- 主键的计算式Update
- 基于条件查询的Update
- 新增类动作
第二类:多数据并发操作的幂等性保证方案
大部分,都是这种场景。
现在的应用,大部分都是微服务的。并且一个操作会涉及到多个数据的并发操作,会通过RPC调用到多个微服务。
分为两种情况:
-
多数据同步操作,一般是服务端提供一个统一的同步操作api,客户端调用该api完成,直接获得操作结果。
-
多数据异步操作,由于同步操作性能低,在高并发场景都会同步变异步,于是乎,服务端还要额外提供一个查询操作结果的api,去查询结果。第一次超时之后,调用方调用查询接口,如果查到了就走成功的流程,失败了就走失败的流程。
多数据并发操作的经典场景,参考如下:
1. 高并发抢红包
在抢一份红包的时候,点击了抢,开始异步抢红包。
抢到就有,没抢到就没有。
抢完之后,无论我们重复点击多少次,红包都会提示你已经抢过该红包了。
2.高并发下单
高并发下单的一个很基本的问题,就是要避免重复订单。
如果用户操作一次,由于超时重试等原因,一看下了两个单,甚至10个重复单。
用户不晕倒在厕所才怪。
2. 高并发支付
在支付场景,支付平台会生成唯一的支付连接,不会再次生成另外的支付连接。
如何保证幂等呢 ?
幂等性的的确保方案,非常多,大致如下图所示

一些基础性的幂等性解决方案
- 全局唯一ID
如果使用全局唯一ID,就是根据业务的操作和内容生成一个全局ID,在执行操作前先根据这个全局唯一ID是否存在,来判断这个操作是否已经执行。
如果不存在则把全局ID,存储到存储系统中,比如数据库、Redis等。如果存在则表示该方法已经执行。
使用全局唯一ID是一个通用方案,可以支持插入、更新、删除业务操作。
一般情况下,对分布式的全局唯一id,可以参考以下几种方式:
-
UUID
-
Snowflake
-
数据库自增ID
-
业务本身的唯一约束
-
业务字段+时间戳拼接
-
唯一索引(去重表)
这种方法适用于在业务中有唯一标识的插入场景中,比如在以上的支付场景中,如果一个订单只会支付一次,所以订单ID可以作为唯一标识。
这时,我们就可以建一张去重表,并且把唯一标识作为唯一索引,在我们实现时,把创建支付单据写入去重表,放在一个事务中,如果重复创建,数据库会抛出唯一约束异常,操作就会回滚。
- 插入或更新(upsert)
这种方法插入并且有唯一索引的情况,比如我们要关联商品品类,其中商品的ID和品类的ID可以构成唯一索引,并且在数据表中也增加了唯一索引。这时就可以使用InsertOrUpdate操作。
- 多版本控制
这种方法适合在更新的场景中,比如我们要更新商品的名字,这时我们就可以在更新的接口中增加一个版本号,来做幂等:
boolean updateGoodsName(int id,String newName,int version);
在实现时可以如下:
update goods set name=#{newName},version=#{version} where id=#{id} and version<${version}
- 状态机控制
这种方法适合在有状态机流转的情况下,比如就会订单的创建和付款,订单的付款肯定是在之前,这时我们可以通过在设计状态字段时,使用int类型,并且通过值类型的大小来做幂等,比如订单的创建为0,付款成功为100,付款失败为99。
在做状态机更新时,我们就这可以这样控制:
update goods_order set status=#{status} where id=#{id} and status<#{status}
以上就是保证接口幂等性的一些方法。
综合性的解决方案:一锁二判三更新
前面的方案,都是一些基础性的方案。
在实际的业务中,一般会结合起来使用。
在双11和双12的活动中,对于幂等性问题,支付宝团队摸索出来了一个综合性的解决方案:一锁二判三更新。这个方案,可以作为一个比较通用的综合性的幂等解决方案。
何为“一锁二判三更新”?
简单来说就是当任何一个并发请求过来的时候
-
- 先锁定单据
-
- 然后判断单据状态,是否之前已经更新过对应状态了
-
3.1 如果之前并没有更新,则本次请求可以更新,并完成相关业务逻辑。
-
3.2 如果之前已经有更新,则本次不能更新,也不能完成业务逻辑。

分布式式场景幂等性基础原则: 一锁、二判、三更性的核心步骤
第一步,先加锁。
高并发场景,建议是redis分布式锁,而不是低性能的DB锁,也不是CP型的 Zookeeper锁。
如果普通的redis分布式锁性能太低,该如何?
还可以考虑引入 锁的分段机制, 比如内部分成100端,总体上,就大概能线性提升 100倍。
第二步,进行幂等性判断。
幂等性判断,就是 进行 数据检查。
可以基于状态机、流水表、唯一性索引等等前面介绍的 基础方案,进行重复操作的判断。
第三步:数据更新
如果通过了第二步的幂等性判断, 说明之前没有执行过更新操作。
那么就进入第三步,进行数据的更新,将数据进行持久化。
操作完成之后, 记得释放锁, 结束整个流程。
关于redis分布式锁、 Zookeeper锁、分段锁的内容,请参见5000页《尼恩Java面试宝典》的相应的专题。
10Wqps 高并发电商 下单,如何保障幂等?
首先,来看看 订单支付的业务流程和交互流程。
图解:订单支付的业务流程和交互流程
结合下图来看看 订单支付的业务流程和交互流程。

订单支付流程, 分为 大致 的 6个步骤 :
1.下单/结算:
下单作为支付的入口,但并非起点,
支付相关的金额等信息全部来至结算,此时订单处于 未支付 状态。
2.申请支付:
用户开始申请支付,客户端调用支付服务,
此时在支付系统内产生一笔订单支付流水,这笔支付流水处于 未支付 状态。
3.发起支付:
支付服务调用 第三方支付平台,
通常, 第三方支付平台 是 钱包类的支付方式,
在发起支付这一步骤,支付平台会响应一些支付的链接,客户端会对链接进行相应的处理。
4.钱包支付:
用户进行支付,
用户 APP端直接拉去钱包进行支付。
5.支付回调:
用户完成支付之后,三方支付平台会回调 商户的支付服务 接口,通知支付结果。
6.更新订单状态:
支付服务 确认订单支付完成后,会向 订单服务同步 支付的结果。
订单服务变更服务的状态:未支付变更为 待发货。

客户端通过轮询、长轮询,或者服务端主动推送的方式,在界面上变更订单状态。

图解:支付状态的变化
如下图,从支付流水角度来分析一下支付状态的变化:

1.从未支付,到有支付结果的终态,中间还有一个中间状态:支付中
2.户通过打开钱包–》完成支付–》支付回调,这段时间的支付流水就处于:支付中
重复下单的定义、危害、应对策略
什么是重复下单
现在问题来了, 什么是重复下单?
用户在下单页面进行下单时,由于用户点击下单按钮 多次 、或者 重试策略 导致在订单服务中接收到了 两次同样 的下单请求。

重复下单带来的危害
重复下单场景,第N次的下单会对数据进行打乱,导致系统整体数据异常
- 库存数据异常
- 金额数据异常
- 优惠券数据异常
- 等等

重复下单场景,第N次的下单需要等第一次下单操作完成

重复下单带来的危害, 总结起来,有以下几点:
1.系统资源占用与性能下降
-
重复下单会占用系统资源,包括服务器、数据库等,特别是在下单高峰期,可能导致系统性能下降,响应速度变慢。
-
重复请求可能引发系统拥堵,影响其他正常用户的购物体验。
2.订单处理复杂性增加
- 商家在处理订单时,需要花费额外的时间和精力去识别、合并或取消重复订单,增加了订单处理的复杂性。
- 重复订单可能导致库存数量出现错误,进而影响后续订单的履行。
3.财务结算与对账难度增大
- 重复下单可能导致财务结算时出现混乱,需要花费更多时间和精力去核对和调整账目。
- 对账过程中需要区分哪些是重复订单,哪些是有效订单,增加了对账的难度。
4.用户体验受损
- 消费者在遇到重复下单时,可能会感到困惑和不满,影响对电商平台的信任度和忠诚度。
- 重复下单可能导致消费者错过优惠活动或促销时机,影响其购物体验。
5.数据异常与决策误导
- 重复下单的数据会干扰销售数据的准确性,可能导致商家在决策时受到误导。
- 错误的销售数据可能影响商家的库存规划、生产计划等关键决策。
6.售后服务与退换货问题
- 如果消费者对重复下单的商品申请了退换货,会增加售后服务的处理难度和成本。
- 重复订单可能导致退换货政策执行混乱,影响消费者的售后体验。
7.安全风险与欺诈行为
- 重复下单有时可能是恶意行为,如刷单、欺诈等,给电商平台带来安全风险。
- 需要重点加强对重复下单的监控和识别,以防范潜在的安全风险。
重复下单问题,主要解决办法就是做好幂等,因为在分布式系统中,我们是没有办法保证用户一定不会快速点击两次下单。
Order 服务调用 Pay 服务,刚好网络超时,然后 Order 服务开始重试机制,于是 Pay 服务对同一支付请求,就接收到了两次,而且因为轮询负载均衡算法,请求落在了不同业务服务节点,所以一个分布式系统服务,须保证幂等性。
什么场景下回发生重复下单?

场景1:客户端bug
用户短时间内多次点击下单按钮,或浏览器刷新按钮导致。
比如下单的按键在点按之后,在没有收到服务器请求之前,按键的状态没有设为已禁用状态,还可以继续点击。又或者,在触摸屏下,用户手指的点按可能被手机操作系统识别为多次点击。
场景2:超时重试
Nginx或Spring Cloud Gateway 网关层、RPC通信重试或业务层重试,进行超时重试导致的。
用户的设备与服务器之间,可能是不稳定的网路。这样一个下单请求过去,服务器不一定及时返回结果。
超时最大的问题:从用户的角度,他无法确定下单的请求是否达到服务器,还是已经到了服务器但是返回结果时数据丢失了。所以用户无法区分到底这个订单是否下单成功。
场景3:用户APP强退/闪退之后重新下单
心急的用户可能会重启流程/重启App/重启手机。在这种强制的手段下,任何技术手段都会失效。
场景4:黑客或恶意用户
黑客或恶意用户使用postman等网络工具,重复恶意提交订单。
重复下单问题与幂等性问题
重复下单问题,本质上,就是下单操作的幂等性问题
说到底,“下单防重”的问题是属于“接口幂等性”的问题范畴。
什么是幂等性问题?
所谓幂等性,就是一次操作和多次操作同一个资源,所产生的影响均与一次操作的影响相同。
"幂等(idempotent、idempotence)是一个数学与计算机学概念,常见于抽象代数中。
幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。
幂等性,用数学语言表达就是:
f(x)=f(f(x))
维基百科的幂等性定义如下:
幂等(idempotent、idempotence)是一个数学与计算机学概念,常见于抽象代数中。
幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。
这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。
例如,“setTrue()”函数就是一个幂等函数,无论多次执行,其结果都是一样的,更复杂的操作幂等保证是利用唯一交易号(流水号)实现.
通俗点说:
一个接口如果幂等,不管被调多少次,只要参数不变,结果也不变。
幂等性是对于写操作来说的,一个写操作,一般都需要保证:
-
幂等性
-
可用性
-
ACID事务属性。
上述内容选自尼恩这篇硬核文章:
如何解决接口幂等问题
接口接口幂等问题,只需记住一句口令:一锁、二判、三更新。只需严格按照这个过程,那么就可以解决接口幂等问题,总结如下:
1.一锁:先加锁,可以加分布式锁、悲观锁都可以,但是一定是一个互斥锁。
2.二判:进行幂等性判断,可以基于状态机、业务流水表、数据库唯一索引等,进行重复操作的判断。
3.三更新:对数据进行更新,将数据进行持久化。
关于幂等性方案,请参见尼恩这篇硬核文章:
如何解决重复下单问题?
方案一:提交订单按钮置灰
防止用户提交,最常规的做法,就是客户端点击下单之后,在收到服务端响应之前,按钮置灰。
前端页面直接防止用户重复提交表单,但网络错误会导致重传,很多RPC框架、网关都有自动重试机制,所以重复请求在前端侧无法完全避免。
当然,这种方案也不是真的没有价值。
这种方案可以在高并发场景下,从浏览器端去拦住一部分请求,减少后端服务器的处理压力,达到过滤流量的效果。
**方案一优点:**简单。基本可以防止重复点击提交按钮造成的重复提交问题。
**方案一缺点:**前进后退操作,或者F5刷新页面等问题并不能得到解决。
方案二:请求唯一ID+数据库唯一索引约束
首先来向大家介绍一种最简单的、成本最低的解决方案。
防重是第一步,需要识别是否重复请求,
所以,需要客户端在请求下单接口的时候,需要生成一个唯一的请求号:requestId,服务端拿这个请求号,判断是否重复请求。
核心流程图:

实现的逻辑,流程如下:
(1) 当用户进入订单提交界面的时候,调用后端获取请求唯一ID,并将唯一ID值埋点在页面里面。
(2) 当用户点击提交按钮时,后端检查这个唯一ID是否用过,如果没有用过,继续后续逻辑;如果用过,就提示重复提交。
(3) 最关键的一步操作,就是把这个唯一ID 存入业务表中,同时设置这个字段为唯一索引类型,从数据库层面做防止重复提交。
对于下单流量不算高的系统,可以采用这种 请求唯一ID + 数据表增加唯一索引约束`的方式,来防止接口重复提交!
但是这个并发量太低,10wqps高并发, 这个根本没法满足。
方案三:reids分布式锁+请求唯一ID
在上一个方案中,我们详细的介绍了对于下单流量不算高的系统,可以通过 请求唯一ID+数据表增加唯一索引约束`这种方案来实现防止接口重复提交!
随着业务的快速增长,每一秒的下单请求次数,可能从几十上升到几百甚至几万。
面对这种下单流量越来越高的场景,此时数据库的访问压力会急剧上升,数据库会成为整个下单流程的瓶颈。
对于这样的场景,我们可以选择引入缓存中间件来缓解数据库高并发场景下的压力,
下面,我们以引入redis缓存中间件,向大家介绍具体的解决方案。

流程如下:
(1) 当用户进入订单提交界面的时候,调用后端获取请求唯一 ID,同时后端将请求唯一ID存储到redis中再返回给前端,前端将唯一 ID 值埋点在页面里面。
(2) 当用户点击提交按钮时,后端检查这个请求唯一 ID 是否存在,如果不存在,提示错误信息;如果存在,继续后续检查流程。
(3) 使用redis的分布式锁服务,对请求 ID 在限定的时间内进行加锁,如果加锁成功,继续后续流程;如果加锁失败,提示说明:服务正在处理,请勿重复提交。
(4) 最后一步,如果加锁成功后,需要将锁手动释放掉,以免再次请求时,提示同样的信息;同时如果任务执行成功,需要将redis中的请求唯一 ID 清理掉。
至于数据库是否需要增加字段唯一索引,理论上可以不用加,如果加了更保险。
这个通过扩展,可以满足 10wqps高并发要求。
具体的扩展方案, 即将在 《尼恩Java面试宝典》 配套视频 发布。
方案四:reids分布式锁+token
在上一个方案中,每次提交订单的时候,都需要调用服务端获取请求唯一ID:requestId,然后才能提交,这里面存在以下问题:
下单链路中,多了的一次请求, 这一次请求专门用于请求 request id。这次请求是否可以减少呢?
当然是可以的,比如, 可以用户的请求的特征数据,根据特定规则生成token,来替代 那个专用的 request id。
而不用专门去来减少一次客户端与服务端之间的交互次数,提高下单流程效率。
特定规则生成token, 比如说,可以组合一些核心参数,去生成token, 核心参数包括:
应用名+接口名+方法名+请求参数签名(请求header、body参数,取SHA1值)

组合一些核心参数,去生成token ,大致 流程如下:
(1) 用户点击提交按钮,服务端接受到请求后,通过规则计算出本次请求唯一ID值
(2) 使用redis的分布式锁服务,对请求 ID 在限定的时间内尝试进行加锁,如果加锁成功,继续后续流程;如果加锁失败,说明服务正在处理,请勿重复提交。
(3) 最后一步,如果加锁成功后,需要将锁手动释放掉,以免再次请求时,提示同样的信息。
方案四和方式三的最大不同,在于 唯一请求 ID 的生成 环节,
方案四 放在服务端通过组合来实现 唯一请求 ID 的生成 ,在保证防止接口重复提交的效果同时,也可以显著的降低接口测试复杂度!
方案四的性能,比方案三更高。
实操:reids分布式锁+token 解决重复下单的问题
只讲理论,是耍流氓
40岁老架构师一直强调, 实操,实操,实操才是王道
比如咱们社群的 k8s 实操:

比如咱们社群的 AT+TCC模式混合事务实操 ):
此实操即将配合 《尼恩Java面试宝典视频》发布

接下来,咱们开始 reids分布式锁+token 解决重复下单的问题的实操
此实操即将配合 《尼恩Java面试宝典视频》发布
实操step1:使用AOP进行 BizToken 的无入侵生成
定义一个注解 BizToken

在业务层或者 控制层,进行BizToken 的使用

实操step2:编写服务验证逻辑,通过 aop 代理方式实现

此 aop 切面的 具体的实操演示,请参见 《尼恩Java面试宝典》 视频
实操step3:使用redission分布式锁保证幂等
在BizToken校验逻辑用到了redis分布式锁保证幂等,
redission分布式锁 具体实现逻辑如下:

通过封装 redission的分布式锁来实现 锁的功能:

具体的实现,委托到 redission的分布式锁来实现

具体的实操演示,请参见 《尼恩Java面试宝典》 视频
10wqps高并发,防止重复下单总结
防止重复下单,本质上就是先做重复判断,然后服务端做好幂等性控制,结合实际业务场景选择相应的方案。
实现幂等性需要先理解自身业务需求,根据业务逻辑来实现这样才合理,处理好其中的每一个结点细节,完善整体的业务流程设计,才能更好的保证系统正常运行。
五: 自定义 AOP 幂等组件深度解析
5.1 组件架构设计
自定义的幂等性组件采用了一种简洁而高效的架构设计,将复杂的幂等性保障逻辑封装在框架内部,让开发者只需通过简单的注解即可实现幂等控制。

5.1.1 核心组件
- RepeatSubmit 注解:声明式地定义幂等控制参数
- RepeatSubmitAspect 切面:AOP实现,拦截方法执行
- IdempotentAutoConfiguration:自动配置类,集成到Spring Boot
5.2 核心实现原理
5.2.1 注解定义
@Inherited
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RepeatSubmit {
/**
* 间隔时间(ms),小于此时间视为重复提交
*/
int interval() default 5000;
TimeUnit timeUnit() default TimeUnit.MILLISECONDS;
/**
* 提示消息 支持国际化 格式为 {code}
*/
String message() default "{repeat.submit.message}";
}
关键参数说明:
interval:重复提交间隔时间,默认5000毫秒timeUnit:时间单位,默认毫秒message:重复提交时的提示消息,支持国际化
5.2.2 AOP切面实现
@Aspect
public class RepeatSubmitAspect {
private static final ThreadLocal<String> KEY_CACHE = new ThreadLocal<>();
@Before("@annotation(repeatSubmit)")
public void doBefore(JoinPoint point, RepeatSubmit repeatSubmit) throws Throwable {
// 1. 计算间隔时间
long interval = repeatSubmit.timeUnit().toMillis(repeatSubmit.interval());
if (interval < 1000) {
throw new ServiceException("重复提交间隔时间不能小于'1'秒");
}
// 2. 获取请求参数
HttpServletRequest request = ServletUtils.getRequest();
String nowParams = argsArrayToString(point.getArgs());
// 3. 生成唯一标识
String submitKey = StringUtils.trimToEmpty(request.getHeader(SaManager.getConfig().getTokenName()));
submitKey = SecureUtil.md5(submitKey + ":" + nowParams);
String cacheRepeatKey = GlobalConstants.REPEAT_SUBMIT_KEY + request.getRequestURI() + submitKey;
// 4. 检查是否重复提交
if (RedisUtils.setObjectIfAbsent(cacheRepeatKey, "", Duration.ofMillis(interval))) {
KEY_CACHE.set(cacheRepeatKey);
} else {
String message = repeatSubmit.message();
if (StringUtils.startsWith(message, "{") && StringUtils.endsWith(message, "}")) {
message = MessageUtils.message(StringUtils.substring(message, 1, message.length() - 1));
}
throw new ServiceException(message);
}
}
@AfterReturning(pointcut = "@annotation(repeatSubmit)", returning = "jsonResult")
public void doAfterReturning(JoinPoint joinPoint, RepeatSubmit repeatSubmit, Object jsonResult) {
if (jsonResult instanceof R<?> r) {
try {
// 成功则不删除redis数据 保证在有效时间内无法重复提交
if (r.getCode() == R.SUCCESS) {
return;
}
RedisUtils.deleteObject(KEY_CACHE.get());
} finally {
KEY_CACHE.remove();
}
}
}
@AfterThrowing(value = "@annotation(repeatSubmit)", throwing = "e")
public void doAfterThrowing(JoinPoint joinPoint, RepeatSubmit repeatSubmit, Exception e) {
RedisUtils.deleteObject(KEY_CACHE.get());
KEY_CACHE.remove();
}
// ... 参数处理方法
}
关键流程解析:
(1) 前置拦截 (@Before):
- 计算允许的最小间隔时间
- 获取请求参数并生成唯一标识
- 使用
RedisUtils.setObjectIfAbsent原子操作检查是否为重复提交
(2) 后置处理 (@AfterReturning):
- 如果业务执行成功,保留Redis中的标识(防止有效期内重复提交)
- 如果业务执行失败,删除Redis中的标识(允许重试)
(3) 异常处理 (@AfterThrowing):
- 发生异常时,清理Redis中的标识和ThreadLocal资源
5.2.3 唯一标识生成 策略
自定义的唯一标识生成,需要设计得 非常巧妙,结合了多种因素:
String submitKey = StringUtils.trimToEmpty(request.getHeader(SaManager.getConfig().getTokenName()));
submitKey = SecureUtil.md5(submitKey + ":" + nowParams);
String cacheRepeatKey = GlobalConstants.REPEAT_SUBMIT_KEY + url + submitKey;
(1) 基础信息:
- 从请求头中获取Token(使用Sa-Token框架)
- 将请求参数转换为字符串
(2) MD5哈希:
- 将Token与请求参数拼接后进行MD5哈希
- 确保生成的标识具有唯一性和固定长度
(3) 最终Key:
- 前缀
REPEAT_SUBMIT_KEY - 加上请求URL
- 加上MD5哈希值
这种设计确保了:
- 相同用户、相同请求参数、相同URL的请求会生成相同的标识
- 不同用户或不同参数的请求会生成不同的标识
- 即使参数顺序不同,只要内容相同,也会生成相同的标识(因为使用了参数序列化)
5.2.4 自定义起步依赖
这里后面进行 项目实操
5.3 使用示例
引入起步依赖,这里后面进行 项目实操
5.3.1 基本用法
@RestController
public class OrderController {
@PostMapping("/order")
@RepeatSubmit(interval = 5, timeUnit = TimeUnit.SECONDS)
public R<?> createOrder(@RequestBody OrderRequest request) {
// 业务逻辑
return R.ok();
}
}
5.3.2 自定义配置
@PostMapping("/payment")
@RepeatSubmit(
interval = 10,
timeUnit = TimeUnit.SECONDS,
message = "{custom.repeat.submit.message}"
)
public R<?> processPayment(@RequestBody PaymentRequest request) {
// 支付业务逻辑
return R.ok();
}
5.4 优势与局限
优势:
- 开箱即用:只需添加注解,无需编写额外代码
- 无缝集成:与Spring Boot和Sa-Token框架完美集成
- 自动管理:自动处理标识的创建、检查和清理
- 灵活配置:通过注解参数灵活调整幂等策略
- 异常安全:确保在各种异常情况下资源都能正确清理
局限:
- 框架依赖:依赖于Spring Boot和Sa-Token
- 定制性有限:对于特殊场景可能需要扩展
- 仅适用于HTTP请求:不适用于消息队列等场景
- 无法处理长时间任务:固定间隔时间可能不适合长时间业务
六、自定义 幂等性组件的 最佳实践建议
(1) 合理设置间隔时间:
- 普通业务:5-10秒
- 支付等关键业务:15-30秒
- 避免设置过短(<1秒),可能导致正常重试失败
(2) 结合业务场景:
- 对于创建类操作,建议使用较短间隔
- 对于查询类操作,一般不需要幂等控制
- 对于长时间任务,考虑结合状态机
(3) 错误提示友好:
- 自定义国际化消息,提供明确的用户提示
- 避免直接暴露技术细节
(4) 监控与调优:
- 监控重复提交拦截率
- 根据实际数据调整间隔时间
- 定期检查Redis中幂等标识的使用情况
七 自定义幂等性组件的 业务代码规范
7. 1 正确做法
// ✅ 正确做法:使用幂等注解
@PostMapping("/payment")
@Idempotent(bizType = "PAYMENT", expireTime = 120)
public ResponseEntity<?> processPayment(@RequestBody PaymentRequest request) {
// 业务逻辑
}
// ✅ 正确做法:手动实现幂等控制
public void processOrder(String requestId, Order order) {
if (idempotentService.isDuplicate(requestId)) {
throw new DuplicateRequestException("重复请求");
}
try {
// 业务逻辑
} finally {
idempotentService.markProcessed(requestId);
}
}
7. 2 错误做法
// ❌ 错误做法:无幂等控制
@PostMapping("/payment")
public ResponseEntity<?> processPayment(@RequestBody PaymentRequest request) {
// 业务逻辑
}
// ❌ 错误做法:幂等控制不完整
public void processOrder(String requestId, Order order) {
if (idempotentService.isDuplicate(requestId)) {
return; // 未抛出明确异常
}
// 业务逻辑
}
// ❌ 错误做法:无异常处理
public void processOrder(String requestId, Order order) {
idempotentService.validate(requestId);
// 业务逻辑(可能抛出异常)
idempotentService.markProcessed(requestId);
}
八、监控与应急处理
…由于平台篇幅限制, 剩下的内容(5000字+),请参参见原文地址

被折叠的 条评论
为什么被折叠?



