支付接口被点了两次,订单表里多了一条,回调那边还一本正经地扣了两次库存。
日志里看着特别老实:
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 消费,必须靠业务唯一键和状态机兜底。
前面几层都是减少麻烦,最后落到数据库那层,才是真正不怕重复提交。