接口防重提交,我最后还是回到了 Redis 占坑

举报
yd_241780069 发表于 2026/10/26 23:10:56 2026/10/26
【摘要】 起因下单接口被刷了。不是攻击,是用户手快连点两下,或者网络超时客户端自动重试,同一个订单号进了两次,库存扣了两回。对账的时候才发现。一开始想在前端拦,按钮置灰、防抖都加了,但脚本直接调接口绕过去了。后端不兜底,前端做再多都是摆设。 先试了数据库唯一索引最直接的办法:订单号加唯一索引,重复插入直接抛 DuplicateKeyException。@Column(unique = true)pr...

起因

下单接口被刷了。不是攻击,是用户手快连点两下,或者网络超时客户端自动重试,同一个订单号进了两次,库存扣了两回。对账的时候才发现。

一开始想在前端拦,按钮置灰、防抖都加了,但脚本直接调接口绕过去了。后端不兜底,前端做再多都是摆设。

先试了数据库唯一索引

最直接的办法:订单号加唯一索引,重复插入直接抛 DuplicateKeyException。

@Column(unique = true)
private String orderNo;

能拦住,但体验不好。用户第二次提交拿到的是一个数据库异常,而不是“请勿重复提交”。而且如果重复请求发生在业务逻辑中间(比如先扣库存再插订单),唯一索引只能保住订单表,库存已经扣了。所以数据库唯一索引适合做最后一道兜底,不适合做入口拦截。

最后用的 SETNX

Redis 的 setIfAbsent 对应 SETNX,原子操作:第一次写入成功放行,第二次失败拦掉。

public boolean checkAndMark(String bizKey, long ttlSeconds) {
    Boolean ok = redis.opsForValue()
            .setIfAbsent("idem:" + bizKey, "1", ttlSeconds, TimeUnit.SECONDS);
    return Boolean.TRUE.equals(ok);
}

Controller 里用业务唯一号做 key:

String key = req.getUserId() + ":" + req.getOrderId();
if (!idempotent.checkAndMark(key, 300)) {
    return ResponseEntity.status(409).body("重复请求");
}

TTL 设了 5 分钟。这里有个坑:TTL 必须比业务执行时间长。如果业务跑 10 秒,TTL 设 5 秒,正常请求还没跑完 key 就过期了,紧接着的重试会再次进来,等于没拦。

抽成了注解

接口多起来之后,每个 Controller 里写一遍 checkAndMark 太啰嗦。抽了个 @Idempotent 注解,配合 AOP 在方法执行前统一拦。

注解定义大概这样:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
    String key() default "";
    long ttl() default 300;
}

切面里从方法参数或 SpEL 表达式解析出业务 key,调 checkAndMark。不通过就抛自定义异常,全局异常处理器转成 409 返回。

SpEL 解析那部分稍微麻烦点,但写一次后面所有接口标个注解就行,比每个方法里手写 Redis 操作干净。

几个边界情况

异常了要不要删 key。 如果业务抛异常,这次请求实际上没成功,用户应该能重试。我的做法是在切面的 catch 里删掉 key,或者给 key 设一个很短的 TTL 让自然过期。但如果是“重复请求”本身抛的异常,不能删,否则第二次、第三次又会进来。

分布式下必须用 Redis。 单机用 ConcurrentHashMap 或者 Guava Cache 在本地开发时能跑,多实例部署就废了,每个实例各算各的,拦不住。所以从一开始就用 Redis,别图省事用内存。

网关重发和用户双击的 key 可能不一样。 如果 key 里带了时间戳或者随机数,两次请求的 key 不同,SETNX 就失效了。key 必须只包含业务上能唯一标识这次操作的字段,比如订单号、用户 ID、接口路径的组合,不能带任何每次请求都会变的参数。

顺带说下和分布式锁的区别

防重提交和分布式锁长得像,但目的不同。防重是“同一个请求只处理一次”,分布式锁是“同一时刻只有一个线程能进”。用 Redisson 的 tryLock 做防重也能实现,但更重,而且要处理锁释放和事务边界的时序问题。单纯的防重,SETNX 加过期就够了,没必要上锁。

小结

防重提交这事,前端做体验,后端做兜底。后端方案里,数据库唯一索引放最后,Redis SETNX 放最前面。注解加 AOP 是最终形态,写一次到处用。

先记到这。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。