作者:不想打工的码农
标签:#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 完全一样)

所以,关键不是锁用户,而是识别“重复的请求”

具体做法:

  1. 前端在发起请求时,生成一个唯一 ID(比如 UUID),放在请求头 X-Request-ID
  2. 后端收到请求后,用 “用户ID + 接口路径 + 请求ID” 作为 key
  3. 尝试将这个 key 存入 Redis(带 5 秒过期)
  4. 如果存成功,说明是首次请求,继续执行业务
  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%,靠业务幂等性兜底(比如数据库唯一索引)。


我是“不想打工的码农”,一个坚持写能跑通、能扛住用户乱点的代码的普通开发者。
如果你觉得有用,欢迎点赞、收藏、关注!

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐