高併發系統限流-漏桶算法和令牌桶算法html
參考:java
http://www.cnblogs.com/LBSer/p/4083131.htmlnode
https://blog.csdn.net/scorpio3k/article/details/53103239linux
https://www.cnblogs.com/clds/p/5850070.htmlnginx
http://jinnianshilongnian.iteye.com/blog/2305117git
http://iamzhongyong.iteye.com/blog/1742829github
某天A君忽然發現本身的接口請求量忽然漲到以前的10倍,沒多久該接口幾乎不可以使用,並引起連鎖反應致使整個系統崩潰。如何應對這種狀況呢?生活給了咱們答案:好比老式電閘都安裝了保險絲,一旦有人使用超大功率的設備,保險絲就會燒斷以保護各個電器不被強電流給燒壞。同理咱們的接口也須要安裝上「保險絲」,以防止非預期的請求對系統壓力過大而引發的系統癱瘓,當流量過大時,能夠採起拒絕或者引流等機制。 web
經常使用的限流算法有兩種:漏桶算法和令牌桶算法。redis
漏桶算法思路很簡單,水(請求)先進入到漏桶裏,漏桶以必定的速度出水,當水流入速度過大會直接溢出,能夠看出漏桶算法能強行限制數據的傳輸速率。算法
圖1 漏桶算法示意圖
對於不少應用場景來講,除了要求可以限制數據的平均傳輸速率外,還要求容許某種程度的突發傳輸。這時候漏桶算法可能就不合適了,令牌桶算法更爲適合。如圖2所示,令牌桶算法的原理是系統會以一個恆定的速度往桶裏放入令牌,而若是請求須要被處理,則須要先從桶裏獲取一個令牌,當桶裏沒有令牌可取時,則拒絕服務。
圖2 令牌桶算法示意圖
Google開源工具包Guava提供了限流工具類RateLimiter,該類基於令牌桶算法來完成限流,很是易於使用。RateLimiter類的接口描述請參考:RateLimiter接口描述,具體使用請參考:RateLimiter使用實踐。
下面是主要源碼:
public double acquire() { return acquire(1); } public double acquire(int permits) { checkPermits(permits); //檢查參數是否合法(是否大於0) long microsToWait; synchronized (mutex) { //應對併發狀況須要同步 microsToWait = reserveNextTicket(permits, readSafeMicros()); //得到須要等待的時間 } ticker.sleepMicrosUninterruptibly(microsToWait); //等待,當未達到限制時,microsToWait爲0 return 1.0 * microsToWait / TimeUnit.SECONDS.toMicros(1L); } private long reserveNextTicket(double requiredPermits, long nowMicros) { resync(nowMicros); //補充令牌 long microsToNextFreeTicket = nextFreeTicketMicros - nowMicros; double storedPermitsToSpend = Math.min(requiredPermits, this.storedPermits); //獲取此次請求消耗的令牌數目 double freshPermits = requiredPermits - storedPermitsToSpend; long waitMicros = storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend) + (long) (freshPermits * stableIntervalMicros); this.nextFreeTicketMicros = nextFreeTicketMicros + waitMicros; this.storedPermits -= storedPermitsToSpend; // 減去消耗的令牌 return microsToNextFreeTicket; } private void resync(long nowMicros) { // if nextFreeTicket is in the past, resync to now if (nowMicros > nextFreeTicketMicros) { storedPermits = Math.min(maxPermits, storedPermits + (nowMicros - nextFreeTicketMicros) / stableIntervalMicros); nextFreeTicketMicros = nowMicros; } }
一、最近在寫一個分佈式服務的框架,對於分佈式服務的框架來講,除了遠程調用,還要進行服務的治理
當進行促銷的時候,全部的資源都用來完成重要的業務,就好比雙11的時候,主要的業務就是讓用戶查詢商品,以及購買支付,
此時,金幣查詢、積分查詢等業務就是次要的,所以要對這些服務進行服務的降級,典型的服務降級算法是採用令牌桶算法,
所以在寫框架的時候去研究了一下令牌桶算法
二、在實施QOS策略時,能夠將用戶的數據限制在特定的帶寬,當用戶的流量超過額定帶寬時,超過的帶寬將採起其它方式來處理。
要衡量流量是否超過額定的帶寬,網絡設備並非採用單純的數字加減法來決定的,也就是說,好比帶寬爲100K,而用戶發來
的流量爲110K,網絡設備並非靠110K減去100K等於10K,就認爲用戶超過流量10K。網絡設備衡量流量是否超過額定帶寬,
須要使用令牌桶算法來計算。下面詳細介紹令牌桶算法機制:
當網絡設備衡量流量是否超過額定帶寬時,須要查看令牌桶,而令牌桶中會放置必定數量的令牌,一個令牌容許接口發送
或接收1bit數據(有時是1 Byte數據),當接口經過1bit數據後,同時也要從桶中移除一個令牌。當桶裏沒有令牌的時候,任何流
量都被視爲超過額定帶寬,只有當桶中有令牌時,數據才能夠經過接口。令牌桶中的令牌不只僅能夠被移除,一樣也能夠往裏添加,
因此爲了保證接口隨時有數據經過,就必須不停地往桶裏加令牌,因而可知,往桶裏加令牌的速度,就決定了數據經過接口的速度。
所以,咱們經過控制往令牌桶裏加令牌的速度從而控制用戶流量的帶寬。而設置的這個用戶傳輸數據的速率被稱爲承諾信息速率(CIR),
一般以秒爲單位。好比咱們設置用戶的帶寬爲1000 bit每秒,只要保證每秒鐘往桶裏添加1000個令牌便可。
三、舉例:
將CIR設置爲8000 bit/s,那麼就必須每秒將8000個令牌放入桶中,當接口有數據經過時,就從桶中移除相應的令牌,每經過1 bit,
就從桶中移除1個令牌。當桶裏沒有令牌的時候,任何流量都被視爲超出額定帶寬,而超出的流量就要採起額外動做。每秒鐘往桶裏加的令牌
就決定了用戶流量的速率,這個速率就是CIR,可是每秒鐘須要往桶裏加的令牌總數,並非一次性加完的,一次性加進的令牌數量被稱爲Burst size(Bc),
若是Bc只是CIR的一半,那麼很明顯每秒鐘就須要往桶裏加兩次令牌,每次加的數量老是Bc的數量。還有就是加令牌的時間,Time interval(Tc),
Tc表示多久該往桶裏加一次令牌,而這個時間並不能手工設置,由於這個時間能夠靠CIR和Bc的關係計算獲得, Bc/ CIR= Tc。
四、令牌桶算法圖例
a. 按特定的速率向令牌桶投放令牌
b. 根據預設的匹配規則先對報文進行分類,不符合匹配規則的報文不須要通過令牌桶的處理,直接發送;
c. 符合匹配規則的報文,則須要令牌桶進行處理。當桶中有足夠的令牌則報文能夠被繼續發送下去,同時令牌桶中的令牌 量按報文的長度作相應的減小;
d. 當令牌桶中的令牌不足時,報文將不能被髮送,只有等到桶中生成了新的令牌,報文才能夠發送。這就能夠限制報文的流量只能是小於等於令牌生成的速度,達到限制流量的目的。
五、Java參考代碼:
package com.netease.datastream.util.flowcontrol; import java.io.BufferedWriter; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStreamWriter; import java.util.Random; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; /** * <pre> * Created by inter12 on 15-3-18. * </pre> */ public class TokenBucket { // 默認桶大小個數 即最大瞬間流量是64M private static final int DEFAULT_BUCKET_SIZE = 1024 * 1024 * 64; // 一個桶的單位是1字節 private int everyTokenSize = 1; // 瞬間最大流量 private int maxFlowRate; // 平均流量 private int avgFlowRate; // 隊列來緩存桶數量:最大的流量峯值就是 = everyTokenSize*DEFAULT_BUCKET_SIZE 64M = 1 * 1024 * // 1024 * 64 private ArrayBlockingQueue<Byte> tokenQueue = new ArrayBlockingQueue<Byte>( DEFAULT_BUCKET_SIZE); private ScheduledExecutorService scheduledExecutorService = Executors .newSingleThreadScheduledExecutor(); private volatile boolean isStart = false; private ReentrantLock lock = new ReentrantLock(true); private static final byte A_CHAR = 'a'; public TokenBucket() { } public TokenBucket(int maxFlowRate, int avgFlowRate) { this.maxFlowRate = maxFlowRate; this.avgFlowRate = avgFlowRate; } public TokenBucket(int everyTokenSize, int maxFlowRate, int avgFlowRate) { this.everyTokenSize = everyTokenSize; this.maxFlowRate = maxFlowRate; this.avgFlowRate = avgFlowRate; } public void addTokens(Integer tokenNum) { // 如果桶已經滿了,就再也不家如新的令牌 for (int i = 0; i < tokenNum; i++) { tokenQueue.offer(Byte.valueOf(A_CHAR)); } } public TokenBucket build() { start(); return this; } /** * 獲取足夠的令牌個數 * * @return */ public boolean getTokens(byte[] dataSize) { // Preconditions.checkNotNull(dataSize); // Preconditions.checkArgument(isStart, // "please invoke start method first !"); int needTokenNum = dataSize.length / everyTokenSize + 1;// 傳輸內容大小對應的桶個數 final ReentrantLock lock = this.lock; lock.lock(); try { boolean result = needTokenNum <= tokenQueue.size(); // 是否存在足夠的桶數量 if (!result) { return false; } int tokenCount = 0; for (int i = 0; i < needTokenNum; i++) { Byte poll = tokenQueue.poll(); if (poll != null) { tokenCount++; } } return tokenCount == needTokenNum; } finally { lock.unlock(); } } public void start() { // 初始化桶隊列大小 if (maxFlowRate != 0) { tokenQueue = new ArrayBlockingQueue<Byte>(maxFlowRate); } // 初始化令牌生產者 TokenProducer tokenProducer = new TokenProducer(avgFlowRate, this); scheduledExecutorService.scheduleAtFixedRate(tokenProducer, 0, 1, TimeUnit.SECONDS); isStart = true; } public void stop() { isStart = false; scheduledExecutorService.shutdown(); } public boolean isStarted() { return isStart; } class TokenProducer implements Runnable { private int avgFlowRate; private TokenBucket tokenBucket; public TokenProducer(int avgFlowRate, TokenBucket tokenBucket) { this.avgFlowRate = avgFlowRate; this.tokenBucket = tokenBucket; } @Override public void run() { tokenBucket.addTokens(avgFlowRate); } } public static TokenBucket newBuilder() { return new TokenBucket(); } public TokenBucket everyTokenSize(int everyTokenSize) { this.everyTokenSize = everyTokenSize; return this; } public TokenBucket maxFlowRate(int maxFlowRate) { this.maxFlowRate = maxFlowRate; return this; } public TokenBucket avgFlowRate(int avgFlowRate) { this.avgFlowRate = avgFlowRate; return this; } private String stringCopy(String data, int copyNum) { StringBuilder sbuilder = new StringBuilder(data.length() * copyNum); for (int i = 0; i < copyNum; i++) { sbuilder.append(data); } return sbuilder.toString(); } public static void main(String[] args) throws IOException, InterruptedException { tokenTest(); } private static void arrayTest() { ArrayBlockingQueue<Integer> tokenQueue = new ArrayBlockingQueue<Integer>( 10); tokenQueue.offer(1); tokenQueue.offer(1); tokenQueue.offer(1); System.out.println(tokenQueue.size()); System.out.println(tokenQueue.remainingCapacity()); } private static void tokenTest() throws InterruptedException, IOException { TokenBucket tokenBucket = TokenBucket.newBuilder().avgFlowRate(512) .maxFlowRate(1024).build(); BufferedWriter bufferedWriter = new BufferedWriter( new OutputStreamWriter(new FileOutputStream("D:/ds_test"))); String data = "xxxx";// 四個字節 for (int i = 1; i <= 1000; i++) { Random random = new Random(); int i1 = random.nextInt(100); boolean tokens = tokenBucket.getTokens(tokenBucket.stringCopy(data, i1).getBytes()); TimeUnit.MILLISECONDS.sleep(100); if (tokens) { bufferedWriter.write("token pass --- index:" + i1); System.out.println("token pass --- index:" + i1); } else { bufferedWriter.write("token rejuect --- index" + i1); System.out.println("token rejuect --- index" + i1); } bufferedWriter.newLine(); bufferedWriter.flush(); } bufferedWriter.close(); } }
在大數據量高併發訪問時,常常會出現服務或接口面對暴漲的請求而不可用的狀況,甚至引起連鎖反映致使整個系統崩潰。此時你須要使用的技術手段之一就是限流,當請求達到必定的併發數或速率,就進行等待、排隊、降級、拒絕服務等。在限流時,常見的兩種算法是漏桶和令牌桶算法算法,本文即對相關內容進行重點介紹。
1、漏桶和令牌桶算法的概念
漏桶算法(Leaky Bucket):主要目的是控制數據注入到網絡的速率,平滑網絡上的突發流量。漏桶算法提供了一種機制,經過它,突發流量能夠被整形以便爲網絡提供一個穩定的流量。漏桶算法的示意圖以下:
請求先進入到漏桶裏,漏桶以必定的速度出水,當水請求過大會直接溢出,能夠看出漏桶算法能強行限制數據的傳輸速率。
令牌桶算法(Token Bucket):是網絡流量整形(Traffic Shaping)和速率限制(Rate Limiting)中最常使用的一種算法。典型狀況下,令牌桶算法用來控制發送到網絡上的數據的數目,並容許突發數據的發送。令牌桶算法示意圖以下所示:
大小固定的令牌桶可自行以恆定的速率源源不斷地產生令牌。若是令牌不被消耗,或者被消耗的速度小於產生的速度,令牌就會不斷地增多,直到把桶填滿。後面再產生的令牌就會從桶中溢出。最後桶中能夠保存的最大令牌數永遠不會超過桶的大小。
2、兩種算法的區別
二者主要區別在於「漏桶算法」可以強行限制數據的傳輸速率,而「令牌桶算法」在可以限制數據的平均傳輸速率外,還容許某種程度的突發傳輸。在「令牌桶算法」中,只要令牌桶中存在令牌,那麼就容許突發地傳輸數據直到達到用戶配置的門限,因此它適合於具備突發特性的流量。
3、使用Guava的RateLimiter進行限流控制
Guava是google提供的java擴展類庫,其中的限流工具類RateLimiter採用的就是令牌桶算法。RateLimiter 從概念上來說,速率限制器會在可配置的速率下分配許可證,若是必要的話,每一個acquire() 會阻塞當前線程直到許可證可用後獲取該許可證,一旦獲取到許可證,不須要再釋放許可證。通俗的講RateLimiter會按照必定的頻率往桶裏扔令牌,線程拿到令牌才能執行,好比你但願本身的應用程序QPS不要超過1000,那麼RateLimiter設置1000的速率後,就會每秒往桶裏扔1000個令牌。例如咱們須要處理一個任務列表,但咱們不但願每秒的任務提交超過兩個,此時能夠採用以下方式:
有一點很重要,那就是請求的許可數歷來不會影響到請求自己的限制(調用acquire(1) 和調用acquire(1000) 將獲得相同的限制效果,若是存在這樣的調用的話),但會影響下一次請求的限制,也就是說,若是一個高開銷的任務抵達一個空閒的RateLimiter,它會被立刻許可,可是下一個請求會經歷額外的限制,從而來償付高開銷任務。注意:RateLimiter 並不提供公平性的保證。
4、使用Semphore進行併發流控
Java 併發庫的Semaphore 能夠很輕鬆完成信號量控制,Semaphore能夠控制某個資源可被同時訪問的個數,經過 acquire() 獲取一個許可,若是沒有就等待,而 release() 釋放一個許可。單個信號量的Semaphore對象能夠實現互斥鎖的功能,而且能夠是由一個線程得到了「鎖」,再由另外一個線程釋放「鎖」,這可應用於死鎖恢復的一些場合。下面的Demo中申明瞭一個只有5個許可的Semaphore,而有20個線程要訪問這個資源,經過acquire()和release()獲取和釋放訪問許可:
最後:進行限流控制還能夠有不少種方法,針對不一樣的場景各有優劣,例如經過AtomicLong計數器控制、使用MQ消息隊列進行流量消峯等等。
曾經在一個大神的博客裏看到這樣一句話:在開發高併發系統時,有三把利器用來保護系統:緩存、降級和限流。那麼何爲限流呢?顧名思義,限流就是限制流量,就像你寬帶包了1個G的流量,用完了就沒了。經過限流,咱們能夠很好地控制系統的qps,從而達到保護系統的目的。本篇文章將會介紹一下經常使用的限流算法以及他們各自的特色。
計 數器法是限流算法裏最簡單也是最容易實現的一種算法。好比咱們規定,對於A接口來講,咱們1分鐘的訪問次數不能超過100個。那麼咱們能夠這麼作:在一開 始的時候,咱們能夠設置一個計數器counter,每當一個請求過來的時候,counter就加1,若是counter的值大於100而且該請求與第一個 請求的間隔時間還在1分鐘以內,那麼說明請求數過多;若是該請求與第一個請求的間隔時間大於1分鐘,且counter的值還在限流範圍內,那麼就重置 counter,具體算法的示意圖以下:
具體的僞代碼以下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
public class CounterDemo {
public long timeStamp = getNowTime();
public int reqCount = 0;
public final int limit = 100; // 時間窗口內最大請求數
public final long interval = 1000; // 時間窗口ms
public boolean grant() {
long now = getNowTime();
if (now < timeStamp + interval) {
// 在時間窗口內
reqCount++;
// 判斷當前時間窗口內是否超過最大請求控制數
return reqCount <= limit;
}
else {
timeStamp = now;
// 超時後重置
reqCount =
1;
return true;
}
}
}
|
這個算法雖然簡單,可是有一個十分致命的問題,那就是臨界問題,咱們看下圖:
從上圖中咱們能夠看到,假設有一個惡意用戶,他在0:59時,瞬間發送了100個請求,而且1:00又瞬間發送了100個請求,那麼其實這個用戶在 1秒裏面,瞬間發送了200個請求。咱們剛纔規定的是1分鐘最多100個請求,也就是每秒鐘最多1.7個請求,用戶經過在時間窗口的重置節點處突發請求, 能夠瞬間超過咱們的速率限制。用戶有可能經過算法的這個漏洞,瞬間壓垮咱們的應用。
聰明的朋友可能已經看出來了,剛纔的問題實際上是由於咱們統計的精度過低。那麼如何很好地處理這個問題呢?或者說,如何將臨界問題的影響下降呢?咱們能夠看下面的滑動窗口算法。
滑動窗口,又稱rolling window。爲了解決這個問題,咱們引入了滑動窗口算法。若是學過TCP網絡協議的話,那麼必定對滑動窗口這個名詞不會陌生。下面這張圖,很好地解釋了滑動窗口算法:
在上圖中,整個紅色的矩形框表示一個時間窗口,在咱們的例子中,一個時間窗口就是一分鐘。而後咱們將時間窗口進行劃分,好比圖中,咱們就將滑動窗口 劃成了6格,因此每格表明的是10秒鐘。每過10秒鐘,咱們的時間窗口就會往右滑動一格。每個格子都有本身獨立的計數器counter,好比當一個請求 在0:35秒的時候到達,那麼0:30~0:39對應的counter就會加1。
那麼滑動窗口怎麼解決剛纔的臨界問題的呢?咱們能夠看上圖,0:59到達的100個請求會落在灰色的格子中,而1:00到達的請求會落在橘黃色的格 子中。當時間到達1:00時,咱們的窗口會往右移動一格,那麼此時時間窗口內的總請求數量一共是200個,超過了限定的100個,因此此時可以檢測出來觸 發了限流。
我再來回顧一下剛纔的計數器算法,咱們能夠發現,計數器算法其實就是滑動窗口算法。只是它沒有對時間窗口作進一步地劃分,因此只有1格。
因而可知,當滑動窗口的格子劃分的越多,那麼滑動窗口的滾動就越平滑,限流的統計就會越精確。
漏桶算法,又稱leaky bucket。爲了理解漏桶算法,咱們看一下維基百科上的對於該算法的示意圖:
從圖中咱們能夠看到,整個算法其實十分簡單。首先,咱們有一個固定容量的桶,有水流進來,也有水流出去。對於流進來的水來講,咱們沒法預計一共有多 少水會流進來,也沒法預計水流的速度。可是對於流出去的水來講,這個桶能夠固定水流出的速率。並且,當桶滿了以後,多餘的水將會溢出。
咱們將算法中的水換成實際應用中的請求,咱們能夠看到漏桶算法天生就限制了請求的速度。當使用了漏桶算法,咱們能夠保證接口會以一個常速速率來處理請求。因此漏桶算法天生不會出現臨界問題。具體的僞代碼實現以下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
public class LeakyDemo {
public long timeStamp = getNowTime();
public int capacity; // 桶的容量
public int rate; // 水漏出的速度
public int water; // 當前水量(當前累積請求數)
public boolean grant() {
long now = getNowTime();
water = max(
0, water - (now - timeStamp) * rate); // 先執行漏水,計算剩餘水量
timeStamp = now;
if ((water + 1) < capacity) {
// 嘗試加水,而且水還未滿
water +=
1;
return true;
}
else {
// 水滿,拒絕加水
return false;
}
}
}
|
令牌桶算法,又稱token bucket。爲了理解該算法,咱們再來看一下維基百科上對該算法的示意圖:
從圖中咱們能夠看到,令牌桶算法比漏桶算法稍顯複雜。首先,咱們有一個固定容量的桶,桶裏存放着令牌(token)。桶一開始是空的,token以 一個固定的速率r往桶裏填充,直到達到桶的容量,多餘的令牌將會被丟棄。每當一個請求過來時,就會嘗試從桶裏移除一個令牌,若是沒有令牌的話,請求沒法通 過。
具體的僞代碼實現以下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
public class TokenBucketDemo {
public long timeStamp = getNowTime();
public int capacity; // 桶的容量
public int rate; // 令牌放入速度
public int tokens; // 當前令牌數量
public boolean grant() {
long now = getNowTime();
// 先添加令牌
tokens = min(capacity, tokens + (now - timeStamp) * rate);
timeStamp = now;
if (tokens < 1) {
// 若不到1個令牌,則拒絕
return false;
}
else {
// 還有令牌,領取令牌
tokens -=
1;
return true;
}
}
}
|
若仔細研究算法,咱們會發現咱們默認從桶裏移除令牌是不須要耗費時間的。若是給移除令牌設置一個延時時間,那麼實際上又採用了漏桶算法的思路。Google的guava庫下的SmoothWarmingUp
類就採用了這個思路。
我 們再來考慮一下臨界問題的場景。在0:59秒的時候,因爲桶內積滿了100個token,因此這100個請求能夠瞬間經過。可是因爲token是以較低的 速率填充的,因此在1:00的時候,桶內的token數量不可能達到100個,那麼此時不可能再有100個請求經過。因此令牌桶算法能夠很好地解決臨界問 題。下圖比較了計數器(左)和令牌桶算法(右)在臨界點的速率變化。咱們能夠看到雖然令牌桶算法容許突發速率,可是下一個突發速率必需要等桶內有足夠的 token後才能發生:
計數器算法是最簡單的算法,能夠當作是滑動窗口的低精度實現。滑動窗口因爲須要存儲多份的計數器(每個格子存一份),因此滑動窗口在實現上須要更多的存儲空間。也就是說,若是滑動窗口的精度越高,須要的存儲空間就越大。
漏桶算法和令牌桶算法最明顯的區別是令牌桶算法容許流量必定程度的突發。由於默認的令牌桶算法,取走token是不須要耗費時間的,也就是說,假設桶內有100個token時,那麼能夠瞬間容許100個請求經過。
令牌桶算法因爲實現簡單,且容許某些流量的突發,對用戶友好,因此被業界採用地較多。固然咱們須要具體狀況具體分析,只有最合適的算法,沒有最優的算法。
在開發高併發系統時有三把利器用來保護系統:緩存、降級和限流。緩存的目的是提高系統訪問速度和增大系統能處理的容量,可謂是抗高併發流量的銀彈;而降級是當服務出問題或者影響到核心流程的性能則須要暫時屏蔽掉,待高峯或者問題解決後再打開;而有些場景並不能用緩存和降級來解決,好比稀缺資源(秒殺、搶購)、寫服務(如評論、下單)、頻繁的複雜查詢(評論的最後幾頁),所以需有一種手段來限制這些場景的併發/請求量,即限流。
限流的目的是經過對併發訪問/請求進行限速或者一個時間窗口內的的請求進行限速來保護系統,一旦達到限制速率則能夠拒絕服務(定向到錯誤頁或告知資源沒有了)、排隊或等待(好比秒殺、評論、下單)、降級(返回兜底數據或默認數據,如商品詳情頁庫存默認有貨)。
通常開發高併發系統常見的限流有:限制總併發數(好比數據庫鏈接池、線程池)、限制瞬時併發數(如nginx的limit_conn模塊,用來限制瞬時併發鏈接數)、限制時間窗口內的平均速率(如Guava的RateLimiter、nginx的limit_req模塊,限制每秒的平均速率);其餘還有如限制遠程接口調用速率、限制MQ的消費速率。另外還能夠根據網絡鏈接數、網絡流量、CPU或內存負載等來限流。
先有緩存這個銀彈,後有限流來應對61八、雙十一高併發流量,在處理高併發問題上能夠說是如虎添翼,不用擔憂瞬間流量致使系統掛掉或雪崩,最終作到有損服務而不是不服務;限流須要評估好,不可亂用,不然會正常流量出現一些奇怪的問題而致使用戶抱怨。
在實際應用時也不要太糾結算法問題,由於一些限流算法實現是同樣的只是描述不同;具體使用哪一種限流技術仍是要根據實際場景來選擇,不要一味去找最佳模式,白貓黑貓能解決問題的就是好貓。
因在實際工做中遇到過許多人來問如何進行限流,所以本文會詳細介紹各類限流手段。那麼接下來咱們從限流算法、應用級限流、分佈式限流、接入層限流來詳細學習下限流技術手段。
常見的限流算法有:令牌桶、漏桶。計數器也能夠進行粗暴限流實現。
令牌桶算法
令牌桶算法是一個存放固定容量令牌的桶,按照固定速率往桶裏添加令牌。令牌桶算法的描述以下:
假設限制2r/s,則按照500毫秒的固定速率往桶中添加令牌;
桶中最多存放b個令牌,當桶滿時,新添加的令牌被丟棄或拒絕;
當一個n個字節大小的數據包到達,將從桶中刪除n個令牌,接着數據包被髮送到網絡上;
若是桶中的令牌不足n個,則不會刪除令牌,且該數據包將被限流(要麼丟棄,要麼緩衝區等待)。
漏桶算法
漏桶做爲計量工具(The Leaky Bucket Algorithm as a Meter)時,能夠用於流量整形(Traffic Shaping)和流量控制(TrafficPolicing),漏桶算法的描述以下:
一個固定容量的漏桶,按照常量固定速率流出水滴;
若是桶是空的,則不需流出水滴;
能夠以任意速率流入水滴到漏桶;
若是流入水滴超出了桶的容量,則流入的水滴溢出了(被丟棄),而漏桶容量是不變的。
令牌桶和漏桶對比:
令牌桶是按照固定速率往桶中添加令牌,請求是否被處理須要看桶中令牌是否足夠,當令牌數減爲零時則拒絕新的請求;
漏桶則是按照常量固定速率流出請求,流入請求速率任意,當流入的請求數累積到漏桶容量時,則新流入的請求被拒絕;
令牌桶限制的是平均流入速率(容許突發請求,只要有令牌就能夠處理,支持一次拿3個令牌,4個令牌),並容許必定程度突發流量;
漏桶限制的是常量流出速率(即流出速率是一個固定常量值,好比都是1的速率流出,而不能一次是1,下次又是2),從而平滑突發流入速率;
令牌桶容許必定程度的突發,而漏桶主要目的是平滑流入速率;
兩個算法實現能夠同樣,可是方向是相反的,對於相同的參數獲得的限流效果是同樣的。
另外有時候咱們還使用計數器來進行限流,主要用來限制總併發數,好比數據庫鏈接池、線程池、秒殺的併發數;只要全局總請求數或者必定時間段的總請求數設定的閥值則進行限流,是簡單粗暴的總數量限流,而不是平均速率限流。
到此基本的算法就介紹完了,接下來咱們首先看看應用級限流。
限流總併發/鏈接/請求數
對於一個應用系統來講必定會有極限併發/請求數,即總有一個TPS/QPS閥值,若是超了閥值則系統就會不響應用戶請求或響應的很是慢,所以咱們最好進行過載保護,防止大量請求涌入擊垮系統。
若是你使用過Tomcat,其Connector 其中一種配置有以下幾個參數:
acceptCount:若是Tomcat的線程都忙於響應,新來的鏈接會進入隊列排隊,若是超出排隊大小,則拒絕鏈接;
maxConnections: 瞬時最大鏈接數,超出的會排隊等待;
maxThreads:Tomcat能啓動用來處理請求的最大線程數,若是請求處理量一直遠遠大於最大線程數則可能會僵死。
詳細的配置請參考官方文檔。另外如Mysql(如max_connections)、Redis(如tcp-backlog)都會有相似的限制鏈接數的配置。
限流總資源數
若是有的資源是稀缺資源(如數據庫鏈接、線程),並且可能有多個系統都會去使用它,那麼須要限制應用;可使用池化技術來限制總資源數:鏈接池、線程池。好比分配給每一個應用的數據庫鏈接是100,那麼本應用最多可使用100個資源,超出了能夠等待或者拋異常。
限流某個接口的總併發/請求數
若是接口可能會有突發訪問狀況,但又擔憂訪問量太大形成崩潰,如搶購業務;這個時候就須要限制這個接口的總併發/請求數總請求數了;由於粒度比較細,能夠爲每一個接口都設置相應的閥值。可使用Java中的AtomicLong進行限流:
try {
if(atomic.incrementAndGet() > 限流數) {
//拒絕請求
}
//處理請求
} finally {
atomic.decrementAndGet();
}
適合對業務無損的服務或者須要過載保護的服務進行限流,如搶購業務,超出了大小要麼讓用戶排隊,要麼告訴用戶沒貨了,對用戶來講是能夠接受的。而一些開放平臺也會限制用戶調用某個接口的試用請求量,也能夠用這種計數器方式實現。這種方式也是簡單粗暴的限流,沒有平滑處理,須要根據實際狀況選擇使用;
限流某個接口的時間窗請求數
即一個時間窗口內的請求數,如想限制某個接口/服務每秒/每分鐘/天天的請求數/調用量。如一些基礎服務會被不少其餘系統調用,好比商品詳情頁服務會調用基礎商品服務調用,可是怕由於更新量比較大將基礎服務打掛,這時咱們要對每秒/每分鐘的調用量進行限速;一種實現方式以下所示:
LoadingCache<Long, AtomicLong> counter =
CacheBuilder.newBuilder()
.expireAfterWrite(2, TimeUnit.SECONDS)
.build(new CacheLoader<Long, AtomicLong>() {
@Override
public AtomicLong load(Long seconds) throws Exception {
return new AtomicLong(0);
}
});
long limit = 1000;
while(true) {
//獲得當前秒
long currentSeconds = System.currentTimeMillis() / 1000;
if(counter.get(currentSeconds).incrementAndGet() > limit) {
System.out.println("限流了:" + currentSeconds);
continue;
}
//業務處理
}
咱們使用Guava的Cache來存儲計數器,過時時間設置爲2秒(保證1秒內的計數器是有的),而後咱們獲取當前時間戳而後取秒數來做爲KEY進行計數統計和限流,這種方式也是簡單粗暴,剛纔說的場景夠用了。
平滑限流某個接口的請求數
以前的限流方式都不能很好地應對突發請求,即瞬間請求可能都被容許從而致使一些問題;所以在一些場景中須要對突發請求進行整形,整形爲平均速率請求處理(好比5r/s,則每隔200毫秒處理一個請求,平滑了速率)。這個時候有兩種算法知足咱們的場景:令牌桶和漏桶算法。Guava框架提供了令牌桶算法實現,可直接拿來使用。
Guava RateLimiter提供了令牌桶算法實現:平滑突發限流(SmoothBursty)和平滑預熱限流(SmoothWarmingUp)實現。
SmoothBursty
RateLimiter limiter = RateLimiter.create(5);
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
將獲得相似以下的輸出:
0.0
0.198239
0.196083
0.200609
0.199599
0.19961
一、RateLimiter.create(5) 表示桶容量爲5且每秒新增5個令牌,即每隔200毫秒新增一個令牌;
二、limiter.acquire()表示消費一個令牌,若是當前桶中有足夠令牌則成功(返回值爲0),若是桶中沒有令牌則暫停一段時間,好比發令牌間隔是200毫秒,則等待200毫秒後再去消費令牌(如上測試用例返回的爲0.198239,差很少等待了200毫秒桶中才有令牌可用),這種實現將突發請求速率平均爲了固定請求速率。
再看一個突發示例:
將獲得相似以下的輸出:
0.0
0.98745
0.183553
0.199909
limiter.acquire(5)表示桶的容量爲5且每秒新增5個令牌,令牌桶算法容許必定程度的突發,因此能夠一次性消費5個令牌,但接下來的limiter.acquire(1)將等待差很少1秒桶中才能有令牌,且接下來的請求也整形爲固定速率了。
將獲得相似以下的輸出:
0.0
1.997428
0.192273
0.200616
同上邊的例子相似,第一秒突發了10個請求,令牌桶算法也容許了這種突發(容許消費將來的令牌),但接下來的limiter.acquire(1)將等待差很少2秒桶中才能有令牌,且接下來的請求也整形爲固定速率了。
接下來再看一個突發的例子:
將獲得相似以下的輸出:
0.0
0.0
0.0
0.0
0.499876
0.495799
一、建立了一個桶容量爲2且每秒新增2個令牌;
二、首先調用limiter.acquire()消費一個令牌,此時令牌桶能夠知足(返回值爲0);
三、而後線程暫停2秒,接下來的兩個limiter.acquire()都能消費到令牌,第三個limiter.acquire()也一樣消費到了令牌,到第四個時就須要等待500毫秒了。
此處能夠看到咱們設置的桶容量爲2(即容許的突發量),這是由於SmoothBursty中有一個參數:最大突發秒數(maxBurstSeconds)默認值是1s,突發量/桶容量=速率*maxBurstSeconds,因此本示例桶容量/突發量爲2,例子中前兩個是消費了以前積攢的突發量,而第三個開始就是正常計算的了。令牌桶算法容許將一段時間內沒有消費的令牌暫存到令牌桶中,留待將來使用,並容許將來請求的這種突發。
SmoothBursty經過平均速率和最後一次新增令牌的時間計算出下次新增令牌的時間的,另外須要一個桶暫存一段時間內沒有使用的令牌(便可以突發的令牌數)。另外RateLimiter還提供了tryAcquire方法來進行無阻塞或可超時的令牌消費。
由於SmoothBursty容許必定程度的突發,會有人擔憂若是容許這種突發,假設忽然間來了很大的流量,那麼系統極可能扛不住這種突發。所以須要一種平滑速率的限流工具,從而系統冷啓動後慢慢的趨於平均固定速率(即剛開始速率小一些,而後慢慢趨於咱們設置的固定速率)。Guava也提供了SmoothWarmingUp來實現這種需求,其能夠認爲是漏桶算法,可是在某些特殊場景又不太同樣。
SmoothWarmingUp建立方式:RateLimiter.create(doublepermitsPerSecond, long warmupPeriod, TimeUnit unit)
permitsPerSecond表示每秒新增的令牌數,warmupPeriod表示在從冷啓動速率過渡到平均速率的時間間隔。
示例以下:
將獲得相似以下的輸出:
0.0
0.51767
0.357814
0.219992
0.199984
0.0
0.360826
0.220166
0.199723
0.199555
速率是梯形上升速率的,也就是說冷啓動時會以一個比較大的速率慢慢到平均速率;而後趨於平均速率(梯形降低到平均速率)。能夠經過調節warmupPeriod參數實現一開始就是平滑固定速率。
到此應用級限流的一些方法就介紹完了。假設將應用部署到多臺機器,應用級限流方式只是單應用內的請求限流,不能進行全侷限流。所以咱們須要分佈式限流和接入層限流來解決這個問題。
分佈式限流最關鍵的是要將限流服務作成原子化,而解決方案可使使用redis+lua或者nginx+lua技術進行實現,經過這兩種技術能夠實現的高併發和高性能。
首先咱們來使用redis+lua實現時間窗內某個接口的請求數限流,實現了該功能後能夠改造爲限流總併發/請求數和限制總資源數。Lua自己就是一種編程語言,也可使用它實現複雜的令牌桶或漏桶算法。
redis+lua實現中的lua腳本:
如上操做因是在一個lua腳本中,又因Redis是單線程模型,所以是線程安全的。如上方式有一個缺點就是當達到限流大小後仍是會遞增的,能夠改形成以下方式實現:
以下是Java中判斷是否須要限流的代碼:
由於Redis的限制(Lua中有寫操做不能使用帶隨機性質的讀操做,如TIME)不能在Redis Lua中使用TIME獲取時間戳,所以只好從應用獲取而後傳入,在某些極端狀況下(機器時鐘不許的狀況下),限流會存在一些小問題。
使用Nginx+Lua實現的Lua腳本:
實現中咱們須要使用lua-resty-lock互斥鎖模塊來解決原子性問題(在實際工程中使用時請考慮獲取鎖的超時問題),並使用ngx.shared.DICT共享字典來實現計數器。若是須要限流則返回0,不然返回1。使用時須要先定義兩個共享字典(分別用來存放鎖和計數器數據):
有人會糾結若是應用併發量很是大那麼redis或者nginx是否是能抗得住;不過這個問題要從多方面考慮:你的流量是否是真的有這麼大,是否是能夠經過一致性哈希將分佈式限流進行分片,是否是能夠當併發量太大降級爲應用級限流;對策很是多,能夠根據實際狀況調節;像在京東使用Redis+Lua來限流搶購流量,通常流量是沒有問題的。
對於分佈式限流目前遇到的場景是業務上的限流,而不是流量入口的限流;流量入口限流應該在接入層完成,而接入層筆者通常使用Nginx。
接入層一般指請求流量的入口,該層的主要目的有:負載均衡、非法請求過濾、請求聚合、緩存、降級、限流、A/B測試、服務質量監控等等,能夠參考筆者寫的《使用Nginx+Lua(OpenResty)開發高性能Web應用》。
對於Nginx接入層限流可使用Nginx自帶了兩個模塊:鏈接數限流模塊ngx_http_limit_conn_module和漏桶算法實現的請求限流模塊ngx_http_limit_req_module。還可使用OpenResty提供的Lua限流模塊lua-resty-limit-traffic進行更復雜的限流場景。
limit_conn用來對某個KEY對應的總的網絡鏈接數進行限流,能夠按照如IP、域名維度進行限流。limit_req用來對某個KEY對應的請求的平均速率進行限流,並有兩種用法:平滑模式(delay)和容許突發模式(nodelay)。
limit_conn是對某個KEY對應的總的網絡鏈接數進行限流。能夠按照IP來限制IP維度的總鏈接數,或者按照服務域名來限制某個域名的總鏈接數。可是記住不是每個請求鏈接都會被計數器統計,只有那些被Nginx處理的且已經讀取了整個請求頭的請求鏈接纔會被計數器統計。
配置示例:
limit_conn:要配置存放KEY和計數器的共享內存區域和指定KEY的最大鏈接數;此處指定的最大鏈接數是1,表示Nginx最多同時併發處理1個鏈接;
limit_conn_zone:用來配置限流KEY、及存放KEY對應信息的共享內存區域大小;此處的KEY是「$binary_remote_addr」其表示IP地址,也可使用如$server_name做爲KEY來限制域名級別的最大鏈接數;
limit_conn_status:配置被限流後返回的狀態碼,默認返回503;
limit_conn_log_level:配置記錄被限流後的日誌級別,默認error級別。
limit_conn的主要執行過程以下所示:
一、請求進入後首先判斷當前limit_conn_zone中相應KEY的鏈接數是否超出了配置的最大鏈接數;
2.一、若是超過了配置的最大大小,則被限流,返回limit_conn_status定義的錯誤狀態碼;
2.二、不然相應KEY的鏈接數加1,並註冊請求處理完成的回調函數;
三、進行請求處理;
四、在結束請求階段會調用註冊的回調函數對相應KEY的鏈接數減1。
limt_conn能夠限流某個KEY的總併發/請求數,KEY能夠根據須要變化。
按照IP限制併發鏈接數配置示例:
首先定義IP維度的限流區域:
接着在要限流的location中添加限流邏輯:
即容許每一個IP最大併發鏈接數爲2。
使用AB測試工具進行測試,併發數爲5個,總的請求數爲5個:
將獲得以下access.log輸出:
[08/Jun/2016:20:10:51+0800] [1465373451.802] 200
[08/Jun/2016:20:10:51+0800] [1465373451.803] 200
[08/Jun/2016:20:10:51 +0800][1465373451.803] 503
[08/Jun/2016:20:10:51 +0800][1465373451.803] 503
[08/Jun/2016:20:10:51 +0800][1465373451.803] 503
此處咱們把access log格式設置爲log_format main '[$time_local] [$msec] $status';分別是「日期 日期秒/毫秒值 響應狀態碼」。
若是被限流了,則在error.log中會看到相似以下的內容:
按照域名限制併發鏈接數配置示例:
首先定義域名維度的限流區域:
接着在要限流的location中添加限流邏輯:
即容許每一個域名最大併發請求鏈接數爲2;這樣配置能夠實現服務器最大鏈接數限制。
limit_req是漏桶算法實現,用於對指定KEY對應的請求進行限流,好比按照IP維度限制請求速率。
配置示例:
limit_req:配置限流區域、桶容量(突發容量,默認0)、是否延遲模式(默認延遲);
limit_req_zone:配置限流KEY、及存放KEY對應信息的共享內存區域大小、固定請求速率;此處指定的KEY是「$binary_remote_addr」表示IP地址;固定請求速率使用rate參數配置,支持10r/s和60r/m,即每秒10個請求和每分鐘60個請求,不過最終都會轉換爲每秒的固定請求速率(10r/s爲每100毫秒處理一個請求;60r/m,即每1000毫秒處理一個請求)。
limit_conn_status:配置被限流後返回的狀態碼,默認返回503;
limit_conn_log_level:配置記錄被限流後的日誌級別,默認error級別。
limit_req的主要執行過程以下所示:
一、請求進入後首先判斷最後一次請求時間相對於當前時間(第一次是0)是否須要限流,若是須要限流則執行步驟2,不然執行步驟3;
2.一、若是沒有配置桶容量(burst),則桶容量爲0;按照固定速率處理請求;若是請求被限流,則直接返回相應的錯誤碼(默認503);
2.二、若是配置了桶容量(burst>0)且延遲模式(沒有配置nodelay);若是桶滿了,則新進入的請求被限流;若是沒有滿則請求會以固定平均速率被處理(按照固定速率並根據須要延遲處理請求,延遲使用休眠實現);
2.三、若是配置了桶容量(burst>0)且非延遲模式(配置了nodelay);不會按照固定速率處理請求,而是容許突發處理請求;若是桶滿了,則請求被限流,直接返回相應的錯誤碼;
三、若是沒有被限流,則正常處理請求;
四、Nginx會在相應時機進行選擇一些(3個節點)限流KEY進行過時處理,進行內存回收。
場景2.1測試
首先定義IP維度的限流區域:
限制爲每秒500個請求,固定平均速率爲2毫秒一個請求。
接着在要限流的location中添加限流邏輯:
即桶容量爲0(burst默認爲0),且延遲模式。
使用AB測試工具進行測試,併發數爲2個,總的請求數爲10個:
將獲得以下access.log輸出:
[08/Jun/2016:20:25:56+0800] [1465381556.410] 200
[08/Jun/2016:20:25:56 +0800][1465381556.410] 503
[08/Jun/2016:20:25:56 +0800][1465381556.411] 503
[08/Jun/2016:20:25:56+0800] [1465381556.411] 200
[08/Jun/2016:20:25:56 +0800][1465381556.412] 503
[08/Jun/2016:20:25:56 +0800][1465381556.412] 503
雖然每秒容許500個請求,可是由於桶容量爲0,因此流入的請求要麼被處理要麼被限流,沒法延遲處理;另外平均速率在2毫秒左右,好比1465381556.410和1465381556.411被處理了;有朋友會說這固定平均速率不是1毫秒嘛,其實這是由於實現算法沒那麼精準形成的。
若是被限流在error.log中會看到以下內容:
2016/06/08 20:25:56 [error] 6130#0: *1962limiting requests, excess: 1.000 by zone "test", client: 127.0.0.1,server: _, request: "GET /limit HTTP/1.0", host:"localhost"
若是被延遲了在error.log(日誌級別要INFO級別)中會看到以下內容:
2016/06/10 09:05:23 [warn] 9766#0: *97021delaying request, excess: 0.368, by zone "test", client: 127.0.0.1,server: _, request: "GET /limit HTTP/1.0", host:"localhost"
場景2.2測試
首先定義IP維度的限流區域:
爲了方便測試設置速率爲每秒2個請求,即固定平均速率是500毫秒一個請求。
接着在要限流的location中添加限流邏輯:
固定平均速率爲500毫秒一個請求,通容量爲3,若是桶滿了新的請求被限流,不然能夠進入桶中排隊並等待(實現延遲模式)。
爲了看出限流效果咱們寫了一個req.sh腳本:
首先進行6個併發請求6次URL,而後休眠300毫秒,而後再進行6個併發請求6次URL;中間休眠目的是爲了能跨越2秒看到效果,若是看不到以下的效果能夠調節休眠時間。
將獲得以下access.log輸出:
[09/Jun/2016:08:46:43+0800] [1465433203.959] 200
[09/Jun/2016:08:46:43 +0800][1465433203.959] 503
[09/Jun/2016:08:46:43 +0800][1465433203.960] 503
[09/Jun/2016:08:46:44+0800] [1465433204.450] 200
[09/Jun/2016:08:46:44+0800] [1465433204.950] 200
[09/Jun/2016:08:46:45 +0800][1465433205.453] 200
[09/Jun/2016:08:46:45 +0800][1465433205.766] 503
[09/Jun/2016:08:46:45 +0800][1465433205.766] 503
[09/Jun/2016:08:46:45 +0800][1465433205.767] 503
[09/Jun/2016:08:46:45+0800] [1465433205.950] 200
[09/Jun/2016:08:46:46+0800] [1465433206.451] 200
[09/Jun/2016:08:46:46+0800] [1465433206.952] 200
桶容量爲3,即桶中在時間窗口內最多流入3個請求,且按照2r/s的固定速率處理請求(即每隔500毫秒處理一個請求);桶計算時間窗口(1.5秒)=速率(2r/s)/桶容量(3),也就是說在這個時間窗口內桶最多暫存3個請求。所以咱們要以當前時間往前推1.5秒和1秒來計算時間窗口內的總請求數;另外由於默認是延遲模式,因此時間窗內的請求要被暫存到桶中,並以固定平均速率處理請求:
第一輪:有4個請求處理成功了,按照漏桶桶容量應該最多3個纔對;這是由於計算算法的問題,第一次計算因沒有參考值,因此第一次計算後,後續的計算纔能有參考值,所以第一次成功能夠忽略;這個問題影響很小能夠忽略;並且按照固定500毫秒的速率處理請求。
第二輪:由於第一輪請求是突發來的,差很少都在1465433203.959時間點,只是由於漏桶將速率進行了平滑變成了固定平均速率(每500毫秒一個請求);而第二輪計算時間應基於1465433203.959;而第二輪突發請求差很少都在1465433205.766時間點,所以計算桶容量的時間窗口應基於1465433203.959和1465433205.766來計算,計算結果爲1465433205.766這個時間點漏桶爲空了,能夠流入桶中3個請求,其餘請求被拒絕;又由於第一輪最後一次處理時間是1465433205.453,因此第二輪第一個請求被延遲到了1465433205.950。這裏也要注意固定平均速率只是在配置的速率左右,存在計算精度問題,會有一些誤差。
若是桶容量改成1(burst=1),執行req.sh腳本能夠看到以下輸出:
09/Jun/2016:09:04:30+0800] [1465434270.362] 200
[09/Jun/2016:09:04:30 +0800][1465434270.371] 503
[09/Jun/2016:09:04:30 +0800] [1465434270.372]503
[09/Jun/2016:09:04:30 +0800][1465434270.372] 503
[09/Jun/2016:09:04:30 +0800][1465434270.372] 503
[09/Jun/2016:09:04:30+0800] [1465434270.864] 200
[09/Jun/2016:09:04:31 +0800][1465434271.178] 503
[09/Jun/2016:09:04:31 +0800][1465434271.178] 503
[09/Jun/2016:09:04:31 +0800][1465434271.178] 503
[09/Jun/2016:09:04:31 +0800][1465434271.178] 503
[09/Jun/2016:09:04:31 +0800][1465434271.179] 503
[09/Jun/2016:09:04:31+0800] [1465434271.366] 200
桶容量爲1,按照每1000毫秒一個請求的固定平均速率處理請求。
場景2.3測試
首先定義IP維度的限流區域:
爲了方便測試配置爲每秒2個請求,固定平均速率是500毫秒一個請求。
接着在要限流的location中添加限流邏輯:
桶容量爲3,若是桶滿了直接拒絕新請求,且每秒2最多兩個請求,桶按照固定500毫秒的速率以nodelay模式處理請求。
爲了看到限流效果咱們寫了一個req.sh腳本:
將獲得相似以下access.log輸出:
[09/Jun/2016:14:30:11+0800] [1465453811.754] 200
[09/Jun/2016:14:30:11+0800] [1465453811.755] 200
[09/Jun/2016:14:30:11+0800] [1465453811.755] 200
[09/Jun/2016:14:30:11+0800] [1465453811.759] 200
[09/Jun/2016:14:30:11 +0800][1465453811.759] 503
[09/Jun/2016:14:30:11 +0800][1465453811.759] 503
[09/Jun/2016:14:30:12+0800] [1465453812.776] 200
[09/Jun/2016:14:30:12+0800] [1465453812.776] 200
[09/Jun/2016:14:30:12 +0800][1465453812.776] 503
[09/Jun/2016:14:30:12 +0800][1465453812.777] 503
[09/Jun/2016:14:30:12 +0800][1465453812.777] 503
[09/Jun/2016:14:30:12 +0800][1465453812.777] 503
[09/Jun/2016:14:30:13 +0800] [1465453813.095]503
[09/Jun/2016:14:30:13 +0800][1465453813.097] 503
[09/Jun/2016:14:30:13 +0800][1465453813.097] 503
[09/Jun/2016:14:30:13 +0800][1465453813.097] 503
[09/Jun/2016:14:30:13 +0800][1465453813.097] 503
[09/Jun/2016:14:30:13 +0800][1465453813.098] 503
[09/Jun/2016:14:30:13+0800] [1465453813.425] 200
[09/Jun/2016:14:30:13 +0800][1465453813.425] 503
[09/Jun/2016:14:30:13 +0800][1465453813.425] 503
[09/Jun/2016:14:30:13 +0800][1465453813.426] 503
[09/Jun/2016:14:30:13 +0800][1465453813.426] 503
[09/Jun/2016:14:30:13 +0800][1465453813.426] 503
[09/Jun/2016:14:30:13+0800] [1465453813.754] 200
[09/Jun/2016:14:30:13 +0800][1465453813.755] 503
[09/Jun/2016:14:30:13 +0800][1465453813.755] 503
[09/Jun/2016:14:30:13 +0800][1465453813.756] 503
[09/Jun/2016:14:30:13 +0800][1465453813.756] 503
[09/Jun/2016:14:30:13 +0800][1465453813.756] 503
[09/Jun/2016:14:30:15+0800] [1465453815.278] 200
[09/Jun/2016:14:30:15+0800] [1465453815.278] 200
[09/Jun/2016:14:30:15+0800] [1465453815.278] 200
[09/Jun/2016:14:30:15 +0800][1465453815.278] 503
[09/Jun/2016:14:30:15 +0800][1465453815.279] 503
[09/Jun/2016:14:30:15 +0800][1465453815.279] 503
[09/Jun/2016:14:30:17+0800] [1465453817.300] 200
[09/Jun/2016:14:30:17+0800] [1465453817.300] 200
[09/Jun/2016:14:30:17+0800] [1465453817.300] 200
[09/Jun/2016:14:30:17+0800] [1465453817.301] 200
[09/Jun/2016:14:30:17 +0800][1465453817.301] 503
[09/Jun/2016:14:30:17 +0800][1465453817.301] 503
桶容量爲3(,即桶中在時間窗口內最多流入3個請求,且按照2r/s的固定速率處理請求(即每隔500毫秒處理一個請求);桶計算時間窗口(1.5秒)=速率(2r/s)/桶容量(3),也就是說在這個時間窗口內桶最多暫存3個請求。所以咱們要以當前時間往前推1.5秒和1秒來計算時間窗口內的總請求數;另外由於配置了nodelay,是非延遲模式,因此容許時間窗內突發請求的;另外從本示例會看出兩個問題:
第一輪和第七輪:有4個請求處理成功了;這是由於計算算法的問題,本示例是若是2秒內沒有請求,而後接着忽然來了不少請求,第一次計算的結果將是不正確的;這個問題影響很小能夠忽略;
第五輪:1.0秒計算出來是3個請求;此處也是因計算精度的問題,也就是說limit_req實現的算法不是很是精準的,假設此處當作相對於2.75的話,1.0秒內只有1次請求,因此仍是容許1次請求的。
若是限流出錯了,能夠配置錯誤頁面:
limit_conn_zone/limit_req_zone定義的內存不足,則後續的請求將一直被限流,因此須要根據需求設置好相應的內存大小。
此處的限流都是單Nginx的,假設咱們接入層有多個nginx,此處就存在和應用級限流相同的問題;那如何處理呢?一種解決辦法:創建一個負載均衡層將按照限流KEY進行一致性哈希算法將請求哈希到接入層Nginx上,從而相同KEY的將打到同一臺接入層Nginx上;另外一種解決方案就是使用Nginx+Lua(OpenResty)調用分佈式限流邏輯實現。
以前介紹的兩個模塊使用上比較簡單,指定KEY、指定限流速率等就能夠了,若是咱們想根據實際狀況變化KEY、變化速率、變化桶大小等這種動態特性,使用標準模塊就很難去實現了,所以咱們須要一種可編程來解決咱們問題;而OpenResty提供了lua限流模塊lua-resty-limit-traffic,經過它能夠按照更復雜的業務邏輯進行動態限流處理了。其提供了limit.conn和limit.req實現,算法與nginx limit_conn和limit_req是同樣的。
此處咱們來實現ngx_http_limit_req_module中的【場景2.2測試】,不要忘記下載lua-resty-limit-traffic模塊並添加到OpenResty的lualib中。
配置用來存放限流用的共享字典:
如下是實現【場景2.2測試】的限流代碼limit_req.lua:
即限流邏輯再nginx access階段被訪問,若是不被限流繼續後續流程;若是須要被限流要麼sleep一段時間繼續後續流程,要麼返回相應的狀態碼拒絕請求。
在分佈式限流中咱們使用了簡單的Nginx+Lua進行分佈式限流,有了這個模塊也可使用這個模塊來實現分佈式限流。
另外在使用Nginx+Lua時也能夠獲取ngx.var.connections_active進行過載保護,即若是當前活躍鏈接數超過閾值進行限流保護。
nginx也提供了limit_rate用來對流量限速,如limit_rate 50k,表示限制下載速度爲50k。
到此筆者在工做中涉及的限流用法就介紹完,這些算法中有些容許突發,有些會整形爲平滑,有些計算算法簡單粗暴;其中令牌桶算法和漏桶算法實現上是相似的,只是表述的方向不太同樣,對於業務來講沒必要刻意去區分它們;所以須要根據實際場景來決定如何限流,最好的算法不必定是最適用的。
參考資料
https://en.wikipedia.org/wiki/Token_bucket
https://en.wikipedia.org/wiki/Leaky_bucket
http://redis.io/commands/incr
http://nginx.org/en/docs/http/ngx_http_limit_req_module.html
http://nginx.org/en/docs/http/ngx_http_limit_conn_module.html
https://github.com/openresty/lua-resty-limit-traffic
http://nginx.org/en/docs/http/ngx_http_core_module.html#limit_rate
目前系統的監控方面,linux機器,能夠定時的獲取cpu、load、IO、網絡等狀況,統計以後,若是超過閥值,便可報警。
web的請求,能夠經過分析apache的日誌,獲取PV、UV以及頁面的響應時間等信息,統計這些信息,若是有異常,報警便可。
可是java系統(一個java進程)中的bean的狀況如何作到監控和流控呢?
雙十一,各個系統都有一些監控和流控的策略,瞭解了一圈以後,打算總結一下,只是粗略的寫一下思路,記錄一下,不涉及到細節(由於細節坑不少)。
監控的目前是爲了瞭解系統的運行細節,流控是爲了在出現問題(瞬間請求暴漲、持續一段時間請求超過平均值、底層依賴的應用或者DB出現異常)的時候可以作出處理(動態限流拒絕新的請求進來<限流策略能夠設置>、利用開關<修改靜態類的屬性>進行應用降級處理減小非核心的調用)。
(1)業務日誌
監控業務代碼拋出來的日誌信息(能夠在linux中經過crontab定時程序來抓取,也能夠經過控制檯來遠程處理,也能夠把文件拉到集中的分析服務器來處理),若是異常數太多,或者出現本身以前設置過的報警關鍵字,則進行報警;
(2)java gc的日誌
java在運行過程當中,若是配置了參數(-verbose:gc -Xloggc:/home/admin/logs/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps),可以看到系統的gc的狀況,若是FullGc特別頻繁(超過一小時一次),能夠提示報警;
(3)擁堵線程數做爲監控指標
建立一個對象,裏面一個key是方法的表明,而後屬性中有個計數器(這個key此時對應的擁堵線程數),若是方法體內處理變慢,併發狀況下擁堵線程數會增長,此時,能夠進行報警,也能夠把後續的請求當掉,下降負載。計數器進行變化能夠有兩種方式:第一種就是,經過AOP的方式,在方法調用前(before)擁堵線程數加一,方法返回後(after)擁堵線程數減一,這種好處就是不用硬編碼;另一種就須要寫代碼,在方法體內,調用開始加以,而後try住,在finally的時候減一。
(4)根據方法調用的QPS和RT
QPS即一秒內方法調用的次數,RT即一秒內方法調用的平均返回時間。對於web請求,通常在web服務器(apache/nginx)中作掉這個,在服務器A中部署一個client,用來作計數統計,具體的計數規則可能會比較複雜,超過閥值(有多是攻擊),則請求server端此時的拒絕策略,是讓用戶等待,仍是跳轉待驗證碼頁面讓其輸入驗證碼以後再進行訪問,仍是直接返回錯誤。對於java中的bean方法,如何獲取呢?如三種描述,兩種方式一種是AOP攔截,一種是代碼中硬編碼。
(5)基於AOP獲取一個特定方法在特定時間段(例如一分鐘)的擁堵線程數、執行次數(總次數、成功次數、失敗次數)、響應時間
作一個本地內存對象,用於記錄這些信息,經過AOP的before獲取信息(當前時間,線程數加一,調用次數加一),afterReturen獲取信息(返回時間、線程數減一,調用次數加一,能夠定製啥叫成功失敗,而後相應次數增長),afterThrowing獲取信息(返回時間、線程數減1、失敗次數加一),最後定時dump內存中的數據,存儲到DB或者日誌,而後對於這些信息進行監控(能夠根據歷史來進行同比和環比)進行報警。
(6)動態來添加QPS和RT的流控
這種流控方式最爲靈活,應用端依賴一個jar包,而後添加一個全局的AOP配置(對於頁面的請求,在web.xml中添加filter便可),攔截全部的方法(性能消耗能夠忽略),可是隻有符合規則的方法纔會進行統計,而後有一個控制檯設置規則,設置好規則後推送到應用端,應用端獲取這個規則,根據這個規則來進行統計,超出閥值則進行限流。這種方式最爲靈活,遇到緊急問題的時候能夠經過控制檯來限流掉。
(7)請求是海量的狀況下如何進行監控
在請求很大的狀況下,此時應用就不要處理監控的邏輯了,只要獲取調用的信息(時間點、響應時間、方法簽名等信息),而後把這些信息打印到日誌中,異步來進行處理,把這些日誌拉到專門的分析集羣,而後在分析集羣裏面來作實時的分析,把分析的結果持久話到BD,對於分析後的結構化數據進行監控。
轉載公司一位同事的文章:
http://rdc.taobao.com/team/jm/archives/2594
流量預警和限流方案中,比較經常使用的有兩種。第一種滑窗模式,經過統計多個單元時間的訪問次數來進行控制,當單位時間的訪問次數達到的某個峯值時進行限流。第二種爲響應模式,經過控制當前活躍請求數,來進行流量控制。下面來簡單分析下兩種的優缺點。
一、滑窗模式
模式分析:
在每次有訪問進來時,咱們判斷前N個單位時間裏總訪問量是否超過了設置的閾值,若超過則不容許執行。
這種模式的實現的方式更加契合流控的本質意義。理解較爲簡單。但因爲訪問量的預先不可預見性,會發生單位時間的前半段有大量的請求涌入,然後半段則拒絕全部請求的狀況發生。(通常,須要會將單位時間切的足夠的細來解決這個問題)其次,咱們很難肯定這個閾值設置在多少比較合適,只能經過經驗或者模擬(如壓測)來進行估計,不過即便是壓測也很難估計的準確,線上每臺機器的硬件參數的不一樣,或者同一臺機子在不一樣的時間點其能夠接受的閾值也不盡相同(系統中),每一個時間點致使可以承受的最大閾值也不盡相同,咱們沒法考慮的周全。
因此滑窗模式每每用來對某一資源的保護上(或者說是承諾比較合適:我對某一接口的提供者承諾過,最高調用量不超過XX),如對db的保護,對某一服務的調用的控制上。由於對於咱們應用來講,db或某一接口就是一共單一的總體。
代碼實現思路:
每個窗(單位時間)就是一個獨立的計數器(原子計數器),用以數組保存。將當前時間以某種方式(好比取模)映射到數組的一項中。每次訪問先對當前窗內計數器+1,再計算前N個單元格的訪問量綜合,超過閾值則限流。
這裏有個問題,時間永遠是遞增的,單純的取模,會致使數組過長,使用內存過多,咱們能夠用環形隊列來解決這個問題。
二、響應模式
模式分析:
每次操做執行時,咱們經過判斷當前正在執行的訪問數是否超過某個閾值在決定是否限流。
該模式看着思路比較的另類,但卻有其獨到之處。實際上咱們限流的根本是爲了保護資源,防止系統接受的請求過多,目不暇接,拖慢系統中其餘接口的服務,形成雪崩。也就是說咱們真正須要關心的是那些運行中的請求,而那些已經完成的請求已經是過去時,再也不是須要關心的了。
咱們來看看其閾值的計算方式,對於一個請求來講,響應時間rt/qps是一個比較容易獲取的參數,那麼咱們這樣計算:qps/1000*rt。
此外,一個應用每每是個複雜的系統,提供的服務或者暴露的請求、資源不止一個。內部GC、定時任務的執行、其餘服務訪問的驟增,外部依賴方、db的抖動,抑或是代碼中不經意間的一個bug。均可能致使相應時間的變化,致使系統同時能夠執行請求的變化。而這種模式,則能恰如其分的自動作出調整,當系統不適時,rt增長時,會自動的對qps作出適應。
代碼實現思路:
當訪問開始時,咱們對當前計數器(原子計數器)+1,當完成時,-1。該計數器即爲當前正在執行的請求數。只需判斷這個計數器是否超過閾值便可。