写了个 Spring Boot 防重复提交的轮子,已发到 Maven Central

前言

事情是这样的,前段时间在公司项目里又写了一遍防重复提交的逻辑------Redis 加锁、拼 Key、设过期时间、处理异常释放锁......写到一半我就烦了,这套东西每个项目都要来一遍,而且每次写法还不太一样,维护起来头大。

网上找了一圈,要么是跟着某个大框架绑定的(比如 sa-token 里自带的),要么就是博客里一段代码片段,复制过来还得自己改半天。

想了想,干脆自己封一个 Starter 算了。搞完发现效果还行,就顺手发到 Maven Central 开源了。

项目叫 Guardian,目前 v1.1.0,专注做接口防重复提交这一件事。

GitHub:github.com/BigGG-Guard... Maven Central:io.github.biggg-guardian:guardian-repeat-submit-spring-boot-starter


先看效果

最简单的用法,三步搞定:

第一步,引依赖:

xml 复制代码
<dependency>
    <groupId>io.github.biggg-guardian</groupId>
    <artifactId>guardian-repeat-submit-spring-boot-starter</artifactId>
    <version>1.1.0</version>
</dependency>

第二步,加注解:

java 复制代码
@PostMapping("/submit")
@RepeatSubmit(interval = 10, message = "订单正在处理,请勿重复提交")
public Result submitOrder(@RequestBody OrderDTO order) {
    return orderService.submit(order);
}

第三步,没了。启动项目就生效了。

10 秒内同一个用户、同一个接口、同样的请求参数,第二次请求会被直接拦截。


为什么不直接用 Redis 加个锁?

你肯定想说:"这不就是 Redis setnx 嘛,我自己写也行。"

确实能写,但你想想实际项目里会遇到的问题:

1. Key 怎么拼?

userId + url 够不够?如果同一个用户对同一个接口传了不同的参数呢?比如下单接口,买商品 A 和买商品 B 应该算两次不同的请求,不能拦截。

所以防重 Key 要把请求参数也算进去。但 POST 请求的 body 是个流,读了一次就没了,你还得处理 HttpServletRequestWrapper 的问题。

Guardian 内置了 RepeatableRequestFilter,自动缓存请求体,Key 生成时会把请求参数做 JSON 序列化 + Base64 编码拼进去。

2. 用户没登录怎么办?

很多防重方案直接用 userId 作为 Key 的一部分,但用户没登录的时候 userId 是 null,Key 就乱了。

Guardian 的处理是:已登录用 userId → 没登录用 sessionId → 没 session 用客户端 IP。三级降级,永远不会出现 null。

3. 业务异常了锁不释放怎么办?

比如用户提交订单,业务代码报了个异常,但防重锁已经设了 10 秒。结果用户修正数据重新提交,被告知"请勿重复提交"------这体验就很差了。

Guardian 在拦截器的 afterCompletion 里做了处理:如果请求抛了异常,自动释放锁。正常完成的请求才让锁自然过期。

4. 有些接口不需要防重怎么办?

全局配了防重之后,健康检查接口、公开查询接口这些也被拦了。你要么给每个接口单独控制,要么维护一个白名单。

Guardian 支持 exclude-urls 白名单,AntPath 通配符匹配,优先级最高。命中直接放行,不走任何防重逻辑。


注解不够用?试试 YAML 批量配置

单个接口用注解挺方便,但如果你有 50 个接口都要配防重,一个一个加注解就有点累了。

Guardian 支持在 YAML 里批量配置,用 AntPath 通配符一口气匹配一批接口:

yaml 复制代码
guardian:
  repeat-submit:
    storage: redis
    key-encrypt: md5
    urls:
      - pattern: /api/order/**
        interval: 10
        key-scope: user
        message: "订单正在处理,请勿重复提交"
      - pattern: /api/sms/send
        interval: 60
        key-scope: ip
    exclude-urls:
      - /api/public/**
      - /api/health

几个要点:

  • YAML 规则的优先级高于注解,同一个接口两边都配了以 YAML 为准
  • 白名单(exclude-urls优先级最高,命中直接放行
  • key-scope 控制防重维度:user(按用户)、ip(按 IP)、global(全局)

比如短信发送接口配 key-scope: ip,同一个 IP 60 秒内只能发一次,不管登没登录、哪个用户------这比按用户维度更合理。


拦截了之后怎么响应?

这是我纠结了挺久的一个设计点。

一开始只做了抛异常的方式------拦截后抛 RepeatSubmitException,让业务端的全局异常处理器去处理。但后来想到,有些项目可能就想开箱即用,不想为了一个防重还得写个异常处理器。

所以做了两种模式:

yaml 复制代码
guardian:
  repeat-submit:
    response-mode: exception  # 默认,抛异常
    # response-mode: json     # 直接返回 JSON

exception 模式 (默认):抛 RepeatSubmitException,你在全局异常处理器里接一下:

java 复制代码
@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(RepeatSubmitException.class)
    public Result handleRepeatSubmit(RepeatSubmitException e) {
        return Result.fail(e.getMessage());
    }
}

json 模式 :拦截器直接写 JSON 响应,默认格式是 {"code":500,"msg":"...","timestamp":...}

格式不满意?注册一个 RepeatSubmitResponseHandler Bean 就能覆盖:

java 复制代码
@Bean
public RepeatSubmitResponseHandler repeatSubmitResponseHandler() {
    return (request, response, message) -> {
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(JSONUtil.toJsonStr(R.fail(message)));
    };
}

不用 Redis 也能跑

不是每个项目都有 Redis 的。本地开发环境、小型单体应用,可能就没有 Redis。

yaml 复制代码
guardian:
  repeat-submit:
    storage: local  # 用本地缓存

切成 local 就行了,底层用 ConcurrentHashMap 实现,带惰性过期清理。当然生产环境还是推荐 Redis,支持分布式。


关于 context-path 的坑

这个坑我自己踩过。项目配了 server.servlet.context-path: /admin-api,然后 YAML 里配的 URL 规则死活匹配不上。

排查了一下发现,request.getRequestURI() 返回的是带 context-path 的完整路径(比如 /admin-api/order/submit),但 YAML 里配的可能是 /order/submit

Guardian 的处理是:匹配时同时尝试完整 URI 和去掉 context-path 后的路径,两者有一个匹配上就算命中。所以不管你 YAML 里写的是 /order/submit 还是 /admin-api/order/submit,都能正确匹配。


内部架构

简单画一下请求的处理流程:

sql 复制代码
请求进入
  │
  ▼
RepeatableRequestFilter     ← 缓存请求体,支持重复读取
  │
  ▼
RepeatSubmitInterceptor
  ├─ 1. 匹配白名单 → 命中直接放行
  ├─ 2. 匹配 YAML 规则
  ├─ 3. 检查 @RepeatSubmit 注解
  │      均未命中 → 放行
  ▼
KeyGenerator                ← 按维度(user/ip/global)生成防重 Key
  │
  ▼
KeyEncrypt                  ← 可选 MD5 加密
  │
  ▼
Storage.tryAcquire()
  ├─ 成功 → 放行,写入存储 + 设置 TTL
  └─ 失败 → 根据 response-mode 响应
      ├─ exception → 抛 RepeatSubmitException
      └─ json → 直接写 JSON 响应
  │
  ▼
业务执行
  ├─ 正常 → Key 自然过期
  └─ 异常 → afterCompletion 自动释放

核心组件都是面向接口编程的,Key 生成策略、加密策略、存储方案、响应处理器全部可以通过注册 Bean 来替换。框架内部用 @ConditionalOnMissingBean 做的,你不注册就用默认的,注册了就用你的。


项目结构

r 复制代码
guardian-parent
├── guardian-core                            # 公共基础(异常、工具)
├── guardian-repeat-submit/                  # 防重复提交
│   ├── guardian-repeat-submit-core          # 核心逻辑
│   ├── guardian-storage-redis               # Redis 存储实现
│   └── guardian-repeat-submit-spring-boot-starter  # 自动配置
└── guardian-example                         # 示例工程

模块拆分是为了后续扩展做的铺垫,Guardian 后面会往接口限流、幂等、防重放这些方向扩展,每个功能一个独立模块,用哪个引哪个。


总结

Guardian 目前就专注做一件事:接口防重复提交

没有大框架那么多功能,但在这一个点上尽量做到灵活、易用、少踩坑:

  • 注解 + YAML 双模式
  • 三种防重维度(用户 / IP / 全局)
  • 双响应模式(抛异常 / 返 JSON)
  • Redis / 本地缓存一键切换
  • 白名单、context-path 兼容、异常自动释放这些细节都处理了
  • 策略全部可插拔替换

如果你的项目里也在手写防重复提交的逻辑,可以试试看能不能省点事。

GitHub:github.com/BigGG-Guard...

Maven Central 坐标:

xml 复制代码
<dependency>
    <groupId>io.github.biggg-guardian</groupId>
    <artifactId>guardian-repeat-submit-spring-boot-starter</artifactId>
    <version>1.1.0</version>
</dependency>

觉得有用的话给个 Star 呗,这是我第一个开源项目,后续会持续更新。有问题欢迎提 Issue。

相关推荐
hewence11 小时前
协程间数据传递:从Channel到Flow,构建高效的协程通信体系
android·java·开发语言
哈库纳1 小时前
dbVisitor 利用 queryForPairs 让键值查询一步到位
java·后端·架构
野犬寒鸦2 小时前
CompletableFuture 在 项目实战 中 创建异步任务 的核心优势及使用场景
java·服务器·后端·性能优化
Java小卷2 小时前
Drools kmodule 与 ruleunit 模块用法详解
java·后端
程序员敲代码吗2 小时前
虚拟机内部工作机制揭秘:深入解析栈帧
java·linux·jvm
小钻风33662 小时前
Spring MVC拦截器的快速应用
java·spring·mvc
wsfk12342 小时前
总结:Spring Boot 之spring.factories
java·spring boot·spring
兮动人2 小时前
Druid连接池心跳与空闲连接回收配置指南
java·druid
callJJ2 小时前
Java 源码阅读方法论:从入门到实战
java·开发语言·人工智能·spring·ioc·源码阅读