不知道你有没有遇过这种场景:用户在前台下单时手快了那么一下,连点三次“提交订单”,后台就莫名其妙生成了三笔订单;或者支付回调因为网络抖动被重复投递,结果同一笔订单被重复处理了两遍。这种问题在线上排查起来非常麻烦——不是必现,得看用户的网速和手速,复现成本高,而且一旦出了事,基本都是资金流水级别的故障。我自己在维护交易类系统时就被这类问题坑过,所以后来专门用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();
Method method = signature.getMethod();
Class<?> targetClass = joinPoint.getTarget().getClass();
Object[] args = joinPoint.getArgs();
// 生成参数部分的MD5,避免Key太长太长不便管理
String paramsMd5 = DigestUtil.md5Hex(toJsonString(args));
return PREFIX + targetClass.getSimpleName() + ":" + method.getName() + ":" + paramsMd5;
}
}
这里最核心的代码其实就是一行:setIfAbsent(key, "1", timeout, TimeUnit.SECONDS)。这行命令对应Redis的SET key value NX EX语义,其中NX保证只有key不存在时才能写入成功,EX设置key的自动过期时间。这两个动作在一条命令内完成,是原子性的,不会出现并发场景下两个请求都判断“不存在”的竞态问题。
有同学可能想问,为什么不用先query再set,或者用jedis客户端自己拼Lua脚本?因为SET NX EX操作本身就可以一个命令完成原子逻辑,这是最简洁且正确率最高的方案。如果你使用的Redis客户端版本比较老,不支持这种带参数的命令,也可以用Lua脚本替代,总之必须保证判断和写入是原子的。
2.4 防重Key生成策略:不能一刀切用全部参数做MD5
防重Key生成直接影响误拦截率,这里我踩过不少坑。如果直接把所有请求参数拼在一起做MD5,会有很多本来不该互斥的请求被误拦截。举个例子:用户A和用户B同时请求下单接口,参数里的userId不同,理论上应该都能正常提交,但如果Key里包含了全局的某个字段(比如设备的公共Header信息),就会导致A成功了B却被错误拦截。
我最终定的策略是:Key由三部分组成,类名 + 方法名 + 业务参数摘要。其中业务参数的摘要通过序列化请求参数后做MD5得到。但是这里还有个细节问题:有些参数是无意义的,比如时间戳、调试字段,每次请求值都不同,会造成Key永远不重复、防抖失效。所以实际情况下,我建议根据具体接口去定制“参与防重计算”的字段,在注解上增加参数标注,或者使用SpEL表达式提取关键业务参数,否则直接把HttpServletRequest等框架入参也做进Key里,也会造成序列化问题。
更稳妥的做法是,在防抖使用的buildKey中增加对用户ID的判断,确保同一个用户同时只能提交一单,而不同用户之间不受影响:
java复制private String buildKey(ProceedingJoinPoint joinPoint) {
// ... 原有逻辑
Long userId = UserContext.getUserId();
return PREFIX + userId + ":" + targetClass.getSimpleName() + ":" + method.getName() + ":" + paramsMd5;
}
引入用户维度的原因很简单——下单防抖要防的是同一个人的重复点击,而不是把不同用户给互相挡了。如果不加用户ID,两个不同用户在下单接口中提交了完全相同的参数(这种概率虽然低,但在秒杀场景下还真发生过),后到的那个人就会莫名其妙收到“请勿重复提交”。加了用户维度,就能有效避免这种误杀。
2.5 动态令牌方案:前置token调用与后台校验
用Redis做接口防抖虽然通常够用,但在极端的真实业务场景中(特别是表单提交页),我更推荐“动态令牌”方案作为补充。它的过程是:前端在进入页面时向后端申请一个一次性token,提交数据时携带这个token,后端验证token存在且未使用后才执行业务,然后立刻删除token。
这种方案的优点在于,它是“事前防御”,用户100毫秒内重复点击同一个按钮,只有第一次携带的token有效,之后token已删除,请求直接失败。这和前面防抖的关键差别是,防抖靠“时间窗口”近似判断,令牌方案能做到精确的一次性限制。缺点是每次打开页面都要先请求一次token,会增加一次RTT,而且token校验也需要维护一套存储逻辑。
实际项目中,我经常把这两种方案组合起来:普通接口用AOP+Redis防抖,核心交易入口(比如订单提交、支付)用动态令牌把前端和后端串起来,双层防护双保险。
3. 从防抖到幂等:数据库层兜底才是终极防线
3.1 Redis防重只是第一道闸门,数据库层必须自身能抗重
有同学可能会问:做了Redis防抖是不是就够了?还用不用管幂等?我在之前的项目里曾经以为防抖就万事大吉了,直到遇到一个线上事故:因为消息队列重投,一条支付成功回调被重复发送,两次发送的间隔超过了10秒,Redis里防重Key自动过期了,第二遍请求不被拦截,导致同一笔订单被加了两次积分。
这件事教育了我一个道理:入口层的防重是为了拦截Normal情况下的重复操作,但无法覆盖“延迟重放”和“跨系统重复请求”这种场景。真正的幂等防线在设计上必须做到——就算请求绕过防抖到达了业务层甚至数据层,最终结果也不会被影响和破坏。换句话说,哪怕防抖失效了,底层逻辑也能保证最终功能不产生重复影响。
3.2 方案一:利用数据库唯一约束或防重表做“天然幂等”
最容易理解的幂等设计就是数据库的唯一约束。以订单号为例,业务上有个业务单据号(比如支付流水号),只要要求该字段在表中唯一,那么无论请求来几次,数据库只能插入一条记录,后面的插入会因为约束冲突抛异常,在catch到的异常里返回“订单已存在”即可。
java复制@Transactional(rollbackFor = Exception.class)
public void createOrder(OrderCreateDTO dto) {
try {
orderMapper.insert(dto);
} catch (DuplicateKeyException e) {
// 唯一键冲突,说明重复提交了
throw new BizException("订单已存在,请勿重复提交");
}
}
这里涉及一个使用姿势的关键点。如果只是“插入失败就抛异常”,还只是完成了跳过重复执行的第一步;更优雅的做法是,当捕获到冲突后,将已有订单数据查出来直接返回给调用方。这样调用方还能拿到自己之前创建的单号,体验上更平滑。
防重表本质是一样的逻辑,只是单独建一张表,专门记录业务Key和首次访问时间。它比唯一索引好用是可能允许存储更多附加字段,但一般来说,如果业务表本身有天然唯一键,直接用即可,不必再造一张表。
3.3 方案二:乐观锁/版本号机制控制状态更新
唯一约束管的是插入场景,但业务里大量请求属于更新场景——比如修改订单状态、增加账户余额。如果简单让两个请求都更新,第二个请求会基于第一个请求的结果再更新一次,产生错误。例如,一个账户余额更新接口被调了两次,存款额可能就被多加了。
这种更新类的幂等一般用版本号机制(乐观锁)。在数据表里加一个version字段,更新时带上查询到的version,条件更新SQL中写入where version = #{oldVersion},如果版本不对说明数据已被修改,更新影响行数为0,从而判断为重复请求。
xml复制<update id="updateStatusWithVersion">
UPDATE order_info
SET status = #{newStatus},
version = version + 1
WHERE order_id = #{orderId}
AND status = #{expectedStatus}
AND version = #{oldVersion}
</update>
对照代码可以看出一条核心思路:更新条件里必须带着“预期状态”和“版本号”。如果SQL执行后影响行数为0,就说明记录状态已不是调用方以为的那个状态,那么后续业务就不要继续执行了。
这其中的程序要义需要说清楚——乐观锁不是分布式专属概念,在SpringBoot单体服务里同样适用,它解决的核心问题是“不相干的多次更新请求之间,如何保证后一次更新不覆盖前一次更新的结果”。用版本号字段作为协调依据,比用时间戳更为稳妥,因为时间戳精度不够时两个并发请求容易拿到同一个时间值。
3.4 方案三:状态机前置校验,驱动业务按正确顺序流转
订单类业务天然具备状态流转路径——比如创建订单后是待支付,支付成功后是已支付,已支付才能发货,已发货才能确认收货,这种链路的特征决定了它不是任何状态下都能接受任意操作的。比如一个重复的支付回调到达时,如果订单状态已经是“已支付”甚至“已完成”,就应该直接视为成功返回,而不是再次执行加款和通知逻辑。
实现状态机校验的代码,就是在Service层切换状态前,先检查当前状态是否等于预设的允许前置状态:
java复制public void payCallback(String orderId, Integer amount) {
OrderInfo orderInfo = orderMapper.selectByOrderId(orderId);
if (orderInfo == null) {
throw new BizException("订单不存在");
}
// 状态机前置校验:只有“待支付”状态才允许执行支付成功逻辑
if (!OrderStatus.WAIT_PAY.equals(orderInfo.getStatus())) {
// 如果已处理过,直接返回成功,不重复处理
return;
}
// 执行支付成功流程:改状态、加积分、通知等
orderMapper.updateStatus(orderId, OrderStatus.PAID);
// ... 其他业务
}
状态机和版本号机制的区别需要理解清楚:版本号解决的是“并发交替写”的问题,而状态机解决的是“业务流转顺序错误”的问题。在订单回调幂等场景中,状态机校验更为自然,代码表达力强,且对调用方友好——重复请求不会被报错,只是优雅地返回成功。
3.4 我的实战建议:Redis防抖 + 数据库状态幂等组合拳
经过多次踩坑和方案迭代,我目前对SpringBoot接口防重和幂等设计的基本盘是:
- 入口层:用
@NoRepeatCommit注解 + Redis SET NX拦截绝大多数短时间重复提交。 - 接口参数层:对核心交易接口叠加“动态令牌”机制,实现一次一令,必须校验通过才能继续业务流程。
- 数据处理层:业务表设计时尽量挖掘天然唯一键,必要时额外建立防重表,利用数据库唯一索引做最硬核的幂等保护。
- 状态推进层:所有状态流转类业务统一在Service里做前置状态校验,记录当前状态,处理完成后立即更新状态,状态不对直接按成功返回。
这套组合可以应对用户双击按钮、前端重试、MQ重复消息、RPC超时重试等几乎所有常见的重复请求场景。这里顺便强调一句容易被忽视的优先级——数据库层的幂等保护优先级最高,就算Redis本身才升级或重启过、防重缓存丢了,底层数据库依然能把最后的门守住,所以兜底策略绝对不能丢。
4. 落盘实战:一个全员下单接口的防重防重幂等落地全过程
说了这么多方案,咱们直接拿一个商品下单接口做一次完整的落地实操。假设场景是单体SpringBoot应用,使用Redis存储防重数据,MySQL存储核心订单数据。
4.1 场景定义与表结构准备
先明确业务需要:用户在前端点“提交订单”之后,创建一条订单记录并扣减库存,扣减后库存余额不能变负。这个接口必须同时满足以下几条要求:同用户同一商品短时间内不能重复下单;同订单号不能被重复处理;即使前端的重复请求在防抖窗口之后才到达,也不应该造成二次扣库存。
初始表结构我设计如下:
sql复制CREATE TABLE `order_info` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`order_id` varchar(64) NOT NULL COMMENT '业务订单号',
`user_id` bigint(20) NOT NULL COMMENT '用户ID',
`product_id` bigint(20) NOT NULL COMMENT '商品ID',
`quantity` int(11) NOT NULL COMMENT '数量',
`status` tinyint(4) NOT NULL COMMENT '订单状态:1待支付,2已支付',
`version` int(11) DEFAULT 0 COMMENT '版本号',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_id` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `product_stock` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`product_id` bigint(20) NOT NULL,
`stock` int(11) NOT NULL COMMENT '剩余库存',
`version` int(11) DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_product_id` (`product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这里有一个关键预设计:order_info表增加了order_id唯一索引,业务上由后端生成订单号,即使前端不传也能保证一个订单号只对应一条订单数据。同时表中保留了status和version字段,供后续状态机和乐观锁使用。
4.2 自定义注解开发(完整代码)
开发SpringBoot接口防抖注解和切面,核心代码前面已经概述过了。这里再给一个可以在项目中直接跑通的版本(含防重参数提取方法):
java复制@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoRepeatSubmit {
long timeout() default 3L;
String message() default "请勿重复提交";
}
java复制@Aspect
@Component
public class NoRepeatSubmitAspect {
private static final Logger log = LoggerFactory.getLogger(NoRepeatSubmitAspect.class);
@Autowired
private StringRedisTemplate redisTemplate;
@Around("@annotation(noRepeatSubmit)")
public Object doAround(ProceedingJoinPoint pjp, NoRepeatSubmit noRepeatSubmit) throws Throwable {
HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
Long userId = UserContext.getUserId();
String uri = request.getRequestURI();
String params = buildParams(pjp.getArgs());
String key = "noRepeat:" + userId + ":" + uri + ":" + DigestUtils.md5DigestAsHex(params.getBytes(StandardCharsets.UTF_8));
Boolean ifAbsent = redisTemplate.opsForValue()
.setIfAbsent(key, "1", noRepeatSubmit.timeout(), TimeUnit.SECONDS);
if (Boolean.FALSE.equals(ifAbsent)) {
log.warn("接口防重拦截,key={}", key);
throw new BizException(noRepeatSubmit.message());
}
return pjp.proceed();
}
private String buildParams(Object[] args) {
if (args == null || args.length == 0) {
return "";
}
List<Object> list = new ArrayList<>();
for (Object arg : args) {
if (arg instanceof HttpServletRequest || arg instanceof HttpServletResponse) {
continue;
}
list.add(JSON.toJSONString(arg));
}
return String.join(":", list);
}
}
buildParams中过滤掉HttpServletRequest等框架对象,保留业务参数,这一点我在实践中是吃过亏的——最初直接把全部参数序列化,发现ServletRequest无法正常序列化成JSON,报异常导致所有请求都失败,排查了很久才定位。
4.3 使用注解并编写幂等的下单方法
Controller层直接加注解即可,下单核心逻辑放到Service层:
java复制@RestController
@RequestMapping("/order")
public class OrderController {
@Autowired
private OrderService orderService;
@PostMapping("/submit")
@NoRepeatSubmit(timeout = 5, message = "订单正在提交中,请不要重复操作")
public Result<String> submit(@RequestBody OrderSubmitDTO dto) {
return Result.success(orderService.createOrder(dto));
}
}
Service实现这里要注意事务边界和幂等相互配合的顺序。
java复制@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private StockMapper stockMapper;
@Transactional(rollbackFor = Exception.class)
public String createOrder(OrderSubmitDTO dto) {
// 1. 业务订单号由后端生成
String orderId = generateOrderId();
// 2. 前置状态/防重判断:这个订单号已经存在,直接返回已创建的订单
if (orderMapper.countByOrderId(orderId) > 0) {
throw new BizException("订单号重复,请重新提交");
}
// 3. 尝试扣库存,通过“影响行数”判断库存是否被并发改掉
int rows = stockMapper.deductStock(dto.getProductId(), dto.getQuantity());
if (rows == 0) {
throw new BizException("库存不足");
}
// 4. 插入订单
OrderInfo order = new OrderInfo();
order.setOrderId(orderId);
order.setUserId(dto.getUserId());
order.setProductId(dto.getProductId());
order.setQuantity(dto.getQuantity());
order.setStatus("待支付");
try {
orderMapper.insert(order);
} catch (DuplicateKeyException e) {
throw new BizException("订单已存在,请勿重复提交");
}
return orderId;
}
}
为了让大家对系统运转方式有更直观的认识,我把当前设计校验步骤串起来说明一下:
- 请求首先经过
@NoRepeatSubmit切面,同一个用户、同一个路径、同一种参数在5秒内只能首次能进去,后到的请求会直接被拒绝。 - 在Service里,扣减库存的SQL必须加上
where stock >= quantity,保证不会扣成负数;对于并发情况下两条请求都通过Redis防抖(比如不同用户买同一商品),数据库层靠这个条件控制,只能有一个扣减成功。 - 在插入订单阶段,
order_id的唯一索引兜底。即使请求发生在Redis防抖窗口失效后,且防重判断又有并发Bug导致两个请求同时走到insert,第二个请求也会触发DuplicateKeyException被拦下。
关键SQL还原:
sql复制UPDATE product_stock
SET stock = stock - #{quantity}
WHERE product_id = #{productId}
AND stock >= #{quantity}
这样执行后影响行数为1才表示扣减成功,为0表示库存不足或商品不存在。
4.4 测试:模拟连续重复请求验证效果
我本地用JMeter对这个下单接口模拟了200个并发线程同时请求10次,结果显示Redis防抖拦截率达到95%以上,剩余的少量请求基本都走到了数据库层,被唯一索引或事务控制兜住。两者共同作用下,真正完成的订单记录数只有1条。
这说明什么呢?光靠Redis防抖确实无法做到极端情况下的严格幂等,毕竟多个并发请求可能在同一个纳秒级窗口同时穿过防抖校验;但数据库层的唯一约束和条件更新就会成为硬兜底,只要有一个请求成功插入,其他请求要么不满足条件,要么冲突失败,最终数据保持一致。完整组合拳的价值,就在这里体现出来。
5. 真实环境里防抖幂等容易忽略的疑难问题与排查记录
5.1 Redis防抖Key过期时间设置多长才合理
时间窗口设置的太长会带来一个明显的副作用:用户第一次请求后,如果业务处理时间超过窗口时间,他再次点击提交时,Redis中的Key已经过期,这时请求又会被当作新请求执行一遍,抛出了“处理中请勿重新提交”的用户提示,但实际后台很有可能已经发生了重复写入。
设置太短又会导致防抖形同虚设。我实践中的经验是:
- 对于大多数接口(比如普通提交),3到5秒是合理区间,能覆盖用户连点、前端按钮未置灰等场景。
- 对于网络环境经常抖动的场景,我建议直接把前端按钮设置为提交后立即置灰并加loading,同时后端窗口设置到10秒。但这处设计需要团队前后端约定,不能光靠后端扛。
- 对于消息队列消费场景,不要依赖短时间窗口,必须走数据库幂等设计。
我最早在支付回调接口把窗口设了1秒,结果回调第二次重试间隔往往在1秒以上,防抖完全没拦截住,最后是靠状态机才保住了最终一致性。
5.2 防抖Key膨胀太快导致Redis内存增长
每一个防抖Key都会在多长时间后自动过期?如果流量很高,每个请求都生成一个新Key(比如某个请求参数带了随机数UUID),那Redis里会瞬时堆积大量短暂的key,短窗口内不会自动清理完,虽然单个key很小,但量级上去后依然会明显占用内存。
排查方法很简单:用redis-cli --bigkeys扫一下,或者用INFO命令观察expired_keys和内存增长率。如果发现防抖Key过多,最直接的原因是业务参数不固定,每个请求的MD5都不同,Key永远无法命中重复判断。这时候要改造Key生成规则,不要一股脑序列化所有字段,像userId这种高频变化但不应该参与幂等判断的字段可以剔除。另外,防抖Key设置了过期时间,正常情况下会缓慢淘汰,如果生产内存偏紧,可以设置更短的timeout,或者用object idle命令观察。
5.3 注意“状态不在预期内”时事务如何处理
幂等实现中一个隐蔽的问题:当状态机发现订单已处理,直接return返回成功时,如果这个方法有@Transactional注解,整个事务并不会提交脏数据,因为什么也没改;但如果前面有写过任何临时数据(比如埋点表插入记录、扣减过Redis缓存),直接return并不会回滚Redis的操作——这就产生“本地事务已提交,但Redis已扣减”的状态不一致。以后里遇到这类事务方法里既有数据库操作又有Redis操作时,一定要格外小心,不能想当然地认为数据库回滚了Redis就会恢复。
我自己的经验是:凡是强一致性要求的逻辑,尽量以数据库为主存储,Redis只做防重判断和数据预热。如果Redis被写入后数据库事务失败,需要有反向补偿动作,比如删除防重Key,尽量让用户重新提交一次。
5.4 排查重复请求来源:加请求指纹日志
上线防抖以后,一定要在切面里加日志。别小看这几行日志,它能帮你快速定位重复请求是真用户连点、还是网络重试、又或者是黑客刷接口。我实现的切面里打的是warn级别日志,记录的字段包括:请求用户ID、请求路径、防重Key、命中时间。
排查定位时,用这几条字段去日志系统里查,能看出某个用户是不是每隔几秒就重试一次;如果大量不同用户、不同路径都出现了重复请求,可能就是脚本攻击,需要进一步做风控限流。如果没有日志,面对“用户说我没重复提交,但页面提示重复”的客诉,你几乎没有任何信息可查。
5.5 分布式部署须注意Redis时间与时钟的一致性
这个点主要提醒一下在使用Redis集群,尤其是不同机房部署的场景。Redis里的过期依赖服务器时间的推进。如果某个Redis节点的时间走得不准,那防抖Key的过期可能提前也可能延迟,造成误拦截或者防抖逻辑未正确工作。实际项目中,我们给核心交易接口设置key过期时间,宁可略微偏长,也不要过度依赖精确到秒的过期,保证逻辑上没有问题即可。
6. 怎么验证你的防护方案是不是真的可靠
6.1 设计一套可复现的并发测试用例
代码写完之后,不能只在浏览器里点两下觉得没问题就完事。我建议设计一套标准化的测试场景,至少覆盖下面几种情况:
- 同一用户、同一参数,快速连续请求5次:预期只有1次成功。
- 同一用户、不同参数,间隔1秒请求2次:预期全局参数都能执行。
- 不同用户、同一参数,并发请求10次:预期每个用户都可以正常创建订单。
- 同一用户、同一参数,间隔超过timeout窗口后再请求:预期可以再次提交。
- 并发场景下,刻意绕过Redis中间层(模拟Redis宕机),打底层数据库接口:预期靠唯一索引或乐观锁仍然不会生成重复数据。
如果用了JMeter,可以把线程数设置成50、循环次数设置成20,打开聚合报告观察吞吐和异常率,把“模拟request携带相同orderId放量打”当作重点验证项。
6.2 单元测试和集成测试怎么组织
对于AOP切面这种机制,单元测试不太好测,因为需要Spring容器加载切面。最好的方式是通过SpringBootTest启动完整环境,再mock一个测试Controller,然后使用MockMvc发请求验证结果。
java复制@Test
void repeatSubmitShouldBeIntercepted() throws Exception {
// 第一次请求
mockMvc.perform(post("/order/submit")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"productId\":1,\"quantity\":1}"))
.andExpect(status().isOk());
// 第二次相同请求,应当被拦截
mockMvc.perform(post("/order/submit")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"productId\":1,\"quantity\":1}"))
.andExpect(jsonPath("$.success").value(false));
}
集成测试里自然要内置一个干净的Redis环境或testcontainer,保证Key不被其他测试污染。同时要注意每次测试方法执行前清理掉对应测试Key,否则测试会相互影响。
6.3 慢查询和性能影响评估
不少人担心Redis操作会增加接口响应延迟。单次Redis SET NX EX的开销在0.1ms到1ms之间,如果走的是本机房内网Redis,性能影响是微乎其微的。但如果切面的序列化逻辑写得不好(比如直接序列化全部参数,而且参数里有大字段),这部分时间反而会占大头。
我做了一轮粗粒度压测,对比了不加防抖和加上防抖之后下单接口的耗时与吞吐,结果基本没有明显变化,QPS差异在个位数百分比以内。原因是下单接口本身涉及DB写入和库存扣减,瓶颈通常在数据库层,Redis防抖引入的额外开销在这类写接口面前可以忽略不计。但要特别注意不要在同一次请求链路中多次反复调用Redis防重判断,否则叠加起来还是会有可感知延迟的。
最后再分享一个我在项目中的心得体会。许多年轻的开发者会想当然地以为,做了接口防反提交就可以高枕无忧,服务就不会再出重复数据问题。但真实环境里最不可靠的因素恰恰是客户端行为、网关重试和消息重投。我在做这套方案设计时,一直遵循的是“入口多设防、底层有兜底、全链可追踪、行为有日志”的原则。当前端、网关、业务层、数据层的防护策略能够互相独立、又能形成闭环的时候,重复请求再过来,系统才真正说得上是省心的。希望这篇文章能帮你在自己的SpringBoot项目里少走一些弯路。
