Spring Boot高可用限流三種實現(xiàn)解決方案
1.什么是限流
限流是對某一時間窗口內(nèi)的請求數(shù)進行限制,保持系統(tǒng)的可用性和穩(wěn)定性,防止因流量暴增而導(dǎo)致的系統(tǒng)運行緩慢或宕機。
為什么需要限流
其實限流思想在生活中隨處可見,例如景區(qū)限流,防止人滿為患。熱門餐飲需要排隊就餐等?;氐交ヂ?lián)網(wǎng)網(wǎng)絡(luò)上,同樣也是這個道理,例如某某明星公布了戀情,訪問從平時的50萬增加到了500萬,系統(tǒng)最多可以支撐200萬訪問,那么就要執(zhí)行限流規(guī)則,保證系統(tǒng)是一個可用的狀態(tài),不至于服務(wù)器崩潰導(dǎo)致所有請求不可用。還有12306購票系統(tǒng),每年的618,雙11等場景都需要限流來抗住高并發(fā)的瞬時流量,保證系統(tǒng)能正常服務(wù),不被沖垮宕機癱瘓。
2.限流算法
計數(shù)器算法
計數(shù)器限流算法是最為簡單粗暴的解決方案,主要用來限制總并發(fā)數(shù),比如數(shù)據(jù)庫連接池大小、線程池大小、接口訪問并發(fā)數(shù)等都是使用計數(shù)器算法。一般我們會限制一秒鐘能夠通過的請求數(shù)。比如我們規(guī)定,對于A接口來說,我們1分鐘的訪問次數(shù)不能超過1000個。那么我們可以這么做:在一開始的時候,我們可以設(shè)置一個計數(shù)器counter,每當(dāng)一個請求過來的時候, counter就加1,如果counter的值大于1000并且該請求與第一個請求的間隔時間還在1分鐘之內(nèi),那么說明請求數(shù)過多; 如果該請求與第一個請求的間隔時間大于1分鐘,且counter的值還在限流范圍內(nèi),那么就重置 counter。
代碼實現(xiàn)如下:
/** * 固定窗口時間算法 * @return */ public class Counter { public long timeStamp = System.currentTimeMillis(); // 當(dāng)前時間 public int reqCount = 0; // 初始化計數(shù)器 public final int limit = 1000; // 時間窗口內(nèi)最大請求數(shù) public final long interval = 1000 * 60; // 時間窗口ms ? public boolean limit() { long now = System.currentTimeMillis(); if (now < timeStamp + interval) { // 在時間窗口內(nèi) reqCount++; // 判斷當(dāng)前時間窗口內(nèi)是否超過最大請求控制數(shù) return reqCount <= limit; } else { timeStamp = now; // 超時后重置 reqCount = 1; return true; } } }
但是,這種上面的固定時間窗口算法有一個很明顯的臨界問題:假設(shè)限流閥值為5個請求,單位時間窗口是1s,如果我們在單位時間內(nèi)的前0.8-1s和1-1.2s,分別并發(fā)5個請求。雖然都沒有超過閥值,但是如果算0.8-1.2s,則并發(fā)數(shù)高達10,已經(jīng)超過單位時間1s不超過5閥值的定義啦。
滑動窗口限流算法
滑動窗口限流解決固定窗口臨界值的問題。它將單位時間周期分為n個小周期,分別記錄每個小周期內(nèi)接口的訪問次數(shù),并且根據(jù)時間滑動刪除過期的小周期。如下圖所示:展示了滑動窗口算法對時間區(qū)間的劃分
假設(shè)單位時間還是1s,滑動窗口算法把它劃分為5個小周期,也就是滑動窗口(單位時間)被劃分為5個小格子。每格表示0.2s。每過0.2s,時間窗口就會往右滑動一格。然后呢,每個小周期,都有自己獨立的計數(shù)器,如果請求是0.83s到達的,0.8~1.0s對應(yīng)的計數(shù)器就會加1。
假設(shè)我們1s內(nèi)的限流閥值還是5個請求,0.81.0s內(nèi)(比如0.9s的時候)來了5個請求,落在黃色格子里。時間過了1.0s這個點之后,又來5個請求,落在紫色格子里。如果是固定窗口算法,是不會被限流的,但是滑動窗口的話,每過一個小周期,它會右移一個小格。過了1.0s這個點后,會右移一小格,當(dāng)前的單位時間段是0.21.2s,這個區(qū)域的請求已經(jīng)超過限定的5了,已觸發(fā)限流啦,實際上,紫色格子的請求都被拒絕啦。
TIPS: 當(dāng)滑動窗口的格子周期劃分的越多,那么滑動窗口的滾動就越平滑,限流的統(tǒng)計就會越精確。
/** * 單位時間劃分的小周期(單位時間是1分鐘,10s一個小格子窗口,一共6個格子) */ private int SUB_CYCLE = 10; /** * 每分鐘限流請求數(shù) */ private int thresholdPerMin = 100; /** * 計數(shù)器, k-為當(dāng)前窗口的開始時間值秒,value為當(dāng)前窗口的計數(shù) */ private final TreeMap<Long, Integer> counters = new TreeMap<>(); /** * 滑動窗口時間算法實現(xiàn) */ boolean slidingWindowsTryAcquire() { long currentWindowTime = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) / SUB_CYCLE * SUB_CYCLE; //獲取當(dāng)前時間在哪個小周期窗口 int currentWindowNum = countCurrentWindow(currentWindowTime); //當(dāng)前窗口總請求數(shù) //超過閥值限流 if (currentWindowNum >= thresholdPerMin) { return false; } //計數(shù)器+1 counters.get(currentWindowTime)++; return true; } /** * 統(tǒng)計當(dāng)前窗口的請求數(shù) */ private int countCurrentWindow(long currentWindowTime) { //計算窗口開始位置 long startTime = currentWindowTime - SUB_CYCLE* (60s/SUB_CYCLE-1); int count = 0; //遍歷存儲的計數(shù)器 Iterator<Map.Entry<Long, Integer>> iterator = counters.entrySet().iterator(); while (iterator.hasNext()) { Map.Entry<Long, Integer> entry = iterator.next(); // 刪除無效過期的子窗口計數(shù)器 if (entry.getKey() < startTime) { iterator.remove(); } else { //累加當(dāng)前窗口的所有計數(shù)器之和 count =count + entry.getValue(); } } return count; }
滑動窗口算法雖然解決了固定窗口的臨界問題,但是一旦到達限流后,請求都會直接暴力被拒絕
漏桶算法
漏桶算法思路很簡單,我們把水比作是請求,漏桶比作是系統(tǒng)處理能力極限,水先進入到漏桶里,漏桶里的水按一定速率流出,當(dāng)流出的速率小于流入的速率時,由于漏桶容量有限,后續(xù)進入的水直接溢出(拒絕請求),以此實現(xiàn)限流。
令牌桶算法
令牌桶算法則是一個存放固定容量令牌的桶,按照固定速率往桶里添加令牌。桶中存放的令牌數(shù)有最大上限,超出之后就被丟棄或者拒絕。當(dāng)流量或者網(wǎng)絡(luò)請求到達時,每個請求都要獲取一個令牌,如果能夠獲取到,則直接處理,并且令牌桶刪除一個令牌。如果獲取不同,該請求就要被限流,要么直接丟棄,要么在緩沖區(qū)等待。
令牌桶和漏桶算法區(qū)別:
1)令牌桶是按照固定速率往桶中添加令牌,請求是否被處理需要看桶中令牌是否足夠,當(dāng)令牌數(shù)減為零時則拒絕新的請求;漏桶則是按照常量固定速率流出請求,流入請求速率任意,當(dāng)流入的請求數(shù)累積到漏桶容量時,則新流入的請求被拒絕;
2)令牌桶限制的是平均流入速率,允許突發(fā)請求,只要有令牌就可以處理,支持一次拿3個令牌,4個令牌;漏桶限制的是常量流出速率,即流出速率是一個固定常量值,比如都是1的速率流出,而不能一次是1,下次又是2,從而平滑突發(fā)流入速率;
3)令牌桶允許一定程度的突發(fā),而漏桶主要目的是平滑流出速率;
3.guava實現(xiàn)限流
Google開源工具包Guava提供了限流工具類RateLimiter,該類基于令牌桶算法實現(xiàn)流量限制,使用十分方便,而且十分高效。RateLimiter提供了令牌桶算法實現(xiàn):平滑突發(fā)限流(SmoothBursty)和平滑預(yù)熱限流(SmoothWarmingUp)。
依賴包
<dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>29.0-jre</version> </dependency>
使用示例
/** * @author fjzheng * @version 1.0 * @date 2021/9/1 10:38 */ @Slf4j @RestController @RequestMapping("/test") @ResponseResultBody public class TestController { /** * 限流策略 :1秒鐘2個請求 */ private final RateLimiter limiter = RateLimiter.create(2.0); private DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); @GetMapping("/rateLimit") public String testLimiter() { //500毫秒內(nèi),沒拿到令牌,就直接進入服務(wù)降級 boolean tryAcquire = limiter.tryAcquire(50, TimeUnit.MILLISECONDS); if (!tryAcquire) { log.warn("進入服務(wù)降級,時間{}", LocalDateTime.now().format(dtf)); return "當(dāng)前排隊人數(shù)較多,請稍后再試!"; } ? log.info("獲取令牌成功,時間{}", LocalDateTime.now().format(dtf)); return "請求成功"; } ? }
以上用到了RateLimiter的2個核心方法:create()、tryAcquire(),以下為詳細(xì)說明
- acquire() 獲取一個令牌, 該方法會阻塞直到獲取到這一個令牌, 返回值為獲取到這個令牌花費的時間
- acquire(int permits) 獲取指定數(shù)量的令牌, 該方法也會阻塞, 返回值為獲取到這 permits 個令牌花費的時間
- tryAcquire() 判斷時候能獲取到令牌, 如果不能獲取立即返回 false
- tryAcquire(int permits) 獲取指定數(shù)量的令牌, 如果不能獲取立即返回 false
- tryAcquire(long timeout, TimeUnit unit) 判斷能否在指定時間內(nèi)獲取到令牌, 如果不能獲取立即返回 false
- tryAcquire(int permits, long timeout, TimeUnit unit) 判斷能否在指定時間內(nèi)獲取到permits個令牌,如果不能則返回false。
這時候調(diào)用上面的測試接口,一秒鐘只能通過2個接口,同時每隔0.5s產(chǎn)生一個令牌,調(diào)用過于頻繁賊會被限制。但是上面的使用方式不夠優(yōu)雅,因為我們需要在每個需要限流的接口重復(fù)使用tryAcquire()方法,然后根據(jù)是否獲取到令牌做邏輯判斷
優(yōu)雅使用
所謂優(yōu)雅就是統(tǒng)一處理,使用aop實現(xiàn)一個攔截器即可,如下所示:
首先我們的創(chuàng)建一個注解,該注解可以配置限流信息
/** * @author fjzheng * @version 1.0 * @date 2021/10/25 00:06 */ @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.METHOD, ElementType.TYPE}) @Documented public @interface RateLimit { /** * 資源的key,唯一 * 作用:不同的接口,不同的流量控制 */ String key() default ""; ? /** * 最多的訪問限制次數(shù) */ double permitsPerSecond () ; ? /** * 獲取令牌最大等待時間 */ long timeout(); ? /** * 獲取令牌最大等待時間,單位(例:分鐘/秒/毫秒) 默認(rèn):毫秒 */ TimeUnit timeunit() default TimeUnit.MILLISECONDS; ? /** * 得不到令牌的提示語 */ String msg() default "系統(tǒng)繁忙,請稍后再試."; ? }
然后使用aop攔截帶有RateLimit注解的方法,進行統(tǒng)一處理即可。
/** * @author fjzheng * @version 1.0 * @date 2021/10/25 00:10 */ @Slf4j @Component @Aspect public class RateLimitAop { /** * 不同的接口,不同的流量控制 * map的key為 Limiter.key */ private final Map<String, RateLimiter> limitMap = Maps.newConcurrentMap(); ? @Around("@annotation(com.shepherd.mall.seckill.annotation.RateLimit)") public Object around(ProceedingJoinPoint joinPoint) throws Throwable{ MethodSignature signature = (MethodSignature) joinPoint.getSignature(); Method method = signature.getMethod(); //拿limit的注解 RateLimit limit = method.getAnnotation(RateLimit.class); if (limit != null) { //key作用:不同的接口,不同的流量控制 String key=limit.key(); RateLimiter rateLimiter = null; //驗證緩存是否有命中key if (!limitMap.containsKey(key)) { // 創(chuàng)建令牌桶 rateLimiter = RateLimiter.create(limit.permitsPerSecond()); limitMap.put(key, rateLimiter); log.info("新建了令牌桶={},容量={}",key,limit.permitsPerSecond()); } rateLimiter = limitMap.get(key); // 拿令牌 boolean acquire = rateLimiter.tryAcquire(limit.timeout(), limit.timeunit()); // 拿不到命令,直接返回異常提示 if (!acquire) { log.debug("令牌桶={},獲取令牌失敗",key); this.responseFail(limit.msg()); return null; } } return joinPoint.proceed(); } ? /** * 直接向前端拋出異常 * @param msg 提示信息 */ private void responseFail(String msg) { HttpServletResponse response=((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse(); ResponseVO<Object> responseVO = ResponseVO.failure(400, msg); WebUtil.writeJson(response, responseVO); } }
優(yōu)雅使用示例
/** * @author fjzheng * @version 1.0 * @date 2021/9/1 10:38 */ @Slf4j @RestController @RequestMapping("/test") @ResponseResultBody public class TestController { @GetMapping("/limit2") @RateLimit(key = "limit2", permitsPerSecond = 1, timeout = 50, timeunit = TimeUnit.MILLISECONDS, msg = "當(dāng)前排隊人數(shù)較多,請稍后再試!") public String limit2() { log.info("令牌桶l(fā)imit2獲取令牌成功"); return "ok"; } }
其測試結(jié)果和上面的示例是差不多的。
guava的rateLimit限流只能使用與單機版,如果是分布式系統(tǒng),部署多個節(jié)點就不行了
4.分布式限流
分布式限流服務(wù)最關(guān)鍵是將限流服務(wù)做成原子化的,防止redis在分步執(zhí)行命令時部分成功執(zhí)行部分失敗問題導(dǎo)致邏輯錯誤。lua腳本可以保證操作的原子性
redis+lua實現(xiàn)
local key = "rate.limit:" .. KEYS[1] local limit = tonumber(ARGV[1]) local expire_time = ARGV[2] ? local is_exists = redis.call("EXISTS", key) if is_exists == 1 then if redis.call("INCR", key) > limit then return 0 else return 1 end else redis.call("SET", key, 1) redis.call("EXPIRE", key, expire_time) return 1 end
nginx+lua實現(xiàn)
local locks = require "resty.lock" ? local function acquire() local lock =locks:new("locks") local elapsed, err =lock:lock("limit_key") --互斥鎖 保證原子特性 local limit_counter =ngx.shared.limit_counter --計數(shù)器 ? local key = "ip:" ..os.time() local limit = 5 --限流大小 local current =limit_counter:get(key) ? if current ~= nil and current + 1> limit then --如果超出限流大小 lock:unlock() return 0 end if current == nil then limit_counter:set(key, 1, 1) --第一次需要設(shè)置過期時間,設(shè)置key的值為1, --過期時間為1秒 else limit_counter:incr(key, 1) --第二次開始加1即可 end lock:unlock() return 1 end ngx.print(acquire())
對于Nginx接入層限流可以使用Nginx自帶了兩個模塊:連接數(shù)限流模塊ngx_http_limit_conn_module和漏桶算法實現(xiàn)的請求限流模塊ngx_http_limit_req_module。
控制并發(fā)數(shù):ngx_http_limit_conn_module
http { include mime.types; default_type application/octet-stream; ? #cache lua_shared_dict dis_cache 128m; ? #限流設(shè)置 limit_req_zone $binary_remote_addr zone=contentRateLimit:10m rate=2r/s; ? #根據(jù)IP地址來限制,存儲內(nèi)存大小10M limit_conn_zone $binary_remote_addr zone=addr:1m; ? sendfile on; #tcp_nopush on; ? #keepalive_timeout 0; keepalive_timeout 65; ? #gzip on; ? server { listen 80; server_name localhost; location /brand { limit_conn addr 2; proxy_pass http://192.168.211.1:18081; } ? location /update_content { content_by_lua_file /root/lua/update_content.lua; } ? location /read_content { limit_req zone=contentRateLimit burst=4 nodelay; content_by_lua_file /root/lua/read_content.lua; } } }
- limit_conn_zone $binary_remote_addr zone=addr:10m; 表示限制根據(jù)用戶的IP地址來顯示,設(shè)置存儲地址為的內(nèi)存大小10M
- limit_conn addr 2; 表示 同一個地址只允許連接2次。
控制速率:ngx_http_limit_req_module
user root root; worker_processes 1; ? events { worker_connections 1024; } ? http { include mime.types; default_type application/octet-stream; ? #cache lua_shared_dict dis_cache 128m; ? #限流設(shè)置 limit_req_zone $binary_remote_addr zone=contentRateLimit:10m rate=2r/s; ? sendfile on; #tcp_nopush on; ? #keepalive_timeout 0; keepalive_timeout 65; ? #gzip on; ? server { listen 80; server_name localhost; ? location /update_content { content_by_lua_file /root/lua/update_content.lua; } ? location /read_content { limit_req zone=contentRateLimit burst=4 nodelay; content_by_lua_file /root/lua/read_content.lua; } } }
burst 譯為突發(fā)、爆發(fā),表示在超過設(shè)定的處理速率后能額外處理的請求數(shù),當(dāng) rate=10r/s 時,將1s拆成10份,即每100ms可處理1個請求。
此處,burst=4 ,若同時有4個請求到達,Nginx 會處理第一個請求,剩余3個請求將放入隊列,然后每隔500ms從隊列中獲取一個請求進行處理。若請求數(shù)大于4,將拒絕處理多余的請求,直接返回503.
不過,單獨使用 burst 參數(shù)并不實用。假設(shè) burst=50 ,rate依然為10r/s,排隊中的50個請求雖然每100ms會處理一個,但第50個請求卻需要等待 50 * 100ms即 5s,這么長的處理時間自然難以接受。
因此,burst 往往結(jié)合 nodelay 一起使用。
到此這篇關(guān)于Spring Boot高可用限流三種實現(xiàn)解決方案的文章就介紹到這了,更多相關(guān)Spring Boot高可用限流內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Springboot AOP對指定敏感字段數(shù)據(jù)加密存儲的實現(xiàn)
本篇文章主要介紹了利用Springboot+AOP對指定的敏感數(shù)據(jù)進行加密存儲以及對數(shù)據(jù)中加密的數(shù)據(jù)的解密的方法,代碼詳細(xì),具有一定的價值,感興趣的小伙伴可以了解一下2021-11-11PowerJob的TimingStrategyHandler工作流程源碼解讀
這篇文章主要為大家介紹了PowerJob的TimingStrategyHandler工作流程源碼解讀,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2024-01-01Java線程狀態(tài)及切換、關(guān)閉線程的正確姿勢分享
這篇文章主要給大家介紹了關(guān)于Java線程狀態(tài)及切換、關(guān)閉線程的正確姿勢,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者使用Java具有一定的參考學(xué)習(xí)價值,需要的朋友們下面來一起學(xué)習(xí)學(xué)習(xí)吧2019-10-10Springboot項目啟動不加載resources目錄下的文件問題
這篇文章主要介紹了Springboot項目啟動不加載resources目錄下的文件問題,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2024-08-08