不知道你有没有遇过这种场景:用户在前台下单时手快了那么一下,连点三次“提交订单”,后台就莫名其妙生成了三笔订单;或者支付回调因为网络抖动被重复投递,结果同一笔订单被重复处理了两遍。这种问题在线上排查起来非常麻烦——不是必现,得看用户的网速和手速,复现成本高,而且一旦出了事,基本都是资金流水级别的故障。我自己在维护交易类系统时就被这类问题坑过,所以后来专门用SpringBoot项目做了一轮接口防抖(防重复提交)和接口幂等性的改造,把零零散散的方案收敛成一套可以复用的实操模板。这篇文章就把这套东西全部拆开讲清楚,包含完整的注解方案、Redis实现、数据库兜底策略,以及每个环节容易踩的坑,适合正在开发订单、支付、库存这类“重复请求会产生副作用”业务的后端同学参考。
1. 先把概念说透:接口防抖和接口幂等性不是一回事
很多刚接触这个需求的人会把防抖和幂等性混为一谈,感觉都是“让同一个请求只生效一次”。我最早也这么理解,但真正动手设计之后才发现,这两者在语义、防护层次和实现难度上是有明显差异的,这里先帮大家把概念理清楚。
1.1 防抖解决的是“短时间急迫入侵”,幂等解决的是“事后重叠执行”
接口防抖,通俗讲就是禁止在极短的时间窗口内,用相同参数连续重复请求。比如用户双击提交按钮、前端超时后自动重试、爬虫轮询触发写入接口,这些都属于“突发式”的重复流量。防抖的核心目标是在入口处就把这些请求拦掉,只要第一个请求还在处理中,第二第三个相同请求就直接返回“请勿重复提交”。
接口幂等性,则是一个更加泛化的概念。它指的是同一个接口用相同参数执行一次和执行多次,最终结果完全一致,且不产生副作用。防重检查可能只挡住几秒内的重复请求,但如果请求在分布式环境下被延迟重放(比如消息队列重投、RPC超时重试),等第一个请求处理完了,第二个请求才慢悠悠地到达入口,这时防抖的时间窗口早过期了——如果没有幂等设计,重复处理就会真实发生。
用生活里的事来类比一下:防抖相当于公司前台对“一分钟内连续按两次门铃的快递员”说等一下;幂等相当于就算你把同一份文件打印了两遍,打印机也不会吐两张,因为底层对“已处理的文件编号”做了登记。前者靠限流式拦截,后者靠状态记录。
1.2 什么样的接口必须要做防抖或幂等设计
不是所有接口都需要做这套处理,比如纯查询接口本身是天然幂等的,多查一次不影响系统。需要重点关注的是那些具有“副作用”的写操作接口,我总结了一下常见的类型:
- 创建型接口:下单、提交订单、开票、生成优惠券等,重复提交会直接造成脏数据。
- 状态流转型接口:订单支付回调、发货、退款、审核通过等,重复请求会驱动业务流程错误地推进。
- 分布式消息消费:MQ消费端在极端条件下可能重复投递,如果消费逻辑是修改余额、扣减库存,不做幂等就会造成严重资损。
- 用户敏感操作:修改手机号、绑定银行卡、提现请求,重复操作轻则数据错乱,重则有资金合规风险。
我们的SpringBoot接口防抖方案主要以Redis作为存储层,以自定义注解配合AOP切面实现,同时用数据库防重表兜底幂等,双管齐下。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 轻量级防抖方案:SpringBoot自定义注解 + AOP + Redis
2.1 为什么选注解 + AOP,而不是写工具类逐个调用
我最初想过一种更“直接”的实现方式,就是写一个静态方法,在业务代码的所有需要防重的地方手动调用。但试了几个接口之后马上放弃了——核心原因有三点:第一,侵入性太强,每个接口都要加几行代码,核心业务逻辑被工具逻辑污染了;第二,容易出现遗漏,开发新接口时一旦忘了调方法,防重就形同虚设;第三,如果后面要统一修改防重策略(比如调整时间窗口、更换过期时间),得把所有调用点都找出来逐个改。
而使用自定义注解加AOP切面的话,对业务代码几乎没有任何侵入,只要在Controller方法或Service方法上标一个@NoRepeatCommit,就自动具备防重能力。新增接口时,只要记得加注解,逻辑就自动覆盖。策略调整时,只需要改切面一处,全盘生效。这也是目前SpringBoot项目里最主流的做法。
2.2 定义防重注解:把灵活的策略参数暴露给使用方
注解需要支持自定义参数,因为不同接口对“时间窗口”的需求不一样。比如订单提交接口,前端的重复点击通常发生在两三秒内,但如果是网络超时导致的自动重试,可能间隔5秒以上,这时候时间窗口就需要调大。我设计注解时主要暴露了下面几个字段:
java复制@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoRepeatCommit {
/**
* 防重的时间窗口,单位:秒
* 默认3秒,表示3秒内的相同请求会被拦截
*/
long timeout() default 3L;
/**
* 提示用户的信息
*/
String message() default "操作太频繁,请稍后重试";
}
timeout就是前文说的时间窗口,message是当请求被拦截后返回给前端的提示信息。不把这两种参数定死,是我做了几个接口以后得出的教训——给用户提交按钮加了1秒的防抖,结果网络抖动一旦发生,前端重试的间隔比1秒长,防抖完全没起到效果,所以干脆把窗口时间开放给业务方自己控制。
2.3 实现AOP切面:防重Key生成与Redis原子写入是核心
有了注解之后,重点就是AOP切面的实现。切面需要完成这几件事:解析注解参数,生成唯一的防重Key,调用Redis写入并判断是否第一次请求,根据判断结果决定是放行还是拦截。
java复制@Aspect
@Component
public class NoRepeatCommitAspect {
private static final String PREFIX = "nr:";
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Around("@annotation(noRepeatCommit)")
public Object around(ProceedingJoinPoint joinPoint, NoRepeatCommit noRepeatCommit) throws Throwable {
// 生成防重Key
String key = buildKey(joinPoint);
long timeout = noRepeatCommit.timeout();
// SET NX EX 原子操作,key不存在时才能设置成功
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(key, "1", timeout, TimeUnit.SECONDS);
if (success == null || !success) {
// 说明已存在相同Key,判定为重复请求
throw new RuntimeException(noRepeatCommit.message());
}
try {
// 放行并执行真实业务
return joinPoint.proceed();
} catch (Throwable throwable) {
throw throwable;
}
}
private String buildKey(ProceedingJoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Met
