Spring Boot 项目中,如何处理“用户重复提交”?
·
作者:不想打工的码农
标签:#SpringBoot #Java #防重复提交 #后端开发 #实战
大家好,我是“不想打工的码农”。
上个月我们上线了一个“优惠券领取”功能,结果运营反馈:“有用户领了 10 张同一种券!”
我一查日志,发现是用户快速点了 5 次“领取”按钮,后端没做防护,直接插入了 5 条记录。
紧急修复时,我看到老代码是这样写的:
// 在领取方法上加锁
synchronized (this) {
if (couponService.hasReceived(userId, couponId)) {
throw new RuntimeException("已领取");
}
couponService.receive(userId, couponId);
}
这在单机下能用,但一上集群就失效——因为 synchronized 只锁当前 JVM 实例。
后来又有人改成用 Redis 分布式锁:
String lockKey = "receive:lock:" + userId + ":" + couponId;
if (redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS)) {
// 执行领取逻辑
} else {
throw new RuntimeException("操作太频繁");
}
看似解决了问题,但带来了新麻烦:
- 每次请求都要读写 Redis,QPS 高时压力大
- 锁时间设短了可能漏防,设长了影响正常用户
- 如果服务宕机,锁可能不释放
今天,我就分享一套轻量、可靠、低开销的防重复提交方案,已在生产环境稳定运行半年。
一、核心思路:用“请求指纹 + 短期缓存”代替全局锁
我们真正要防的,不是“同一个用户多次操作”,而是“同一笔操作被重复提交”。
比如:
- 用户点击“领取”按钮 → 发起请求 A
- 网络卡顿,用户以为没点成功 → 再点一次 → 发起请求 B(和 A 完全一样)
所以,关键不是锁用户,而是识别“重复的请求”。
具体做法:
- 前端在发起请求时,生成一个唯一 ID(比如 UUID),放在请求头
X-Request-ID - 后端收到请求后,用 “用户ID + 接口路径 + 请求ID” 作为 key
- 尝试将这个 key 存入 Redis(带 5 秒过期)
- 如果存成功,说明是首次请求,继续执行业务
- 如果已存在,说明是重复提交,直接拒绝
✅ 优势:
- 不影响不同请求(比如用户领完券 A,马上领券 B,不受影响)
- 缓存只存 5 秒,内存占用极低
- 即使 Redis 挂了,最多只是短暂失效,不会导致系统不可用
二、实战:三步实现
第一步:前端生成请求 ID(以 Axios 为例)
// request.js
import { v4 as uuidv4 } from 'uuid';
axios.interceptors.request.use(config => {
config.headers['X-Request-ID'] = uuidv4();
return config;
});
💡 如果不用前端框架,也可以在 form 提交时加个隐藏字段
<input type="hidden" name="requestId" value="...">
第二步:自定义注解标记需要防重的接口
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PreventDuplicateSubmit {
// 可扩展:比如指定过期时间
}
第三步:用拦截器实现核心逻辑
@Component
public class DuplicateSubmitInterceptor implements HandlerInterceptor {
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod method = (HandlerMethod) handler;
if (!method.hasMethodAnnotation(PreventDuplicateSubmit.class)) {
return true; // 不是防重接口,放行
}
String userId = getCurrentUserId(request); // 从 token 或 session 获取
String uri = request.getRequestURI();
String requestId = request.getHeader("X-Request-ID");
if (StringUtils.isBlank(requestId)) {
throw new IllegalArgumentException("缺少请求标识 X-Request-ID");
}
String cacheKey = "duplicate_submit:" + userId + ":" + uri + ":" + requestId;
// 尝试设置(NX = not exist)
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(cacheKey, "1", Duration.ofSeconds(5));
if (Boolean.FALSE.equals(success)) {
throw new RuntimeException("请勿重复提交");
}
return true;
}
private String getCurrentUserId(HttpServletRequest request) {
// 根据你的认证方式实现,比如从 JWT 解析
return "mock_user_id"; // 示例
}
}
第四步:注册拦截器
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new DuplicateSubmitInterceptor())
.addPathPatterns("/api/**"); // 按需调整
}
}
第五步:在 Controller 上加注解
@PostMapping("/coupon/receive")
@PreventDuplicateSubmit
public Result<String> receiveCoupon(@RequestBody ReceiveDTO dto) {
couponService.receive(dto.getUserId(), dto.getCouponId());
return Result.ok("领取成功");
}
三、避坑指南:这些细节决定成败
❌ 坑1:没考虑用户未登录场景
如果接口允许游客访问(比如商品下单),不能用 userId 做 key。
✅ 改用 IP + User-Agent + URI + RequestID 组合,但要注意代理 IP 问题。
❌ 坑2:Redis 连接失败导致所有请求被拒
建议加一层容错:
try {
Boolean success = redisTemplate.opsForValue().setIfAbsent(...);
if (Boolean.FALSE.equals(success)) {
throw new RuntimeException("请勿重复提交");
}
} catch (Exception e) {
log.warn("Redis 防重失效,放行请求", e);
// 容忍降级,不阻断主流程
}
❌ 坑3:前端刷新页面导致 RequestID 重复
确保每次请求都生成新的 RequestID,不要缓存复用。
四、为什么这个方案更实用?
| 方案 | 优点 | 缺点 |
|---|---|---|
| synchronized | 简单 | 单机有效,集群无效 |
| Redis 分布式锁 | 集群可用 | 性能开销大,锁粒度粗 |
| 请求指纹 + 短缓存 | 精准、低开销、自动过期 | 需要前端配合 |
记住:
防重复提交不是为了“绝对安全”,而是挡住 99% 的误操作。
剩下的 1%,靠业务幂等性兜底(比如数据库唯一索引)。
我是“不想打工的码农”,一个坚持写能跑通、能扛住用户乱点的代码的普通开发者。
如果你觉得有用,欢迎点赞、收藏、关注!
更多推荐

所有评论(0)