SpringBoot 接口防護(hù)(防重提交 + 限流)
前言
事情是這樣的,前段時(shí)間在公司項(xiàng)目里又寫(xiě)了一遍防重復(fù)提交的邏輯——Redis 加鎖、拼 Key、設(shè)過(guò)期時(shí)間、處理異常釋放鎖……寫(xiě)到一半我就煩了,這套東西每個(gè)項(xiàng)目都要來(lái)一遍,而且每次寫(xiě)法還不太一樣,維護(hù)起來(lái)頭大。
限流也是,要么上 Sentinel 搞一套,要么自己寫(xiě)個(gè)攔截器糊一個(gè),代碼散得到處都是。
想了想,干脆自己封一個(gè) Starter。搞著搞著就把防重復(fù)提交和接口限流都做了,打包發(fā)到 Maven Central 開(kāi)源了。
項(xiàng)目叫 Guardian,一個(gè)輕量級(jí)的 Spring Boot API 請(qǐng)求層防護(hù)框架,目前 v1.3.0。兩個(gè)功能完全獨(dú)立,用哪個(gè)引哪個(gè),互不依賴。
項(xiàng)目地址(源碼 + 示例 + 文檔全在里面):
- GitHub:https://github.com/BigGG-Guardian/guardian ← 順手點(diǎn)個(gè) Star,不迷路
一、防重復(fù)提交
先看效果
三步搞定:
第一步,引依賴:
<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 = "訂單正在處理,請(qǐng)勿重復(fù)提交")
public Result submitOrder(@RequestBody OrderDTO order) {
return orderService.submit(order);
}
第三步,沒(méi)了。啟動(dòng)項(xiàng)目就生效了。
10 秒內(nèi)同一個(gè)用戶、同一個(gè)接口、同樣的請(qǐng)求參數(shù),第二次請(qǐng)求會(huì)被直接攔截。
為什么不直接用 Redis 加個(gè)鎖?
你肯定想說(shuō):"這不就是 Redis setnx 嘛,我自己寫(xiě)也行。"
確實(shí)能寫(xiě),但你想想實(shí)際項(xiàng)目里會(huì)遇到的問(wèn)題:
1. Key 怎么拼?
userId + url 夠不夠?如果同一個(gè)用戶對(duì)同一個(gè)接口傳了不同的參數(shù)呢?比如下單接口,買(mǎi)商品 A 和買(mǎi)商品 B 應(yīng)該算兩次不同的請(qǐng)求,不能攔截。
所以防重 Key 要把請(qǐng)求參數(shù)也算進(jìn)去。但 POST 請(qǐng)求的 body 是個(gè)流,讀了一次就沒(méi)了,你還得處理 HttpServletRequestWrapper 的問(wèn)題。
Guardian 內(nèi)置了 RepeatableRequestFilter,自動(dòng)緩存請(qǐng)求體,Key 生成時(shí)會(huì)把請(qǐng)求參數(shù)做 JSON 序列化 + Base64 編碼拼進(jìn)去。
2. 用戶沒(méi)登錄怎么辦?
很多防重方案直接用 userId 作為 Key 的一部分,但用戶沒(méi)登錄的時(shí)候 userId 是 null,Key 就亂了。
Guardian 的處理是:已登錄用 userId → 沒(méi)登錄用 sessionId → 沒(méi) session 用客戶端 IP。三級(jí)降級(jí),永遠(yuǎn)不會(huì)出現(xiàn) null。
3. 業(yè)務(wù)異常了鎖不釋放怎么辦?
比如用戶提交訂單,業(yè)務(wù)代碼報(bào)了個(gè)異常,但防重鎖已經(jīng)設(shè)了 10 秒。結(jié)果用戶修正數(shù)據(jù)重新提交,被告知"請(qǐng)勿重復(fù)提交"——這體驗(yàn)就很差了。
Guardian 在攔截器的 afterCompletion 里做了處理:如果請(qǐng)求拋了異常,自動(dòng)釋放鎖。正常完成的請(qǐng)求才讓鎖自然過(guò)期。
4. 有些接口不需要防重怎么辦?
全局配了防重之后,健康檢查接口、公開(kāi)查詢接口這些也被攔了。你要么給每個(gè)接口單獨(dú)控制,要么維護(hù)一個(gè)白名單。
Guardian 支持 exclude-urls 白名單,AntPath 通配符匹配,優(yōu)先級(jí)最高。命中直接放行,不走任何防重邏輯。
注解不夠用?試試 YAML 批量配置
單個(gè)接口用注解挺方便,但如果你有 50 個(gè)接口都要配防重,一個(gè)一個(gè)加注解就有點(diǎn)累了。
Guardian 支持在 YAML 里批量配置,用 AntPath 通配符一口氣匹配一批接口:
guardian:
repeat-submit:
storage: redis
key-encrypt: md5
urls:
- pattern: /api/order/**
interval: 10
key-scope: user
message: "訂單正在處理,請(qǐng)勿重復(fù)提交"
- pattern: /api/sms/send
interval: 60
key-scope: ip
exclude-urls:
- /api/public/**
- /api/health幾個(gè)要點(diǎn):
- YAML 規(guī)則的優(yōu)先級(jí)高于注解,同一個(gè)接口兩邊都配了以 YAML 為準(zhǔn)
- 白名單(exclude-urls)優(yōu)先級(jí)最高,命中直接放行
- key-scope 控制防重維度:user(按用戶)、ip(按 IP)、global(全局)
比如短信發(fā)送接口配 key-scope: ip,同一個(gè) IP 60 秒內(nèi)只能發(fā)一次,不管登沒(méi)登錄、哪個(gè)用戶——這比按用戶維度更合理。
攔截了之后怎么響應(yīng)?
這是我糾結(jié)了挺久的一個(gè)設(shè)計(jì)點(diǎn)。
一開(kāi)始只做了拋異常的方式——攔截后拋 RepeatSubmitException,讓業(yè)務(wù)端的全局異常處理器去處理。但后來(lái)想到,有些項(xiàng)目可能就想開(kāi)箱即用,不想為了一個(gè)防重還得寫(xiě)個(gè)異常處理器。
所以做了兩種模式:
guardian:
repeat-submit:
response-mode: exception # 默認(rèn),拋異常
# response-mode: json # 直接返回 JSONexception 模式(默認(rèn)):拋 RepeatSubmitException,你在全局異常處理器里接一下:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(RepeatSubmitException.class)
public Result handleRepeatSubmit(RepeatSubmitException e) {
return Result.fail(e.getMessage());
}
}json 模式:攔截器直接寫(xiě) JSON 響應(yīng),默認(rèn)格式是 {"code":500,"msg":"...","timestamp":...}。
格式不滿意?注冊(cè)一個(gè) 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 也能跑
不是每個(gè)項(xiàng)目都有 Redis 的。本地開(kāi)發(fā)環(huán)境、小型單體應(yīng)用,可能就沒(méi)有 Redis。
guardian:
repeat-submit:
storage: local # 用本地緩存切成 local 就行了,底層用 ConcurrentHashMap 實(shí)現(xiàn),帶定時(shí)過(guò)期清理。當(dāng)然生產(chǎn)環(huán)境還是推薦 Redis,支持分布式。
想看 Redis 存儲(chǔ)和本地存儲(chǔ)的具體實(shí)現(xiàn)?源碼在 guardian-storage-redis 和 RepeatSubmitLocalStorage。
關(guān)于 context-path 的坑
這個(gè)坑我自己踩過(guò)。項(xiàng)目配了 server.servlet.context-path: /admin-api,然后 YAML 里配的 URL 規(guī)則死活匹配不上。
排查了一下發(fā)現(xiàn),request.getRequestURI() 返回的是帶 context-path 的完整路徑(比如 /admin-api/order/submit),但 YAML 里配的可能是 /order/submit。
Guardian 的處理是:匹配時(shí)同時(shí)嘗試完整 URI 和去掉 context-path 后的路徑,兩者有一個(gè)匹配上就算命中。所以不管你 YAML 里寫(xiě)的是 /order/submit 還是 /admin-api/order/submit,都能正確匹配。
防重的內(nèi)部流程
簡(jiǎn)單畫(huà)一下請(qǐng)求的處理流程:
請(qǐng)求進(jìn)入
│
▼
RepeatableRequestFilter ← 緩存請(qǐng)求體,支持重復(fù)讀取
│
▼
RepeatSubmitInterceptor
├─ 1. 匹配白名單 → 命中直接放行
├─ 2. 匹配 YAML 規(guī)則
├─ 3. 檢查 @RepeatSubmit 注解
│ 均未命中 → 放行
▼
KeyGenerator ← 按維度(user/ip/global)生成防重 Key
│
▼
KeyEncrypt ← 可選 MD5 加密
│
▼
Storage.tryAcquire()
├─ 成功 → 放行,寫(xiě)入存儲(chǔ) + 設(shè)置 TTL
└─ 失敗 → 根據(jù) response-mode 響應(yīng)
├─ exception → 拋 RepeatSubmitException
└─ json → 直接寫(xiě) JSON 響應(yīng)
│
▼
業(yè)務(wù)執(zhí)行
├─ 正常 → Key 自然過(guò)期
└─ 異常 → afterCompletion 自動(dòng)釋放二、接口限流
為什么要做限流?
防重復(fù)提交解決的是"同一個(gè)請(qǐng)求短時(shí)間內(nèi)被提交多次"的問(wèn)題,但還有另一類問(wèn)題它管不了:惡意刷接口。
比如有人寫(xiě)個(gè)腳本一秒鐘請(qǐng)求你的搜索接口 1000 次,防重?cái)r不?。ㄒ?yàn)槊看螀?shù)可能不一樣),這時(shí)候就需要限流了。
市面上的限流方案不少,但要么是網(wǎng)關(guān)級(jí)別的(Sentinel、Spring Cloud Gateway),要么得寫(xiě)一堆配置。如果你就是個(gè)普通的 Spring Boot 單體應(yīng)用,想給幾個(gè)接口加個(gè)限流,沒(méi)必要引那么重的東西。
Guardian 的限流就是沖著這個(gè)場(chǎng)景來(lái)的:輕量、注解 + YAML 雙模式、兩種算法可選。
先看效果
<dependency>
<groupId>io.github.biggg-guardian</groupId>
<artifactId>guardian-rate-limit-spring-boot-starter</artifactId>
<version>1.3.0</version>
</dependency>// 滑動(dòng)窗口:每秒最多 10 次 @RateLimit(qps = 10) // 令牌桶:每秒補(bǔ) 5 個(gè)令牌,桶容量 20,允許瞬間突發(fā) 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 優(yōu)先級(jí)高于注解,白名單優(yōu)先級(jí)最高。
滑動(dòng)窗口 vs 令牌桶
這是限流最常用的兩種算法,Guardian 都支持。
滑動(dòng)窗口:統(tǒng)計(jì)時(shí)間窗口內(nèi)的請(qǐng)求次數(shù),超了就拒絕。比如配了 qps=10, window=1s,就是每秒最多 10 次,多了直接打回。
@RateLimit(qps = 10)
特點(diǎn)是嚴(yán)格。窗口內(nèi)絕對(duì)不會(huì)超過(guò)閾值。適合短信發(fā)送、登錄嘗試這種需要精確控制頻率的場(chǎng)景。
令牌桶:桶里裝令牌,按固定速率往里放,請(qǐng)求來(lái)了取一個(gè),桶空了就拒絕。桶滿時(shí)可以一口氣把令牌全用完。
@RateLimit(qps = 5, capacity = 20, algorithm = RateLimitAlgorithm.TOKEN_BUCKET)
這個(gè)配置的意思是:每秒補(bǔ) 5 個(gè)令牌,桶最多攢 20 個(gè)。平時(shí)空閑的時(shí)候令牌慢慢攢,突然來(lái)一波流量,瞬間可以放過(guò) 20 個(gè)請(qǐng)求,打完之后回到每秒 5 個(gè)的穩(wěn)態(tài)。
特點(diǎn)是允許突發(fā)。適合秒殺、搶購(gòu)這種"平時(shí)沒(méi)啥流量,偶爾來(lái)一波高峰"的場(chǎng)景。
舉個(gè)直觀的例子,都是 qps=10,突然來(lái)了 20 個(gè)請(qǐng)求:
| 滑動(dòng)窗口 | 令牌桶(capacity=20) | |
|---|---|---|
| 第 1-10 個(gè) | 通過(guò) | 通過(guò) |
| 第 11-20 個(gè) | 全部拒絕 | 全部通過(guò) |
| 之后每秒 | 最多 10 個(gè) | 最多 10 個(gè) |
補(bǔ)充速率怎么控制?
令牌桶的補(bǔ)充速率通過(guò) qps 和 window 兩個(gè)參數(shù)控制:
- qps=10, window=1s → 每秒補(bǔ) 10 個(gè)
- qps=10, window=1min → 每分鐘補(bǔ) 10 個(gè)(約 6 秒補(bǔ) 1 個(gè))
// 每分鐘補(bǔ) 10 個(gè)令牌,桶容量 10
@RateLimit(qps = 10, window = 1, windowUnit = TimeUnit.MINUTES,
capacity = 10, algorithm = RateLimitAlgorithm.TOKEN_BUCKET)這樣就能實(shí)現(xiàn)慢速補(bǔ)充的場(chǎng)景。
限流維度
和防重一樣,限流也支持三種維度:
| 維度 | 效果 | 典型場(chǎng)景 |
|---|---|---|
| GLOBAL(默認(rèn)) | 整個(gè)接口共用一個(gè)計(jì)數(shù)器 | 全站搜索接口 |
| IP | 每個(gè) IP 獨(dú)立計(jì)數(shù) | 短信發(fā)送、驗(yàn)證碼 |
| USER | 每個(gè)用戶獨(dú)立計(jì)數(shù) | 用戶操作頻率限制 |
@RateLimit(qps = 1, rateLimitScope = RateLimitKeyScope.IP, message = "短信發(fā)送過(guò)于頻繁")
限流的響應(yīng)處理
和防重一樣,兩種模式:
guardian:
rate-limit:
response-mode: exception # 默認(rèn),拋 RateLimitException
# response-mode: json # 直接返回 JSON也支持自定義響應(yīng)處理器,注冊(cè)一個(gè) RateLimitResponseHandler Bean 就行。
三、一些設(shè)計(jì)細(xì)節(jié)
并發(fā)安全
限流對(duì)并發(fā)安全的要求比防重高。你想,10 個(gè)請(qǐng)求同時(shí)進(jìn)來(lái),限流閾值是 5,如果并發(fā)控制沒(méi)做好,可能 10 個(gè)都放過(guò)去了。
Guardian 的處理:
- Redis:滑動(dòng)窗口和令牌桶都用 Lua 腳本,Redis 單線程執(zhí)行 Lua 是天然原子的
- 本地緩存:synchronized 鎖到 Key 粒度,不同 Key 之間互不阻塞
防重那邊也是一樣,Redis 用 SET NX EX 原子操作,本地緩存用 ConcurrentHashMap 的原子方法。
本地緩存的內(nèi)存管理
用 ConcurrentHashMap 做本地存儲(chǔ)有個(gè)容易忽略的問(wèn)題:Key 只進(jìn)不出,長(zhǎng)時(shí)間運(yùn)行內(nèi)存會(huì)一直漲。
Guardian 在防重和限流的本地存儲(chǔ)里都加了守護(hù)線程,每 5 分鐘掃一次,清理過(guò)期的 Key。線程是 daemon 的,不會(huì)阻止 JVM 關(guān)閉。源碼可以看 RateLimitLocalStorage。
可插拔架構(gòu)
兩個(gè)模塊的核心組件都是面向接口編程的,框架內(nèi)部用 @ConditionalOnMissingBean 做的,你不注冊(cè)就用默認(rèn)的,注冊(cè)了就用你的:
| 組件 | 防重復(fù)提交 | 接口限流 |
|---|---|---|
| Key 生成 | RepeatSubmitKeyGenerator | RateLimitKeyGenerator |
| Key 加密 | AbstractKeyEncrypt | AbstractKeyEncrypt |
| 存儲(chǔ) | RepeatSubmitStorage | RateLimitStorage |
| 響應(yīng)處理 | RepeatSubmitResponseHandler | RateLimitResponseHandler |
| 用戶上下文 | UserContext(共享) | UserContext(共享) |
可觀測(cè)性
兩個(gè)模塊都內(nèi)置了監(jiān)控能力:
攔截日志:log-enabled: true 開(kāi)啟后,攔截/放行都有日志輸出。
Actuator 端點(diǎn):
GET /actuator/guardianRepeatSubmit → 防重統(tǒng)計(jì) GET /actuator/guardianRateLimit → 限流統(tǒng)計(jì)
限流的統(tǒng)計(jì)數(shù)據(jù)長(zhǎng)這樣:
{
"totalRequestCount": 5560,
"totalPassCount": 5432,
"totalBlockCount": 128,
"blockRate": "2.30%",
"topBlockedApis": { "/api/sms/send": 56 },
"topRequestApis": { "/api/search": 3200 }
}項(xiàng)目結(jié)構(gòu)
guardian-parent ├── guardian-core # 公共基礎(chǔ)(共享類) ├── guardian-repeat-submit/ # 防重復(fù)提交 │ ├── 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 存儲(chǔ)(多模塊共享) └── guardian-example/ # 示例工程
模塊拆分是為了靈活組合。比如你只需要防重就引 guardian-repeat-submit-spring-boot-starter,只需要限流就引 guardian-rate-limit-spring-boot-starter,都需要就兩個(gè)都引,互不影響。
guardian-core 放的是兩個(gè)模塊都用到的公共類,比如 UserContext、GuardianResponseHandler。guardian-storage-redis 是 Redis 存儲(chǔ)的共享實(shí)現(xiàn),兩個(gè)模塊的 Redis 存儲(chǔ)都在這里面。
完整的示例代碼在 guardian-example 模塊里,防重 + 限流的各種場(chǎng)景都有,clone 下來(lái)直接跑。
總結(jié)
Guardian 做了兩件事:
- 防重復(fù)提交:攔截短時(shí)間內(nèi)的重復(fù)請(qǐng)求,支持注解 / YAML、用戶 / IP / 全局維度、異常自動(dòng)釋放、context-path 兼容
- 接口限流:控制接口訪問(wèn)頻率,支持滑動(dòng)窗口 / 令牌桶、突發(fā)流量處理、三種維度
兩個(gè)功能獨(dú)立 Starter,核心組件全部可插拔,注冊(cè) Bean 就能替換默認(rèn)實(shí)現(xiàn)。Redis 和本地緩存一鍵切換。
如果你的 Spring Boot 項(xiàng)目里需要這些能力,但又不想引 Sentinel 那么重的東西,可以試試。
Maven Central 坐標(biāo)(最新 v1.3.0):
<!-- 防重復(fù)提交 -->
<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>項(xiàng)目地址(README 里有完整配置文檔和更新日志):
GitHub:https://github.com/BigGG-Guardian/guardian/tree/master/guardian-storage-redis
到此這篇關(guān)于SpringBoot 接口防護(hù)(防重提交 + 限流)的文章就介紹到這了,更多相關(guān)SpringBoot 接口防護(hù)內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
- SpringBoot 防止接口惡意多次請(qǐng)求的操作
- SpringBoot中防止接口重復(fù)提交的有效方法
- SpringBoot接口防重復(fù)提交的三種解決方案
- SpringBoot實(shí)現(xiàn)接口防刷的兩種方法
- SpringBoot接口防抖(防重復(fù)提交)的實(shí)現(xiàn)方案
- SpringBoot項(xiàng)目中接口防刷的完整代碼
- 基于注解實(shí)現(xiàn) SpringBoot 接口防刷的方法
- SpringBoot+Redis實(shí)現(xiàn)接口防刷的示例代碼
- 詳解Springboot如何通過(guò)注解實(shí)現(xiàn)接口防刷
相關(guān)文章
Spring 自動(dòng)裝配的二義性實(shí)例解析
這篇文章主要介紹了Spring 自動(dòng)裝配的二義性實(shí)例解析,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2019-11-11
使用arthas命令redefine實(shí)現(xiàn)Java熱更新(推薦)
今天分享一個(gè)非常重要的命令 redefine ,主要作用是加載外部的 .class 文件,用來(lái)替換 JVM 已經(jīng)加載的類,總結(jié)起來(lái)就是實(shí)現(xiàn)了 Java 的熱更新,感興趣的朋友跟隨小編一起看看吧2020-05-05
Spring Boot 簡(jiǎn)介(入門(mén)篇)
Spring Boot是由Pivotal團(tuán)隊(duì)提供的全新框架,其設(shè)計(jì)目的是用來(lái)簡(jiǎn)化新Spring應(yīng)用的初始搭建以及開(kāi)發(fā)過(guò)程。下面通過(guò)本文給大家介紹spring boot相關(guān)知識(shí),需要的的朋友參考下吧2017-04-04
MyBatis Oracle 自增序列的實(shí)現(xiàn)方法
這篇文章給大家分享MyBatis Oracle 自增序列的實(shí)現(xiàn)方法及mybatis配置oracle的主鍵自增長(zhǎng)的方法,非常不錯(cuò)具有一定的參考借鑒價(jià)值,感興趣的朋友一起看看吧2016-11-11
Java代碼審計(jì)的一些基礎(chǔ)知識(shí)你知道嗎
這篇文章主要介紹了基于Java的代碼審計(jì)功能的基礎(chǔ)知識(shí),小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2021-09-09
idea設(shè)置JVM運(yùn)行參數(shù)的幾種方式
對(duì)JVM運(yùn)行參數(shù)進(jìn)行修改是JVM性能調(diào)優(yōu)的重要手段,本文主要介紹了idea設(shè)置JVM運(yùn)行參數(shù)的幾種方式,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2022-04-04

