找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1479

积分

0

好友

191

主题
发表于 14 小时前 | 查看: 4| 回复: 0

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

限流也是,要么上 Sentinel 搞一套,要么自己写个拦截器糊一个,代码散得到处都是。

想了想,干脆自己封装一个 Starter。搞着搞着就把防重复提交和接口限流都做了,打包发到 Maven Central 开源了。项目叫 Guardian,一个轻量级的 Spring Boot API 请求层防护框架,目前 v1.3.0。两个功能完全独立,用哪个引哪个,互不依赖。

一、防重复提交

先看效果

三步搞定:

第一步,引依赖:

<dependency>
    <groupId>io.github.biggg-guardian</groupId>
    <artifactId>guardian-repeat-submit-spring-boot-starter</artifactId>
    <version>1.3.0</version>
</dependency>

第二步,加注解:

@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 通配符一口气匹配一批接口:

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,让业务端的全局异常处理器去处理。但后来想到,有些项目可能就想开箱即用,不想为了一个防重还得写个异常处理器。

所以做了两种模式:

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

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

@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 就能覆盖:

@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。

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,都能正确匹配。

防重的内部流程

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

请求进入
  │
  ▼
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 自动释放

二、接口限流

为什么要做限流?

防重复提交解决的是“同一个请求短时间内被提交多次”的问题,但还有另一类问题它管不了:恶意刷接口

比如有人写个脚本一秒钟请求你的搜索接口 1000 次,防重拦不住(因为每次参数可能不一样),这时候就需要限流了。

市面上的限流方案不少,但要么是网关级别的(Sentinel、Spring Cloud Gateway),要么得写一堆配置。如果你就是个普通的 Spring Boot 单体应用,想给几个接口加个限流,没必要引那么重的东西。

Guardian 的限流就是冲着这个场景来的:轻量、注解 + YAML 双模式、两种算法可选

先看效果

<dependency>
    <groupId>io.github.biggg-guardian</groupId>
    <artifactId>guardian-rate-limit-spring-boot-starter</artifactId>
    <version>1.3.0</version>
</dependency>
// 滑动窗口:每秒最多 10 次
@RateLimit(qps = 10)

// 令牌桶:每秒补 5 个令牌,桶容量 20,允许瞬间突发 20 次
@RateLimit(qps = 5, capacity = 20, algorithm = RateLimitAlgorithm.TOKEN_BUCKET)

同样支持 YAML 批量配置:

guardian:
  rate-limit:
    urls:
      - pattern: /api/sms/send
        qps: 1
        rate-limit-scope: ip
      - pattern: /api/seckill/**
        qps: 10
        capacity: 50
        algorithm: token_bucket
        rate-limit-scope: global
    exclude-urls:
      - /api/public/**

和防重一样,注解 + YAML 双模式,YAML 优先级高于注解,白名单优先级最高。

滑动窗口 vs 令牌桶

这是限流最常用的两种算法,Guardian 都支持。

滑动窗口:统计时间窗口内的请求次数,超了就拒绝。比如配了 qps=10, window=1s,就是每秒最多 10 次,多了直接打回。

@RateLimit(qps = 10)

特点是严格。窗口内绝对不会超过阈值。适合短信发送、登录尝试这种需要精确控制频率的场景。

令牌桶:桶里装令牌,按固定速率往里放,请求来了取一个,桶空了就拒绝。桶满时可以一口气把令牌全用完。

@RateLimit(qps = 5, capacity = 20, algorithm = RateLimitAlgorithm.TOKEN_BUCKET)

这个配置的意思是:每秒补 5 个令牌,桶最多攒 20 个。平时空闲的时候令牌慢慢攒,突然来一波流量,瞬间可以放过 20 个请求,打完之后回到每秒 5 个的稳态。

特点是允许突发。适合秒杀、抢购这种“平时没啥流量,偶尔来一波高峰”的场景。

举个直观的例子,都是 qps=10,突然来了 20 个请求:

滑动窗口 令牌桶(capacity=20)
第 1-10 个 通过 通过
第 11-20 个 全部拒绝 全部通过
之后每秒 最多 10 个 最多 10 个

补充速率怎么控制?

令牌桶的补充速率通过 qpswindow 两个参数控制:

  • qps=10, window=1s → 每秒补 10 个
  • qps=10, window=1min → 每分钟补 10 个(约 6 秒补 1 个)
// 每分钟补 10 个令牌,桶容量 10
@RateLimit(qps = 10, window = 1, windowUnit = TimeUnit.MINUTES,
        capacity = 10, algorithm = RateLimitAlgorithm.TOKEN_BUCKET)

这样就能实现慢速补充的场景。

限流维度

和防重一样,限流也支持三种维度:

维度 效果 典型场景
GLOBAL(默认) 整个接口共用一个计数器 全站搜索接口
IP 每个 IP 独立计数 短信发送、验证码
USER 每个用户独立计数 用户操作频率限制
@RateLimit(qps = 1, rateLimitScope = RateLimitKeyScope.IP, message = “短信发送过于频繁“)

限流的响应处理

和防重一样,两种模式:

guardian:
  rate-limit:
    response-mode: exception # 默认,抛 RateLimitException
    # response-mode: json     # 直接返回 JSON

也支持自定义响应处理器,注册一个 RateLimitResponseHandler Bean 就行。

三、一些设计细节

并发安全

限流对并发安全的要求比防重高。你想,10 个请求同时进来,限流阈值是 5,如果并发控制没做好,可能 10 个都放过去了。

Guardian 的处理:

  • Redis:滑动窗口和令牌桶都用 Lua 脚本,Redis 单线程执行 Lua 是天然原子的
  • 本地缓存synchronized 锁到 Key 粒度,不同 Key 之间互不阻塞

防重那边也是一样,Redis 用 SET NX EX 原子操作,本地缓存用 ConcurrentHashMap 的原子方法。

本地缓存的内存管理

ConcurrentHashMap 做本地存储有个容易忽略的问题:Key 只进不出,长时间运行内存会一直涨。

Guardian 在防重和限流的本地存储里都加了守护线程,每 5 分钟扫一次,清理过期的 Key。线程是 daemon 的,不会阻止 JVM 关闭。

可插拔架构

两个模块的核心组件都是面向接口编程的,框架内部用 @ConditionalOnMissingBean 做的,你不注册就用默认的,注册了就用你的:

组件 防重复提交 接口限流
Key 生成 RepeatSubmitKeyGenerator RateLimitKeyGenerator
Key 加密 AbstractKeyEncrypt AbstractKeyEncrypt
存储 RepeatSubmitStorage RateLimitStorage
响应处理 RepeatSubmitResponseHandler RateLimitResponseHandler
用户上下文 UserContext(共享) UserContext(共享)

可观测性

两个模块都内置了监控能力:

拦截日志log-enabled: true 开启后,拦截/放行都有日志输出。

Actuator 端点

GET /actuator/guardian-repeat-submit    → 防重统计
GET /actuator/guardian-rate-limit       → 限流统计

限流的统计数据长这样:

{
    “totalRequestCount“: 5560,
    “totalPassCount“: 5432,
    “totalBlockCount“: 128,
    “blockRate“: “2.30%“,
    “topBlockedApis“: { “/api/sms/send“: 56 },
    “topRequestApis“: { “/api/search“: 3200 }
}

项目结构

guardian-parent
├── guardian-core                          # 公共基础(共享类)
├── guardian-repeat-submit/                # 防重复提交
│   ├── guardian-repeat-submit-core/
│   └── guardian-repeat-submit-spring-boot-starter/
├── guardian-rate-limit/                   # 接口限流
│   ├── guardian-rate-limit-core/
│   └── guardian-rate-limit-spring-boot-starter/
├── guardian-storage-redis/                # Redis 存储(多模块共享)
└── guardian-example/                      # 示例工程

模块拆分是为了灵活组合。比如你只需要防重就引 guardian-repeat-submit-spring-boot-starter,只需要限流就引 guardian-rate-limit-spring-boot-starter,都需要就两个都引,互不影响。

guardian-core 放的是两个模块都用到的公共类,比如 UserContextGuardianResponseHandlerguardian-storage-redis 是 Redis 存储的共享实现,两个模块的 Redis 存储都在这里面。

总结

Guardian 做了两件事:

  1. 防重复提交:拦截短时间内的重复请求,支持注解 / YAML、用户 / IP / 全局维度、异常自动释放、context-path 兼容
  2. 接口限流:控制接口访问频率,支持滑动窗口 / 令牌桶、突发流量处理、三种维度

两个功能独立 Starter,核心组件全部可插拔,注册 Bean 就能替换默认实现。Redis 和本地缓存一键切换。

如果你的 Spring Boot 项目里需要这些能力,但又不想引 Sentinel 那么重的东西,可以试试。这个项目已经开源并发布到 Maven Central,也欢迎在云栈社区分享你的使用体验或改进想法。

Maven Central 坐标:

<!-- 防重复提交 -->
<dependency>
    <groupId>io.github.biggg-guardian</groupId>
    <artifactId>guardian-repeat-submit-spring-boot-starter</artifactId>
    <version>1.3.0</version>
</dependency>

<!-- 接口限流 -->
<dependency>
    <groupId>io.github.biggg-guardian</groupId>
    <artifactId>guardian-rate-limit-spring-boot-starter</artifactId>
    <version>1.3.0</version>
</dependency>

熊猫挥手表情包“点个赞再走!”




上一篇:五个自动化脚本分析:云主机如何被黑客转为攻击节点
下一篇:基于LangGraph为AI智能体赋能:集成RAG与长期记忆实战
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-2-23 19:35 , Processed in 0.438543 second(s), 38 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表