SpringBoot使用Redis對(duì)用戶IP進(jìn)行接口限流的項(xiàng)目實(shí)踐
一、思路
使用接口限流的主要目的在于提高系統(tǒng)的穩(wěn)定性,防止接口被惡意打擊(短時(shí)間內(nèi)大量請(qǐng)求)。
比如要求某接口在1分鐘內(nèi)請(qǐng)求次數(shù)不超過1000次,那么應(yīng)該如何設(shè)計(jì)代碼呢?
下面講兩種思路,如果想看代碼可直接翻到后面的代碼部分。
1.1 固定時(shí)間段(舊思路)
1.1.1 思路描述
該方案的思路是:使用Redis記錄固定時(shí)間段內(nèi)某用戶IP訪問某接口的次數(shù),其中:
- Redis的key:用戶IP + 接口方法名
- Redis的value:當(dāng)前接口訪問次數(shù)。
當(dāng)用戶在近期內(nèi)第一次訪問該接口時(shí),向Redis中設(shè)置一個(gè)包含了用戶IP和接口方法名的key,value的值初始化為1(表示第一次訪問當(dāng)前接口)。同時(shí),設(shè)置該key的過期時(shí)間(比如為60秒)。
之后,只要這個(gè)key還未過期,用戶每次訪問該接口都會(huì)導(dǎo)致value自增1次。
用戶每次訪問接口前,先從Redis中拿到當(dāng)前接口訪問次數(shù),如果發(fā)現(xiàn)訪問次數(shù)大于規(guī)定的次數(shù)(如超過1000次),則向用戶返回接口訪問失敗的標(biāo)識(shí)。
1.1.2 思路缺陷
該方案的缺點(diǎn)在于,限流時(shí)間段是固定的。
比如要求某接口在1分鐘內(nèi)請(qǐng)求次數(shù)不超過1000次,觀察以下流程:
可以發(fā)現(xiàn),00:59和01:01之間僅僅間隔了2秒,但接口卻被訪問了1000+999=1999次,是限流次數(shù)(1000次)的2倍!
所以在該方案中,限流次數(shù)的設(shè)置可能不起作用,仍然可能在短時(shí)間內(nèi)造成大量訪問。
1.2 滑動(dòng)窗口(新思路)
1.2.1 思路描述
為了避免出現(xiàn)方案1中由于鍵過期導(dǎo)致的短期訪問量增大的情況,我們可以改變一下思路,也就是把固定的時(shí)間段改成動(dòng)態(tài)的:
假設(shè)某個(gè)接口在10秒內(nèi)只允許訪問5次。用戶每次訪問接口時(shí),記錄當(dāng)前用戶訪問的時(shí)間點(diǎn)(時(shí)間戳),并計(jì)算前10秒內(nèi)用戶訪問該接口的總次數(shù)。如果總次數(shù)大于限流次數(shù),則不允許用戶訪問該接口。這樣就能保證在任意時(shí)刻用戶的訪問次數(shù)不會(huì)超過1000次。
如下圖,假設(shè)用戶在0:19時(shí)間點(diǎn)訪問接口,經(jīng)檢查其前10秒內(nèi)訪問次數(shù)為5次,則允許本次訪問。
假設(shè)用戶0:20時(shí)間點(diǎn)訪問接口,經(jīng)檢查其前10秒內(nèi)訪問次數(shù)為6次(超出限流次數(shù)5次),則不允許本次訪問。
1.2.2 Redis部分的實(shí)現(xiàn)
1)選用何種 Redis 數(shù)據(jù)結(jié)構(gòu)
首先是需要確定使用哪個(gè)Redis數(shù)據(jù)結(jié)構(gòu)。用戶每次訪問時(shí),需要用一個(gè)key記錄用戶訪問的時(shí)間點(diǎn),而且還需要利用這些時(shí)間點(diǎn)進(jìn)行范圍檢查。
2)為何選擇 zSet 數(shù)據(jù)結(jié)構(gòu)
為了能夠?qū)崿F(xiàn)范圍檢查,可以考慮使用Redis中的zSet有序集合。
添加一個(gè)zSet元素的命令如下:
ZADD?[key]?[score]?[member]
它有一個(gè)關(guān)鍵的屬性score,通過它可以記錄當(dāng)前member的優(yōu)先級(jí)。
于是我們可以把score設(shè)置成用戶訪問接口的時(shí)間戳,以便于通過score進(jìn)行范圍檢查。key則記錄用戶IP和接口方法名,至于member設(shè)置成什么沒有影響,一個(gè)member記錄了用戶訪問接口的時(shí)間點(diǎn)。因此member也可以設(shè)置成時(shí)間戳。
3)zSet 如何進(jìn)行范圍檢查(檢查前幾秒的訪問次數(shù))
思路是,把特定時(shí)間間隔之前的member都刪掉,留下的member就是時(shí)間間隔之內(nèi)的總訪問次數(shù)。然后統(tǒng)計(jì)當(dāng)前key中的member有多少個(gè)即可。
① 把特定時(shí)間間隔之前的member都刪掉。
zSet有如下命令,用于刪除score范圍在[min~max]
之間的member:
Zremrangebyscore?[key]?[min]?[max]
假設(shè)限流時(shí)間設(shè)置為5秒,當(dāng)前用戶訪問接口時(shí),獲取當(dāng)前系統(tǒng)時(shí)間戳為currentTimeMill
,那么刪除的score范圍可以設(shè)置為:
min = 0 max = currentTimeMill - 5 * 1000
相當(dāng)于把5秒之前的所有member都刪除了,只留下前5秒內(nèi)的key。
② 統(tǒng)計(jì)特定key中已存在的member有多少個(gè)。
zSet有如下命令,用于統(tǒng)計(jì)某個(gè)key的member總數(shù):
?ZCARD?[key]
統(tǒng)計(jì)的key的member總數(shù),就是當(dāng)前接口已經(jīng)訪問的次數(shù)。如果該數(shù)目大于限流次數(shù),則說明當(dāng)前的訪問應(yīng)被限流。
二、代碼實(shí)現(xiàn)
主要是使用注解 + AOP的形式實(shí)現(xiàn)。
2.1 固定時(shí)間段思路
使用了lua腳本。
2.1.1 限流注解
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface RateLimiter { /** * 限流時(shí)間,單位秒 */ int time() default 5; /** * 限流次數(shù) */ int count() default 10; }
2.1.2 定義lua腳本
在resources/lua
下新建limit.lua
:
-- 獲取redis鍵 local key = KEYS[1] -- 獲取第一個(gè)參數(shù)(次數(shù)) local count = tonumber(ARGV[1]) -- 獲取第二個(gè)參數(shù)(時(shí)間) local time = tonumber(ARGV[2]) -- 獲取當(dāng)前流量 local current = redis.call('get', key); -- 如果current值存在,且值大于規(guī)定的次數(shù),則拒絕放行(直接返回當(dāng)前流量) if current and tonumber(current) > count then return tonumber(current) end -- 如果值小于規(guī)定次數(shù),或值不存在,則允許放行,當(dāng)前流量數(shù)+1 (值不存在情況下,可以自增變?yōu)?) current = redis.call('incr', key); -- 如果是第一次進(jìn)來,那么開始設(shè)置鍵的過期時(shí)間。 if tonumber(current) == 1 then redis.call('expire', key, time); end -- 返回當(dāng)前流量 return tonumber(current)
2.1.3 注入Lua執(zhí)行腳本
關(guān)鍵代碼是limitScript()
方法
@Configuration public class RedisConfig { @Bean public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>(); redisTemplate.setConnectionFactory(connectionFactory); // 使用Jackson2JsonRedisSerialize 替換默認(rèn)序列化(默認(rèn)采用的是JDK序列化) Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class); ObjectMapper om = new ObjectMapper(); om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL); jackson2JsonRedisSerializer.setObjectMapper(om); redisTemplate.setKeySerializer(jackson2JsonRedisSerializer); redisTemplate.setValueSerializer(jackson2JsonRedisSerializer); redisTemplate.setHashKeySerializer(jackson2JsonRedisSerializer); redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer); return redisTemplate; } /** * 解析lua腳本的bean */ @Bean("limitScript") public DefaultRedisScript<Long> limitScript() { DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(); redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/limit.lua"))); redisScript.setResultType(Long.class); return redisScript; } }
2.1.3 定義Aop切面類
@Slf4j @Aspect @Component public class RateLimiterAspect { @Autowired private RedisTemplate redisTemplate; @Autowired private RedisScript<Long> limitScript; @Before("@annotation(rateLimiter)") public void doBefore(JoinPoint point, RateLimiter rateLimiter) throws Throwable { int time = rateLimiter.time(); int count = rateLimiter.count(); String combineKey = getCombineKey(rateLimiter.type(), point); List<String> keys = Collections.singletonList(combineKey); try { Long number = (Long) redisTemplate.execute(limitScript, keys, count, time); // 當(dāng)前流量number已超過限制,則拋出異常 if (number == null || number.intValue() > count) { throw new RuntimeException("訪問過于頻繁,請(qǐng)稍后再試"); } log.info("[limit] 限制請(qǐng)求數(shù)'{}',當(dāng)前請(qǐng)求數(shù)'{}',緩存key'{}'", count, number.intValue(), combineKey); } catch (Exception ex) { ex.printStackTrace(); throw new RuntimeException("服務(wù)器限流異常,請(qǐng)稍候再試"); } } /** * 把用戶IP和接口方法名拼接成 redis 的 key * @param point 切入點(diǎn) * @return 組合key */ private String getCombineKey(JoinPoint point) { StringBuilder sb = new StringBuilder("rate_limit:"); ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest request = attributes.getRequest(); sb.append( Utils.getIpAddress(request) ); MethodSignature signature = (MethodSignature) point.getSignature(); Method method = signature.getMethod(); Class<?> targetClass = method.getDeclaringClass(); // keyPrefix + "-" + class + "-" + method return sb.append("-").append( targetClass.getName() ) .append("-").append(method.getName()).toString(); } }
2.2 滑動(dòng)窗口思路
2.2.1 限流注解
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface RateLimiter { /** * 限流時(shí)間,單位秒 */ int time() default 5; /** * 限流次數(shù) */ int count() default 10; }
2.2.2 定義Aop切面類
@Slf4j @Aspect @Component public class RateLimiterAspect { @Autowired private RedisTemplate redisTemplate; /** * 實(shí)現(xiàn)限流(新思路) * @param point * @param rateLimiter * @throws Throwable */ @SuppressWarnings("unchecked") @Before("@annotation(rateLimiter)") public void doBefore(JoinPoint point, RateLimiter rateLimiter) throws Throwable { // 在 {time} 秒內(nèi)僅允許訪問 {count} 次。 int time = rateLimiter.time(); int count = rateLimiter.count(); // 根據(jù)用戶IP(可選)和接口方法,構(gòu)造key String combineKey = getCombineKey(rateLimiter.type(), point); // 限流邏輯實(shí)現(xiàn) ZSetOperations zSetOperations = redisTemplate.opsForZSet(); // 記錄本次訪問的時(shí)間結(jié)點(diǎn) long currentMs = System.currentTimeMillis(); zSetOperations.add(combineKey, currentMs, currentMs); // 這一步是為了防止member一直存在于內(nèi)存中 redisTemplate.expire(combineKey, time, TimeUnit.SECONDS); // 移除{time}秒之前的訪問記錄(滑動(dòng)窗口思想) zSetOperations.removeRangeByScore(combineKey, 0, currentMs - time * 1000); // 獲得當(dāng)前窗口內(nèi)的訪問記錄數(shù) Long currCount = zSetOperations.zCard(combineKey); // 限流判斷 if (currCount > count) { log.error("[limit] 限制請(qǐng)求數(shù)'{}',當(dāng)前請(qǐng)求數(shù)'{}',緩存key'{}'", count, currCount, combineKey); throw new RuntimeException("訪問過于頻繁,請(qǐng)稍后再試!"); } } /** * 把用戶IP和接口方法名拼接成 redis 的 key * @param point 切入點(diǎn) * @return 組合key */ private String getCombineKey(JoinPoint point) { StringBuilder sb = new StringBuilder("rate_limit:"); ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest request = attributes.getRequest(); sb.append( Utils.getIpAddress(request) ); MethodSignature signature = (MethodSignature) point.getSignature(); Method method = signature.getMethod(); Class<?> targetClass = method.getDeclaringClass(); // keyPrefix + "-" + class + "-" + method return sb.append("-").append( targetClass.getName() ) .append("-").append(method.getName()).toString(); } }
到此這篇關(guān)于SpringBoot使用Redis對(duì)用戶IP進(jìn)行接口限流的文章就介紹到這了,更多相關(guān)SpringBoot Redis接口限流內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
- Springboot+Redis實(shí)現(xiàn)API接口防刷限流的項(xiàng)目實(shí)踐
- SpringBoot整合Redis并且用Redis實(shí)現(xiàn)限流的方法 附Redis解壓包
- 基于SpringBoot+Redis實(shí)現(xiàn)一個(gè)簡單的限流器
- SpringBoot使用Redis對(duì)用戶IP進(jìn)行接口限流的示例詳解
- SpringBoot Redis用注釋實(shí)現(xiàn)接口限流詳解
- 使用SpringBoot?+?Redis?實(shí)現(xiàn)接口限流的方式
- SpringBoot中使用Redis對(duì)接口進(jìn)行限流的實(shí)現(xiàn)
- springboot+redis 實(shí)現(xiàn)分布式限流令牌桶的示例代碼
- SpringBoot整合redis實(shí)現(xiàn)計(jì)數(shù)器限流的示例
相關(guān)文章
微服務(wù)Spring?Boot?整合Redis?阻塞隊(duì)列實(shí)現(xiàn)異步秒殺下單思路詳解
這篇文章主要介紹了微服務(wù)Spring?Boot?整合Redis?阻塞隊(duì)列實(shí)現(xiàn)異步秒殺下單,使用阻塞隊(duì)列實(shí)現(xiàn)秒殺的優(yōu)化,采用異步秒殺完成下單的優(yōu)化,本文給大家分享詳細(xì)步驟及實(shí)現(xiàn)思路,需要的朋友可以參考下2022-10-10Java 中的 NoSuchMethodException 異常及解決思路(最新推薦)
NoSuchMethodException異常是Java中使用反射機(jī)制時(shí)常見的錯(cuò)誤,它通常由方法名或參數(shù)不匹配、訪問權(quán)限問題、方法簽名不匹配等原因引發(fā),解決方法包括核實(shí)方法名及其參數(shù)類型、確認(rèn)方法訪問權(quán)限、檢查方法簽名和重載問題、確保方法存在于正確的類中,感興趣的朋友一起看看吧2025-01-01Spring、Spring?Boot、Spring?Cloud?的區(qū)別與聯(lián)系分析
Spring、SpringBoot和SpringCloud是Java開發(fā)中常用的框架,分別針對(duì)企業(yè)級(jí)應(yīng)用開發(fā)、快速開發(fā)和分布式系統(tǒng),本文介紹Spring、Spring?Boot、Spring?Cloud?的區(qū)別與聯(lián)系,感興趣的朋友一起看看吧2025-03-03Java調(diào)用計(jì)算機(jī)攝像頭拍照實(shí)現(xiàn)過程解析
這篇文章主要介紹了Java調(diào)用計(jì)算機(jī)攝像頭拍照實(shí)現(xiàn)過程解析,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2020-05-05Java的泛型擦除和運(yùn)行時(shí)泛型信息獲取方式
Java泛型在編譯時(shí)會(huì)發(fā)生類型擦除,即泛型參數(shù)被替換為它們的限定類型(如Object),這使得ArrayList<Integer>和ArrayList<String>在運(yùn)行時(shí)類型相同,盡管如此,我們可以通過定義類或匿名內(nèi)部類的方式在運(yùn)行時(shí)獲取泛型信息2024-09-09