首页
学习
活动
专区
圈层
工具
发布

SpringBoot实现接口防抖的几种方案,杜绝重复提交

支付接口被点了两次,订单表里多了一条,回调那边还一本正经地扣了两次库存。

日志里看着特别老实:

2026-06-10 10:21:03.421  INFO create order userId=8842 sku=10019 clientSeq=A781

2026-06-10 10:21:03.588  INFO create order userId=8842 sku=10019 clientSeq=A781

两条日志差 167ms。

这种问题我一般不先骂前端。前端按钮置灰、防抖肯定要做,但你把防重复提交全押在浏览器上,线上迟早会被教育。刷新、弱网重试、网关超时、用户连点、App 重发,都能绕过去。

后端至少要兜一层。

最常见的做法,是给接口加一个短时间 Redis 锁。注意,这里不是分布式锁那一套大工程,就是挡住短时间内同一个人、同一个接口、同一份参数的重复请求。

先定义一个注解:

@Target(ElementType.METHOD)

@Retention(RetentionPolicy.RUNTIME)

public @interface NoRepeatSubmit {

  int seconds() default 3;

}

切面里拦一下:

@Aspect

@Component

publicclass RepeatSubmitGuard {

  privatefinal StringRedisTemplate redis;

  public RepeatSubmitGuard(StringRedisTemplate redis) {

      this.redis = redis;

  }

  @Around("@annotation(mark)")

  public Object blockRepeat(ProceedingJoinPoint point, NoRepeatSubmit mark) throws Throwable {

      HttpServletRequest req =

              ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();

      String userId = req.getHeader("X-User-Id");

      String uri = req.getRequestURI();

      String argsHash = DigestUtils.md5DigestAsHex(

              JSON.toJSONString(point.getArgs()).getBytes(StandardCharsets.UTF_8)

      );

      String key = "repeat:" + userId + ":" + uri + ":" + argsHash;

      Boolean ok = redis.opsForValue().setIfAbsent(

              key,

              String.valueOf(System.currentTimeMillis()),

              mark.seconds(),

              TimeUnit.SECONDS

      );

      if (!Boolean.TRUE.equals(ok)) {

          thrownew IllegalStateException("请求太快了,别重复提交");

      }

      return point.proceed();

  }

}

接口上这么用:

@PostMapping("/order/create")

@NoRepeatSubmit(seconds = 5)

public Long create(@RequestBody CreateOrderCmd cmd) {

  return orderAppService.create(cmd);

}

这里有个坑,很多人会在 finally 里把 Redis key 删掉。

我不建议。

因为这个方案防的是“短时间重复提交”,不是保护临界区。你请求 100ms 就执行完了,finally 一删,用户 200ms 后再点一下,照样进来。这个 key 就应该让它自然过期。

不过这种方案只适合拦“手抖型”的重复请求。真正涉及下单、支付、提现,光靠 3 秒防抖不够。

更稳一点的是 token 方案。

页面打开时先拿一个提交令牌:

@GetMapping("/submit-token")

public Map<String, String> token(HttpServletRequest request) {

  String userId = request.getHeader("X-User-Id");

  String token = UUID.randomUUID().toString().replace("-", "");

  redis.opsForValue().set(

          "submit:token:" + userId + ":" + token,

          "1",

          10,

          TimeUnit.MINUTES

  );

  return Map.of("token", token);

}

提交订单时,前端把 token 带上来。后端检查一次,然后立刻消费掉。

这个动作最好用 Lua,别先 get 再 delete,中间有缝。

private boolean consumeToken(String userId, String token) {

  String key = "submit:token:" + userId + ":" + token;

  String script = """

          if redis.call('get', KEYS[1]) then

              redis.call('del', KEYS[1])

              return 1

          end

          return 0

          """;

  Long ret = redis.execute(

          new DefaultRedisScript<>(script, Long.class),

          Collections.singletonList(key)

  );

  return Long.valueOf(1).equals(ret);

}

业务代码里别绕:

@Transactional(rollbackFor = Exception.class)

public Long createOrder(CreateOrderCmd cmd, String userId, String token) {

  if (!consumeToken(userId, token)) {

      throw new IllegalStateException("重复提交或页面已过期");

  }

  Order order = new Order();

  order.setUserId(Long.valueOf(userId));

  order.setSkuId(cmd.getSkuId());

  order.setClientSeq(cmd.getClientSeq());

  order.setStatus("WAIT_PAY");

  orderMapper.insert(order);

  return order.getId();

}

这个方案对表单提交比较好用,比如创建订单、发布内容、保存资料。它的麻烦点也明显:前端要先拿 token,页面返回、刷新、多 Tab 都要处理好。

但我线上最信的,还是数据库唯一约束。

接口层防抖是挡流量,Redis token 是挡重复动作,数据库唯一索引才是最后一刀。

比如下单,客户端可以生成一个client_seq,同一个用户同一次提交固定不变:

ALTER TABLE t_order

ADD UNIQUE KEY uk_user_client_seq(user_id, client_seq);

插入时撞唯一索引,就别再创建新订单了,直接查老订单返回。

public Long safeCreate(CreateOrderCmd cmd, Long userId) {

  try {

      Order row = new Order();

      row.setUserId(userId);

      row.setSkuId(cmd.getSkuId());

      row.setClientSeq(cmd.getClientSeq());

      row.setStatus("WAIT_PAY");

      orderMapper.insert(row);

      return row.getId();

  } catch (DuplicateKeyException e) {

      Long oldId = orderMapper.findIdByUserAndSeq(userId, cmd.getClientSeq());

      if (oldId == null) {

          throw e;

      }

      return oldId;

  }

}

这段代码不漂亮,但很抗揍。

尤其是支付回调、MQ 消费、第三方通知这种场景,你根本控制不了对方会不会重试。这个时候别谈什么按钮防抖,没意义。业务唯一键必须落库。

支付回调我一般这么做:

ALTER TABLE t_pay_record

ADD UNIQUE KEY uk_pay_trade_no(channel_no, trade_no);

收到回调先插记录,插成功再推进订单状态。重复回调撞唯一索引,直接返回成功。

@Transactional(rollbackFor = Exception.class)

public void handlePayNotify(PayNotifyCmd cmd) {

  try {

      payRecordMapper.insertNotify(cmd.getChannelNo(), cmd.getTradeNo(), cmd.getRawBody());

  } catch (DuplicateKeyException ignored) {

      return;

  }

  int changed = orderMapper.paySuccess(cmd.getOrderNo(), cmd.getTradeNo());

  if (changed == 0) {

      log.warn("pay notify ignored, order not changed, orderNo={}, tradeNo={}",

              cmd.getOrderNo(), cmd.getTradeNo());

  }

}

这里的paySuccess也不能无脑 update:

UPDATE t_order

SET status = 'PAID', pay_time = NOW(), trade_no = #{tradeNo}

WHERE order_no = #{orderNo}

AND status = 'WAIT_PAY';

这个AND status = 'WAIT_PAY'很关键。

没有它,重复回调、补偿任务、人工重推都可能把状态来回覆盖。线上很多脏数据不是并发多复杂,就是 update 写得太随意。

所以接口防抖别只记一个注解。

普通保存接口,用 Redis 短 key 挡一下就够。

表单类提交,用 token,一次生成,一次消费。

资金、订单、支付、MQ 消费,必须靠业务唯一键和状态机兜底。

前面几层都是减少麻烦,最后落到数据库那层,才是真正不怕重复提交。

  • 发表于:
  • 原文链接https://page.om.qq.com/page/O8qE26Yu1Uf-S7mftxlKhElw0
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。
领券