自研一个 幂等性 AOP组件(10Wqps),设计一个通用的 最系统最透彻的幂等性方案

本文 的 原文 地址

原始的内容,请参考 本文 的 原文 地址

本文 的 原文 地址

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,不一定具备幂等性(需要根据实际情况进行分析判断)
  • 删除类动作:
    • 基于主键的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的活动中,对于幂等性问题,支付宝团队摸索出来了一个综合性的解决方案:一锁二判三更新。这个方案,可以作为一个比较通用的综合性的幂等解决方案。

何为“一锁二判三更新”?

简单来说就是当任何一个并发请求过来的时候

    1. 先锁定单据
    1. 然后判断单据状态,是否之前已经更新过对应状态了
  • 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字+),请参参见原文地址

原始的内容,请参考 本文 的 原文 地址

本文 的 原文 地址

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值