艾編教學筆記:高併發限流+分佈式限流高併發限流技術揭祕

內容概要:

1、爲什麼要限流

2、分佈式限流解決方案

3Guava實現令牌限流和漏桶限流

4SpringBoot結合Redis實現分佈式限流

5SpringCloud GateWay網關限流---微服務SprignCloud 6Nginx限流


 

爲什麼要限流

目標

學習在項目開發中爲什麼要使用限流技術,以及限流的作用。

概述

在分佈式領域,我們難免會遇到併發量突增,對後端服務造成高壓力,嚴重甚至會導致系統宕機。爲避  免這種問題,我們通常會爲接口添加限流、降級、熔斷等能力,從而使接口更爲健壯。Java領域常見的  開源組件有Netflix的hystrix,阿里系開源的sentinel等,都是蠻不錯的限流熔斷框架。

 

圖解

 

 

 

 

 

在開發高併發系統時有三把利器用來保護系統:緩存、降級和限流。緩存的目的是提升系統訪問速度和  增大系統能處理的容量,可謂是抗高併發流量的銀彈;而降級是當服務出問題或者影響到核心流程的性  能則需要暫時屏蔽掉,待高峯或者問題解決後再打開;而有些場景並不能用緩存和降級來解決,比如稀  缺資源(秒殺、搶購)、寫服務(如評論、下單)、頻繁的複雜查詢(評論的最後幾頁),因此需有一  種手段來限制這些場景的併發/請求量,即限流。

解決一個問題:保護、保證系統一定可用。

 

解決方案

擴容

增加物理服務的硬件和設備。

 

緩存

緩存比較好理解,在大型高併發系統中,如果沒有緩存數據庫將分分鐘被爆,系統也會瞬間癱瘓。使用  緩存不單單能夠提升系統訪問速度、提高併發訪問量,也是保護數據庫、保護系統的有效方式。大型網  站一般主要是“讀”,緩存的使用很容易被想到。在大型“寫”系統中,緩存也常常扮演者非常重要的角

色。比如累積一些數據批量寫入,內存裏面的緩存隊列(生產消費),以及HBase寫數據的機制等等也  都是通過緩存提升系統的吞吐量或者實現系統的保護措施。甚至消息中間件,你也可以認爲是一種分佈  式的數據緩存。

 

降級

服務降級是當服務器壓力劇增的情況下,根據當前業務情況及流量對一些服務和頁面有策略的降級,以  此釋放服務器資源以保證核心任務的正常運行。降級往往會指定不同的級別,面臨不同的異常等級執行  不同的處理。根據服務方式:可以拒接服務,可以延遲服務,也有時候可以隨機服務。根據服務範圍:  可以砍掉某個功能,也可以砍掉某些模塊。總之服務降級需要根據不同的業務需求採用不同的降級策     略。主要的目的就是服務雖然有損但是總比沒有好。

 

限流

限流可以認爲服務降級的一種,限流就是限制系統的輸入和輸出流量已達到保護系統的目的。一般來說  系統的吞吐量是可以被測算的,爲了保證系統的穩定運行,一旦達到的需要限制的閾值,就需要限制流  量並採取一些措施以完成限制流量的目的。比如:延遲處理,拒絕處理,或者部分拒絕處理等等。

 

限流的目的

限流的目的是通過對併發訪問/請求進行限速或者一個時間窗口內的的請求進行限速來保護系統,一旦   達到限制速率則可以拒絕服務(定向到錯誤頁或告知資源沒有了)、排隊或等待(比如秒殺、評論、下  單)、降級(返回兜底數據或默認數據,如商品詳情頁庫存默認有貨)。

一般開發高併發系統常見的限流有:限制總併發數(比如數據庫連接池、線程池)、限制瞬時併發數

(如nginx的limit_conn模塊,用來限制瞬時併發連接數)、限制時間窗口內的平均速率(如Guava的RateLimiter、nginx的limit_req模塊,限制每秒的平均速率);其他還有如限制遠程接口調用速率、限  制MQ的消費速率。另外還可以根據網絡連接數、網絡流量、CPU或內存負載等來限流。

 

先有緩存這個銀彈,後有限流來應對618、雙十一高併發流量,在處理高併發問題上可以說是如虎添      翼,不用擔心瞬間流量導致系統掛掉或雪崩,最終做到有損服務而不是不服務;限流需要評估好,不可  亂用,否則會正常流量出現一些奇怪的問題而導致用戶抱怨。

 

保護、保證系統和網站的正常運行。

 

使用場景

秒殺、搶購,年底查詢密集,評論查詢。

 

SpringBoot結合Redis實現分佈式限流演進


 

 

 

目標

爲什麼使用分佈式限流解決方案,整個過程是如何來的。

 

 

 

 

 

步驟

1:搭建springboot框架

2:導入web依賴,和guava依賴,以及redis依賴

 
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>

<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

3:最簡單的限流講解---計數器限流

 

package com.itheima.limiting.web;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class OrderController {
// 搶購,搶購數量,也就限流的閾值
static long limit = 10;
// 計算器,代表請求的用戶數量
private long count = 0;
@GetMapping("/makeorder")
public String makeOrder(){
long c = ++count;
if(c > limit){
return "搶購結束,下次在來! count = " + c;
}
return "恭喜,搶購成功,count = " + c;
}
}

 

存在問題:

1:當前count沒有標記成爲static。需要標記成static嗎?答案是不需要,因爲 @RestController 標記當前類是單例的, 所以在內存中count只有一份,不會出現所謂的多份問題。而是共用的一個計數器。

2:往往在分佈式集羣的項目中,項目是部署多多臺,是多個  jvm。每個jvm都又自己的計數器,這個時候就會引發高併發帶來的線程安全問題。

3:那可以使用volatile 嗎?答案是不可以。因爲volatile只保證成員變量在線程見的可見性,它不保證線程安全。

4:如果線程不安全可以使用synchronized ,這種是沒問題的,可以解決線程安全的問題。但是同時帶

@GetMapping("/makeorder")
public synchronized String makeOrder(){
long c = ++count;
if(c > limit){
return "搶購結束,下次在來! count = " + c; 6    }
        return    "恭喜,搶購成功,count = " + c; 8    }

來的隱患就是:性能低下,這個是必然的,如果使用了synchronized關鍵字,就是上了鎖,代碼的執行 就編程了串行,一大推的阻塞,如果這個時候又1w的併發,這個時候處理都會造成大量的阻塞。而且    性能極低。在一般的開發中我們都會認爲和代碼是不適合的。也滿足不了我們高併發的需要。那麼進行  優化,如何解決呢?原子類。

5:如果在分佈式環境下呢?

 

 

可以使用分佈式緩存來解決這個問題:

爲什麼不用Locksynchronized,在單機環境下,是沒有問題,但是往往在開發中,大部分情況下是集  羣環境,這個適合每個電腦都是獨立JVM環境,你是沒有辦法去控制別人jvm內存中的東西,所以我們    要計數器進行共享。

 

 

 

爲什麼選擇Redis呢?而不是memcache操作呢?

答案很簡單:Redis單線程。(基於linuxio模型來架構的,EPOLL 異步IO BIO NIO EPOLL  jedis redission

 

 

 

 代碼如下:

 

引入依賴:

 

<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.1.0</version>
</dependency>
@GetMapping("makeorder2")
public String makeOrder2(String name){
// 從jedis上獲取自增值
try(Jedis jedis = new Jedis("localhost",6379)){
long c = jedis.incr(name);
if(c > limit){
return "搶購結束,下次在來! count = " + c; 8    }
        return    "恭喜,搶購成功,count = " + c; 10    }
11    }

測試:啓動80808081兩個端口:訪問

 

 

http://localhost:8081/makeorder2?name=lisi  http://localhost:8080/makeorder2?name=lisi  同一個用戶在集羣環境下的併發問是否共享計數器。答案很明顯是可以的。

 

 

 

 

限流接口的時間窗請求數--時間窗限流

 

 

 

概述

即一個時間窗口內的請求數,如需限制某個接口/服務每秒/每分鐘/每天的請求數/調用量。

 

 

 

 

 

 

如果實現呢?

Guava---單機系統

使用LoadingCache限流

 

package com.itheima.limiting.web;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import redis.clients.jedis.Jedis;
import java.util.Calendar;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
@RestController
public class OrderGuavaController {
// 搶購,搶購數量,也就限流的閾值
static long limit = 5;
// 限時間窗請求數,限制5r/s
LoadingCache<Long,AtomicLong> loadingCache =
CacheBuilder.newBuilder().expireAfterAccess(2, TimeUnit.SECONDS)
.build(new CacheLoader<Long, AtomicLong>() {
@Override
public AtomicLong load(Long aLong) throws Exception {
return new AtomicLong(0);
}
});
@GetMapping("/makeorder3")
public String makeOrder() throws Exception{
// 當前秒
long currentTime = System.currentTimeMillis()/1000L;
//long c = atomicLong.incrementAndGet();
long c = loadingCache.get(currentTime).incrementAndGet();
if(c > limit){
return "搶購結束,下次在來! count = " + c;
}
return "恭喜,搶購成功,count = " + c;
}
}

 

模擬代碼:
package com.itheima.limiting.limiter;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicLong;
public class MyLimiter {
private ConcurrentMap<Long, AtomicLong> map = new ConcurrentHashMap<>
();
private long before;
public MyLimiter(long before){
super();
this.before = before;
}
public AtomicLong get(long key){
if(!this.map.containsKey(key)){
synchronized (map){
if(!this.map.containsKey(key)){
map.put(key,new AtomicLong(0L));
// 移除指定秒數的計數器
this.removeBeforeKey(key);
}
}
}
return this.map.get(key);
}
private void removeBeforeKey(long ckey){
for(Long key : this.map.keySet()){
// 把哪些超過時間的key,從map中移除出去。
if(key + before < ckey){
this.map.remove(key);
}
}
}
}
分佈式限流--Lua腳本

目標
使用lua腳本完成分佈式限流
步驟 
package com.itheima.limiting.web;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.itheima.limiting.limiter.MyLimiter;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import redis.clients.jedis.Jedis;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
@RestController
public class OrderRedisController {
// 搶購,搶購數量,也就限流的閾值
static long limit = 5;
public String makeorder6(String name) throws Exception{
try (Jedis jedis = new Jedis("localhost",6379)){
long c = jedis.incr(name);
if(c > limit){
return (System.currentTimeMillis()/1000)+"搶購結束,下次在來!
count = " + c;
}else{
if(c==1){
// 設置過期時間
jedis.expire(name,1);
}
}
return (System.currentTimeMillis()/1000) + "恭喜,搶購成功,count
= " + c;
}
}
}
Lua腳本
 
local key = KEYS[1] --限流Key(一秒一個)
local limit = tonumber(ARGV[1]) --限流大小
local expire = ARGV[2] --過期時間
-- 獲取當前計數器的值
local current = tonumber(redis.call('get',key) or "0")
-- 如果超過限制大小
if current + 1 > limit then
return 0
Redis處理類
測試用例
else
current = tonumber(redis.call('INCRBY',key,"1")) --請求數+1
if current == 1 then --如果是第一次訪問需要設置過期時間
redis.call("expire",key,expire) --設置過期時間
end
end
return 1 --返回1代表不限流
Redis處理類
 
package com.itheima.limiting.limiter;
import com.google.common.io.Files;
import org.springframework.core.io.ClassPathResource;
import redis.clients.jedis.Jedis;
import java.nio.charset.Charset;
public class JedisLuaLimiter {
private String luascript;
private String key;
private String limit;
private String expire;
public JedisLuaLimiter(String key,String limit,String expire,String
scriptFile){
super();
this.key = key;
this.limit = limit;
this.expire = expire;
try{
this.luascript = Files.asCharSource(new
ClassPathResource(scriptFile).getFile(), Charset.defaultCharset()).read();
}catch(Exception ex){
ex.printStackTrace();
}
}
// 嘗試獲取
public boolean tryAcqure(){
Jedis jedis = new Jedis("localhost",6379);
return (Long)jedis.eval(this.luascript,1,key,limit,expire) == 1L;
}
}

測試用例

 

package com.itheima.limiting;
import com.itheima.limiting.web.OrderRedisController;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.TimeUnit;
@SpringBootTest
class LimitingApplicationTests {
@Autowired
private OrderRedisController orderRedisController;
@Test
void contextLoads() throws Exception {
// 倒計數鎖存器
CountDownLatch countDownLatch = new CountDownLatch(50);
// 循環屏障
CyclicBarrier cyclicBarrier = new CyclicBarrier(50);
for (int i = 0; i < 50; i++) {
new Thread(()->{
try{
cyclicBarrier.await();
}catch(Exception ex){
ex.printStackTrace();
}
try {
System.out.println(Thread.currentThread().getName() +
"===" + orderRedisController.makeorder6("aicode111"));
}catch (Exception ex){
ex.printStackTrace();
}
countDownLatch.countDown();
}).start();
}
try{
countDownLatch.await();
}catch(Exception ex){
ex.printStackTrace();
}
TimeUnit.SECONDS.sleep(1);
System.out.println(Thread.currentThread().getName() + "===" +
orderRedisController.makeorder6("aicode111"));
}
}

 

削峯填谷-平滑限流某個接口的請求數
 
 
當消費端請求驟增時,可以爲其配置排隊等待的流控規則,以穩定的速度逐步處理這些請求,起到
峯填谷的效果,從而避免流量驟增造成系統負載過高。
 
 
背景信息
 
 
在實際應用中,收到的請求是沒有規律的。例如:某應用的處理請求的能力是每秒 10 個。在某一秒,
突然到來了 30 個請求,而接下來兩秒,都沒有請求到達。在這種情況下,如果直接拒絕 20 個請求,
應用在接下來的兩秒就會空閒。所以,需要把驟增的請求平均到一段時間內,讓系統負載保持在請求處
理水位之內,同時儘可能地處理更多請求。 
 

 

上圖中,紅色的部分代表超出消息處理能力的部分。把紅色部分的消息平均到之後的空閒時間去處理,  這樣既可以保證系統負載處在一個穩定的水位,又可以儘可能地處理更多消息。通過配置流控規則,可  以達到消息勻速處理的效果。

功能原理

AHAS  流控降級的排隊等待功能,可以把驟增的大量請求勻速分配,以固定的間隔時間讓請求通過,起削峯填谷的效果,從而避免流量驟增造成系統負載過高的情況。堆積的請求將會被排隊處理,當請  求的預計排隊時間超過最大超時時長時,AHAS 將拒絕這部分超時的請求。

例如:配置勻速模式下請求 QPS  5,則每 200 ms 處理一條請求,多餘的處理任務將排隊;同時設置了超時時間爲 5s,則預計排隊時長超過 5s 的處理任務將被直接拒絕。具體操作步驟,參見新建流控規

示意圖如下:

 

 

 

 問題

突發請求,流量整形,整形爲勻速請求處理,(比如 5r/s 時間間隔200毫秒處理一個請求,平滑速率).

解決方案:令牌桶算法

 

 

package com.itheima.limiting.web;
import com.google.common.util.concurrent.RateLimiter;
import com.itheima.limiting.limiter.JedisLuaLimiter;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class OrderGuavaRateLimitController {
// 平滑限流請求
RateLimiter rateLimiter = RateLimiter.create(4);
//@GetMapping("/makeorder7")
public String makeorder7() throws Exception{
if(!rateLimiter.tryAcquire()){
return (System.currentTimeMillis()/1000)+"搶購結束,下次在來!
" ;
}
return (System.currentTimeMillis()/1000) + "恭喜,搶購成功";
}
}

 

 測試代碼

 

package com.itheima.limiting;
import com.itheima.limiting.web.OrderGuavaRateLimitController;
import com.itheima.limiting.web.OrderRedisController;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.TimeUnit;
@SpringBootTest
class LimitingApplicationTests {
@Autowired
private OrderRedisController orderRedisController;
@Autowired
private OrderGuavaRateLimitController
orderGuavaRateLimitController;
@Test
void contextLoads() throws Exception {
// 倒計數鎖存器
CountDownLatch countDownLatch = new CountDownLatch(50);
// 循環屏障
CyclicBarrier cyclicBarrier = new CyclicBarrier(50);
for (int i = 0; i < 50; i++) {
new Thread(()->{
try{
cyclicBarrier.await();
}catch(Exception ex){
ex.printStackTrace();
}
try {
//System.out.println(Thread.currentThread().getName() + "===" +
orderRedisController.makeorder6("aicode111"));
System.out.println(Thread.currentThread().getName()
+ "===" + orderGuavaRateLimitController.makeorder7());
}catch (Exception ex){
ex.printStackTrace();
}

自定義令牌桶算法
countDownLatch.countDown();
}).start();
}
try{
countDownLatch.await();
}catch(Exception ex){
ex.printStackTrace();
}
TimeUnit.SECONDS.sleep(1);
// System.out.println(Thread.currentThread().getName() + "===" +
orderRedisController.makeorder6("aicode111"));
System.out.println(Thread.currentThread().getName() + "===" +
orderGuavaRateLimitController.makeorder7());
}
}

 

 

 

 

自定義令牌桶算法 

 

package com.itheima.limiting.limiter;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.Semaphore;
public class MyRateLimiter implements AutoCloseable{
// 定義信號量 併發協同工具
private Semaphore semaphore;
// 限制數量
private int limit;
// 定時器
private Timer timer;
public MyRateLimiter(int limit){
super();
this.limit = limit;
this.semaphore = new Semaphore(limit);
this.timer = new Timer();
// 放入令牌的時間間隔
long period = 1000L/limit;
// 通過定時器。定時放入令牌
timer.scheduleAtFixedRate(new TimerTask() {
@Override
public void run() {
if(semaphore.availablePermits() < limit){
semaphore.release();
漏桶算法
SpringBoot 結合Aop完成限流策略
原理
首先解釋下爲何採用Redis作爲限流組件的核心。
通俗地講,假設一個用戶(用IP判斷)每秒訪問某服務接口的次數不能超過10次,那麼我們可以在
Redis中創建一個鍵,並設置鍵的過期時間爲60秒。
當一個用戶對此服務接口發起一次訪問就把鍵值加1,在單位時間(此處爲1s)內當鍵值增加到10的時
候,就禁止訪問服務接口。PS:在某種場景中添加訪問時間間隔還是很有必要的。我們本次不考慮間隔
時間,只關注單位時間內的訪問次數。
需求
}
}
},period,period);
}
public void acquire() throws InterruptedException{
this.semaphore.acquire();
}
public boolean tryAcquire(){
return this.semaphore.tryAcquire();
}
public int availablePermits(){
return this.semaphore.availablePermits();
}
@Override
public void close(){
System.out.println("自動來關閉了..............");
this.timer.cancel();
}
}

1 @GetMapping("/makeorder")


2 public synchronized String makeOrder(){


3 long c = ++count;


4 if(c > limit){


5 return "搶購結束,下次在來! count = " + c; 6}


7return"恭喜,搶購成功,count = " + c; 8}

 
漏桶算法
 

SpringBoot 結合Aop完成限流策略

 

 

原理

首先解釋下爲何採用Redis作爲限流組件的核心。

通俗地講,假設一個用戶(用IP判斷)每秒訪問某服務接口的次數不能超過10次,那麼我們可以在

Redis中創建一個鍵,並設置鍵的過期時間爲60秒。

當一個用戶對此服務接口發起一次訪問就把鍵值加1,在單位時間(此處爲1s)內當鍵值增加到10的時  候,就禁止訪問服務接口。PS:在某種場景中添加訪問時間間隔還是很有必要的。我們本次不考慮間隔  時間,只關注單位時間內的訪問次數。

需求


原理已經講過了,說下需求。

1. 基於Redisincr及過期機制開發

1. Redis整合

由於我們是基於Redis進行的限流操作,因此需要整合Redis的類庫,上面已經講到,我們是基於

Springboot進行的開發,因此這裏可以直接整合RedisTemplate

1.1 座標引入

 

這裏我們引入spring-boot-starter-redis的依賴。

正式開發

到這裏,我們正式開始手寫限流組件的進程。

1. 工程定義

項目基於maven構建,主要依賴Spring-boot-starter,我們主要在springboot上進行開發,因此自定義  的開發包可以直接依賴下面這個座標,方便進行包管理。版本號自行選擇穩定版。

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<version>1.4.2.RELEASE</version>
</dependency>

 2、Redis整合

由於我們是基於Redis進行的限流操作,因此需要整合Redis的類庫,上面已經講到,我們是基於

Springboot進行的開發,因此這裏可以直接整合RedisTemplate。

1.1 座標引入

這裏我們引入spring-boot-starter-redis的依賴。

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-redis</artifactId>
<version>1.4.2.RELEASE</version>
</dependency>
2.2 注入CacheManager及RedisTemplate
 
新建一個Redis的配置類,命名爲RedisCacheConfifig,使用javaconfifig形式注入CacheManager及
RedisTemplate。爲了操作方便,我們採用了Jackson進行序列化。代碼如下 
@Configuration
@EnableCaching
public class RedisCacheConfig {
private static final Logger LOGGER =
LoggerFactory.getLogger(RedisCacheConfig.class);
@Bean
public CacheManager cacheManager(RedisTemplate<?, ?> redisTemplate)
{
CacheManager cacheManager = new
RedisCacheManager(redisTemplate);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("Springboot Redis cacheManager 加載完成");
}
return cacheManager;
}
@Bean
public RedisTemplate<String, Object>
redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
//使用Jackson2JsonRedisSerializer來序列化和反序列化redis的value值
(默認使用JDK的序列化方式)
Jackson2JsonRedisSerializer serializer = new
Jackson2JsonRedisSerializer(Object.class);
ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(PropertyAccessor.ALL,
JsonAutoDetect.Visibility.ANY);
mapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
serializer.setObjectMapper(mapper);
template.setValueSerializer(serializer);
//使用StringRedisSerializer來序列化和反序列化redis的key值
template.setKeySerializer(new StringRedisSerializer());
template.afterPropertiesSet();
LOGGER.info("Springboot RedisTemplate 加載完成");
return template;
}
}
注意 要使用 @Confifiguration 標註此類爲一個配置類,當然你可以使用 @Component, 但是不推
薦,原因在於 @Component 註解雖然也可以當作配置類,但是並不會爲其生成CGLIB代理Class,而
使用@Confifiguration,CGLIB會爲其生成代理類,進行性能的提升。
 
2.3 調用方application.propertie需要增加Redis配置
 
我們的包開發完畢之後,調用方的application.properties需要進行相關配置如下: 
#單機模式redis
spring.redis.host=127.0.0.1
spring.redis.port=6379
spring.redis.pool.maxActive=8
spring.redis.pool.maxWait=-1
spring.redis.pool.maxIdle=8
spring.redis.pool.minIdle=0
spring.redis.timeout=10000
spring.redis.password=
如果有密碼的話,配置password即可。
這裏爲單機配置,如果需要支持哨兵集羣,則配置如下,Java代碼不需要改動,只需要變動配置即可。
注意 兩種配置不能共存! 
 
#哨兵集羣模式
# database name
spring.redis.database=0
# server password 密碼,如果沒有設置可不配
spring.redis.password=
# pool settings ...池配置
spring.redis.pool.max-idle=8
spring.redis.pool.min-idle=0
spring.redis.pool.max-active=8
spring.redis.pool.max-wait=-1
# name of Redis server 哨兵監聽的Redis server的名稱
spring.redis.sentinel.master=mymaster
# comma-separated list of host:port pairs 哨兵的配置列表
spring.redis.sentinel.nodes=127.0.0.1:26379,127.0.0.1:26479,127.0.0.1:26579
3. 定義註解
爲了調用方便,我們定義一個名爲RateLimiter 的註解,內容如下 
/**
* @author snowalker
* @version 1.0
* @date 2018/10/27 1:25
* @className RateLimiter
* @desc 限流注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimiter {
/**
* 限流key
* @return
*/
String key() default "rate:limiter";
/**
* 單位時間限制通過請求數
* @return
*/long limit() default 10;
/**
* 過期時間,單位秒
* @return
*/
long expire() default 1;
}
該註解明確只用於方法,主要有三個屬性。
1. key--表示限流模塊名,指定該值用於區分不同應用,不同場景,推薦格式爲:應用名:模塊名:ip:接
口名:方法名
2. limit--表示單位時間允許通過的請求數
3. expire--incr的值的過期時間,業務中表示限流的單位時間。
4. 解析註解
定義好註解後,需要開發註解使用的切面,這裏我們直接使用aspectj進行切面的開發。先看代碼 
@Aspect
@Component
public class RateLimterHandler {
private static final Logger LOGGER =
LoggerFactory.getLogger(RateLimterHandler.class);
@Autowired
RedisTemplate redisTemplate;
private DefaultRedisScript<Long> getRedisScript;
@PostConstruct
public void init() {
getRedisScript = new DefaultRedisScript<>();
getRedisScript.setResultType(Long.class);
getRedisScript.setScriptSource(new ResourceScriptSource(new
ClassPathResource("rateLimter.lua")));
LOGGER.info("RateLimterHandler[分佈式限流處理器]腳本加載完成");
}
這裏是注入了RedisTemplate,使用其API進行Lua腳本的調用。
init() 方法在應用啓動時會初始化DefaultRedisScript,並加載Lua腳本,方便進行調用。
PS: Lua腳本放置在classpath下,通過ClassPathResource進行加載。 
@Pointcut("@annotation(com.snowalker.shield.ratelimiter.core.annotation.RateL
imiter)")
public void rateLimiter() {}
這裏我們定義了一個切點,表示只要註解了 @RateLimiter 的方法,均可以觸發限流操作。 
1 @Around("@annotation(rateLimiter)")
這段代碼的邏輯爲,獲取 @RateLimiter 註解配置的屬性:key、limit、expire,並通過
redisTemplate.execute(RedisScript script, List keys, Object... args) 方法傳遞給Lua腳本進行限
流相關操作,邏輯很清晰。
這裏我們定義如果腳本返回狀態爲0則爲觸發限流,1表示正常請求。
5. Lua腳本
public Object around(ProceedingJoinPoint proceedingJoinPoint,
RateLimiter rateLimiter) throws Throwable {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("RateLimterHandler[分佈式限流處理器]開始執行限流操
作");
}
Signature signature = proceedingJoinPoint.getSignature();
if (!(signature instanceof MethodSignature)) {
throw new IllegalArgumentException("the Annotation
@RateLimter must used on method!");
}
/**
* 獲取註解參數
*/
// 限流模塊key
String limitKey = rateLimiter.key();
Preconditions.checkNotNull(limitKey);
// 限流閾值
long limitTimes = rateLimiter.limit();
// 限流超時時間
long expireTime = rateLimiter.expire();
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("RateLimterHandler[分佈式限流處理器]參數值爲-
limitTimes={},limitTimeout={}", limitTimes, expireTime);
}
/**
* 執行Lua腳本
*/
List<String> keyList = new ArrayList();
// 設置key值爲註解中的值
keyList.add(limitKey);
/**
* 調用腳本並執行
*/
Long result = (Long) redisTemplate.execute(getRedisScript,
keyList, expireTime, limitTimes);
if (result == 0) {
String msg = "由於超過單位時間=" + expireTime + "-允許的請求次數
=" + limitTimes + "[觸發限流]";
LOGGER.debug(msg);
return "false";
}
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("RateLimterHandler[分佈式限流處理器]限流執行結果-
result={},請求[正常]響應", result);
}
return proceedingJoinPoint.proceed();
}
}
這段代碼的邏輯爲,獲取 @RateLimiter 註解配置的屬性:key、limit、expire,並通過
redisTemplate.execute(RedisScript script, List keys, Object... args) 方法傳遞給Lua腳本進行限
流相關操作,邏輯很清晰。
這裏我們定義如果腳本返回狀態爲0則爲觸發限流,1表示正常請求。

5. Lua腳本 

這裏是我們整個限流操作的核心,通過執行一個Lua腳本進行限流的操作。腳本內容如下 
--獲取KEY
local key1 = KEYS[1]
local val = redis.call('incr', key1)
local ttl = redis.call('ttl', key1)
--獲取ARGV內的參數並打印
local expire = ARGV[1]
local times = ARGV[2]
redis.log(redis.LOG_DEBUG,tostring(times))
redis.log(redis.LOG_DEBUG,tostring(expire))
redis.log(redis.LOG_NOTICE, "incr "..key1.." "..val);
if val == 1 then
redis.call('expire', key1, tonumber(expire))
else
if ttl == -1 then
redis.call('expire', key1, tonumber(expire))
end
end
if val > tonumber(times) then
return 0
end
return 1
邏輯很通俗,我簡單介紹下。
1. 首先腳本獲取Java代碼中傳遞而來的要限流的模塊的key,不同的模塊key值一定不能相同,否則會
覆蓋!
2. redis.call('incr', key1)對傳入的key做incr操作,如果key首次生成,設置超時時間ARGV[1];(初
始值爲1)
3. ttl是爲防止某些key在未設置超時時間並長時間已經存在的情況下做的保護的判斷;
4. 每次請求都會做+1操作,當限流的值val大於我們註解的閾值,則返回0表示已經超過請求限制,
觸發限流。否則爲正常請求。
當過期後,又是新的一輪循環,整個過程是一個原子性的操作,能夠保證單位時間不會超過我們預設的
請求閾值。
到這裏我們便可以在項目中進行測試。

測試

demo地址
這裏我貼一下核心代碼,我們定義一個接口,並註解 @RateLimiter(key = "ratedemo:1.0.0", limit
= 5, expire = 100) 表示模塊ratedemo:sendPayment:1.0.0 在100s內允許通過5個請求,這裏的參數設
置是爲了方便看結果。實際中,我們通常會設置1s內允許通過的次數。 
 
@Controller
public class TestController {
private static final Logger LOGGER =
LoggerFactory.getLogger(TestController.class);
@ResponseBody
@RequestMapping("ratelimiter")
@RateLimiter(key = "ratedemo:1.0.0", limit = 5, expire = 100)
public String sendPayment(HttpServletRequest request) throws
Exception {
return "正常請求";
}
}
我們通過RestClient請求接口,日誌返回如下: 
 
2018-10-28 00:00:00.602 DEBUG 17364 --- [nio-8888-exec-1]
c.s.s.r.core.handler.RateLimterHandler :
RateLimterHandler[分佈式限流處理器]開始執行限流操作
2018-10-28 00:00:00.688 DEBUG 17364 --- [nio-8888-exec-1]
c.s.s.r.core.handler.RateLimterHandler :
RateLimterHandler[分佈式限流處理器]限流執行結果-result=1,請求[正常]響應
2018-10-28 00:00:00.860 DEBUG 17364 --- [nio-8888-exec-3]
c.s.s.r.core.handler.RateLimterHandler :
RateLimterHandler[分佈式限流處理器]開始執行限流操作
2018-10-28 00:00:01.183 DEBUG 17364 --- [nio-8888-exec-4]
c.s.s.r.core.handler.RateLimterHandler :
RateLimterHandler[分佈式限流處理器]開始執行限流操作
2018-10-28 00:00:01.520 DEBUG 17364 --- [nio-8888-exec-3]
c.s.s.r.core.handler.RateLimterHandler :
RateLimterHandler[分佈式限流處理器]限流執行結果-result=1,請求[正常]響應
2018-10-28 00:00:01.521 DEBUG 17364 --- [nio-8888-exec-4]
c.s.s.r.core.handler.RateLimterHandler :
RateLimterHandler[分佈式限流處理器]限流執行結果-result=1,請求[正常]響應
2018-10-28 00:00:01.557 DEBUG 17364 --- [nio-8888-exec-5]
c.s.s.r.core.handler.RateLimterHandler :
RateLimterHandler[分佈式限流處理器]開始執行限流操作
2018-10-28 00:00:01.558 DEBUG 17364 --- [nio-8888-exec-5]
c.s.s.r.core.handler.RateLimterHandler :
RateLimterHandler[分佈式限流處理器]限流執行結果-result=1,請求[正常]響應
2018-10-28 00:00:01.774 DEBUG 17364 --- [nio-8888-exec-7]
c.s.s.r.core.handler.RateLimterHandler :
RateLimterHandler[分佈式限流處理器]開始執行限流操作
2018-10-28 00:00:02.111 DEBUG 17364 --- [nio-8888-exec-8]
c.s.s.r.core.handler.RateLimterHandler :
RateLimterHandler[分佈式限流處理器]開始
2018-10-28 00:00:02.169 DEBUG 17364 --- [nio-8888-exec-7]
c.s.s.r.core.handler.RateLimterHandler :
RateLimterHandler[分佈式限流處理器]限流執行結果-result=1,請求[正常]響應
2018-10-28 00:00:02.169 DEBUG 17364 --- [nio-8888-exec-8]
c.s.s.r.core.handler.RateLimterHandler :
由於超過單位時間=100-允許的請求次數=5[觸發限流]

2018-10-28 00:00:02.276 DEBUG 17364 --- [io-8888-exec-10]
c.s.s.r.core.handler.RateLimterHandler :
RateLimterHandler[分佈式限流處理器]開始執行限流操作
2018-10-28 00:00:02.276 DEBUG 17364 --- [io-8888-exec-10]
c.s.s.r.core.handler.RateLimterHandler :
RateLimterHandler[分佈式限流處理器]參數值爲-limitTimes=5,limitTimeout=100
2018-10-28 00:00:02.278 DEBUG 17364 --- [io-8888-exec-10]
c.s.s.r.core.handler.RateLimterHandler :
由於超過單位時間=100-允許的請求次數=5[觸發限流]
2018-10-28 00:00:02.445 DEBUG 17364 --- [nio-8888-exec-2]
c.s.s.r.core.handler.RateLimterHandler :
RateLimterHandler[分佈式限流處理器]開始執行限流操作
2018-10-28 00:00:02.445 DEBUG 17364 --- [nio-8888-exec-2]
c.s.s.r.core.handler.RateLimterHandler :
RateLimterHandler[分佈式限流處理器]參數值爲-limitTimes=5,limitTimeout=100
2018-10-28 00:00:02.446 DEBUG 17364 --- [nio-8888-exec-2]
c.s.s.r.core.handler.RateLimterHandler :
由於超過單位時間=100-允許的請求次數=5[觸發限流]
2018-10-28 00:00:02.628 DEBUG 17364 --- [nio-8888-exec-4]
c.s.s.r.core.handler.RateLimterHandler :
RateLimterHandler[分佈式限流處理器]開始執行限流操作
2018-10-28 00:00:02.628 DEBUG 17364 --- [nio-8888-exec-4]
c.s.s.r.core.handler.RateLimterHandler :
RateLimterHandler[分佈式限流處理器]參數值爲-limitTimes=5,limitTimeout=100
2018-10-28 00:00:02.629 DEBUG 17364 --- [nio-8888-exec-4]
c.s.s.r.core.handler.RateLimterHandler :
由於超過單位時間=100-允許的請求次數=5[觸發限流]
根據日誌能夠看到,正常請求5次後,返回限流觸發,說明我們的邏輯生效,對前端而言也是可以看到
false標記,表明我們的Lua腳本限流邏輯是正確的,這裏具體返回什麼標記需要調用方進行明確的定
義。

總結

我們通過Redis的incr及expire功能特性,開發定義了一套基於註解的分佈式限流操作,核心邏輯基於
Lua保證了原子性。達到了很好的限流的目的,生產上,可以基於該特點進行定製自己的限流組件,當
然你可以參考本文的代碼,相信你寫的一定比我的demo更好!
在實際應用時也不要太糾結算法問題,因爲一些限流算法實現是一樣的只是描述不一樣;具體使用哪種
限流技術還是要根據實際場景來選擇,不要一味去找最佳模式,白貓黑貓能解決問題的就是好貓。
因在實際工作中遇到過許多人來問如何進行限流,因此本文會詳細介紹各種限流手段。那麼接下來我們
從限流算法、應用級限流、分佈式限流、接入層限流來詳細學習下限流技術手段。
限流就好比保險絲,根據你制定的標準,達到了就拉閘。

5、Hystrix或Gateway網關限流 

6、Nginx限流 

 

 

算法思想是:
令牌以固定速率產生,並緩存到令牌桶中;
令牌桶放滿時,多餘的令牌被丟棄;
請求要消耗等比例的令牌才能被處理;
令牌不夠時,請求被緩存。

漏桶算法

 

 

算法思想是:
水(請求)從上方倒入水桶,從水桶下方流出(被處理);
來不及流出的水存在水桶中(緩衝),以固定速率流出;
水桶滿後水溢出(丟棄)。
這個算法的核心是:緩存請求、勻速處理、多餘的請求直接丟棄。
相比漏桶算法,令牌桶算法不同之處在於它不但有一隻“桶”,還有個隊列,這個桶是用來存放令牌
的,隊列纔是用來存放請求的。
從作用上來說,漏桶和令牌桶算法最明顯的區別就是是否允許突發流量(burst)的處理,漏桶算法能夠強
行限制數據的實時傳輸(處理)速率,對突發流量不做額外處理;而令牌桶算法能夠在限制數據的平均
傳輸速率的同時允許某種程度的突發傳輸。
Nginx按請求速率限速模塊使用的是漏桶算法,即能夠強行保證請求的實時處理速度不會超過設置的閾
值。
Nginx官方版本限制IP的連接和併發分別有兩個模塊:
limit_req_zone 用來限制單位時間內的請求數,即速率限制,採用的漏桶算法 "leaky bucket"。
limit_req_conn 用來限制同一時間連接數,即併發限制。

limit_req_zone 參數配置

Syntax: limit_req zone=name [burst=number] [nodelay];
Default: —
Context: http, server, location
limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;

 

第一個參數:$binary_remote_addr 表示通過remote_addr這個標識來做限制,“binary_”的目的
是縮寫內存佔用量,是限制同一客戶端ip地址。
第二個參數:zone=one:10m表示生成一個大小爲10M,名字爲one的內存區域,用來存儲訪問的
頻次信息。
第三個參數:rate=1r/s表示允許相同標識的客戶端的訪問頻次,這裏限制的是每秒1次,還可以有
比如30r/m的。


 limit_req zone=one burst=5 nodelay;
第一個參數:zone=one 設置使用哪個配置區域來做限制,與上面limit_req_zone 裏的name對
應。
第二個參數:burst=5,重點說明一下這個配置,burst爆發的意思,這個配置的意思是設置一個
大小爲5的緩衝區當有大量請求(爆發)過來時,超過了訪問頻次限制的請求可以先放到這個緩衝
區內。
第三個參數:nodelay,如果設置,超過訪問頻次而且緩衝區也滿了的時候就會直接返回503,如
果沒有設置,則所有請求會等待排隊。

例子: 

http {
limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;
server {
location /search/ {
limit_req zone=one burst=5 nodelay;
}
}

 

下面配置可以限制特定UA(比如搜索引擎)的訪問: 
limit_req_zone $anti_spider zone=one:10m rate=10r/s;
limit_req zone=one burst=100 nodelay;
if ($http_user_agent ~* "googlebot|bingbot|Feedfetcher-Google") {
set $anti_spider $http_user_agent;
}

 
其他參數

Syntax: limit_req_log_level info | notice | warn | error; Default: limit_req_log_level error; Context: http, server, location
當服務器由於limit被限速或緩存時,配置寫入日誌。延遲的記錄比拒絕的記錄低一個級別。例子:
limit_req_log_level notice 延遲的的基本是info。 
 
Syntax: limit_req_status code;
Default:
limit_req_status 503;
Context: http, server, location
設置拒絕請求的返回值。值只能設置 400 到 599 之間。

ngx_http_limit_conn_module 參數配置

這個模塊用來限制單個IP的請求數。並非所有的連接都被計數。只有在服務器處理了請求並且已經讀取
了整個請求頭時,連接才被計數。 
 
Syntax: limit_conn zone number;
Default: —
Context: http, server, location
limit_conn_zone $binary_remote_addr zone=addr:10m;
server {
location /download/ {
limit_conn addr 1;
}
一次只允許每個IP地址一個連接。 
limit_conn_zone $binary_remote_addr zone=perip:10m;
limit_conn_zone $server_name zone=perserver:10m;
server {
...
limit_conn perip 10;
limit_conn perserver 100;
}
可以配置多個limit_conn指令。例如,以上配置將限制每個客戶端IP連接到服務器的數量,同時限制連
接到虛擬服務器的總數。 
Syntax: limit_conn_zone key zone=name:size;
Default: —
Context: http
limit_conn_zone $binary_remote_addr zone=addr:10m;
在這裏,客戶端IP地址作爲關鍵。請注意,不是 $ remote_addr ,而是使用 $ binary_remote_addr
變量。 $ remote_addr 變量的大小可以從7到15個字節不等。存儲的狀態在32位平臺上佔用32或64字
節的內存,在64位平臺上總是佔用64字節。對於IPv4地址, $ binary_remote_addr 變量的大小始終
爲4個字節,對於IPv6地址則爲16個字節。存儲狀態在32位平臺上始終佔用32或64個字節,在64位平臺
上佔用64個字節。一個兆字節的區域可以保持大約32000個32字節的狀態或大約16000個64字節的狀
態。如果區域存儲耗盡,服務器會將錯誤返回給所有其他請求。 
 
Syntax: limit_conn_log_level info | notice | warn | error;
Default:
limit_conn_log_level error;
Context: http, server, location
當服務器限制連接數時,設置所需的日誌記錄級別。
Syntax: limit_conn_status code;
Default:
limit_conn_status 503;
Context: http, server, location

  

設置拒絕請求的返回值。

 

實戰

實例一 限制訪問速率 
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s;
server {
location / {
limit_req zone=mylimit;
}
}
上述規則限制了每個IP訪問的速度爲2r/s,並將該規則作用於根目錄。如果單個IP在非常短的時間內並
發發送多個請求,結果會怎樣呢?

 

 

我們使用單個IP在10ms內發併發送了6個請求,只有1個成功,剩下的5個都被拒絕。我們設置的速度是
2r/s,爲什麼只有1個成功呢,是不是Nginx限制錯了?當然不是,是因爲Nginx的限流統計是基於毫秒
的,我們設置的速度是2r/s,轉換一下就是500ms內單個IP只允許通過1個請求,從501ms開始才允許
通過第二個請求。

實例二 burst緩存處理

我們看到,我們短時間內發送了大量請求,Nginx按照毫秒級精度統計,超出限制的請求直接拒絕。這
在實際場景中未免過於苛刻,真實網絡環境中請求到來不是勻速的,很可能有請求“突發”的情況,也就
是“一股子一股子”的。Nginx考慮到了這種情況,可以通過burst關鍵字開啓對突發請求的緩存處理,而
不是直接拒絕。
來看我們的配置: 
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s;
server {
location / {
limit_req zone=mylimit burst=4;
}
}
我們加入了burst=4,意思是每個key(此處是每個IP)最多允許4個突發請求的到來。如果單個IP在10ms
內發送6個請求,結果會怎樣呢?
相比實例一成功數增加了4個,這個我們設置的burst數目是一致的。具體處理流程是:1個請求被立即
處理,4個請求被放到burst隊列裏,另外一個請求被拒絕。通過burst參數,我們使得Nginx限流具備了
緩存處理突發流量的能力。
但是請注意:burst的作用是讓多餘的請求可以先放到隊列裏,慢慢處理。如果不加nodelay參數,隊列
裏的請求不會立即處理,而是按照rate設置的速度,以毫秒級精確的速度慢慢處理。

實例三 nodelay降低排隊時間

實例二中我們看到,通過設置burst參數,我們可以允許Nginx緩存處理一定程度的突發,多餘的請求可
以先放到隊列裏,慢慢處理,這起到了平滑流量的作用。但是如果隊列設置的比較大,請求排隊的時間
就會比較長,用戶角度看來就是RT變長了,這對用戶很不友好。有什麼解決辦法呢?nodelay參數允許
請求在排隊的時候就立即被處理,也就是說只要請求能夠進入burst隊列,就會立即被後臺worker處
理,請注意,這意味着burst設置了nodelay時,系統瞬間的QPS可能會超過rate設置的閾值。nodelay
參數要跟burst一起使用纔有作用。
 
延續實例二的配置,我們加入nodelay選項: 
 
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s;
server {
location / {
limit_req zone=mylimit;
}
}

limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s;
server {
location / {
limit_req zone=mylimit burst=4;
}
}
單個IP 10ms內併發發送6個請求,結果如下: 

 

 

跟實例二相比,請求成功率沒變化,但是總體耗時變短了。這怎麼解釋呢?實例二中,有4個請求被放
到burst隊列當中,工作進程每隔500ms(rate=2r/s)取一個請求進行處理,最後一個請求要排隊2s纔會
被處理;實例三中,請求放入隊列跟實例二是一樣的,但不同的是,隊列中的請求同時具有了被處理的
資格,所以實例三中的5個請求可以說是同時開始被處理的,花費時間自然變短了。
但是請注意,雖然設置burst和nodelay能夠降低突發請求的處理時間,但是長期來看並不會提高吞吐量
的上限,長期吞吐量的上限是由rate決定的,因爲nodelay只能保證burst的請求被立即處理,但Nginx
會限制隊列元素釋放的速度,就像是限制了令牌桶中令牌產生的速度。
看到這裏你可能會問,加入了nodelay參數之後的限速算法,到底算是哪一個“桶”,是漏桶算法還是令
牌桶算法?當然還算是漏桶算法。考慮一種情況,令牌桶算法的token爲耗盡時會怎麼做呢?由於它有
一個請求隊列,所以會把接下來的請求緩存下來,緩存多少受限於隊列大小。但此時緩存這些請求還有
意義嗎?如果server已經過載,緩存隊列越來越長,RT越來越高,即使過了很久請求被處理了,對用戶
來說也沒什麼價值了。所以當token不夠用時,最明智的做法就是直接拒絕用戶的請求,這就成了漏桶
算法。
 
示例四 自定義返回值 
 
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s;
server {
location / {
limit_req zone=mylimit burst=4 nodelay;
limit_req_status 598;
}
}

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章