參考文章:https://zhuanlan.zhihu.com/p/439093222
https://mp.weixin.qq.com/s/zf9uqfJfRYvmSVXUQofF2A
互聯網應用往往是高併發的場景,互聯網的特性就是瞬時、激增,比如鹿晗官宣了,此時,如果沒有流量管控,很容易導致系統雪崩。
而限流是用來保證系統穩定性的常用手段,當系統遭遇瞬時流量激增,很可能會因系統資源耗盡導致宕機,限流可以把一超出系統承受能力外的流量直接拒絕掉,保證大部分流量可以正常訪問,從而保證系統只接收承受範圍以內的請求。
我們常用的限流算法有:漏桶算法、令牌桶算法。
漏桶算法
漏桶算法很形象,我們可以想像有一個大桶,大桶底部有一個固定大小的洞,Web請求就像水一樣,先進入大桶,然後以固定的速率從底部漏出來,無論進入桶中的水多麼迅猛,漏桶算法始終以固定的速度來漏水。
對應到Web請求就是:
- 當桶中無水時表示當前無請求等待,可以直接處理當前的請求;
- 當桶中有水時表示當前有請求正在等待處理,此時新來的請求也是需要進行等待處理;
- 當桶中水已經裝滿,並且進入的速率大於漏水的速率,水就會溢出來,此時系統就會拒絕新來的請求;
令牌桶算法
令牌桶跟漏桶算法有點不一樣,令牌桶算法也有一個大桶,桶中裝的都是令牌,有一個固定的“人”在不停的往桶中放令牌,每個請求來的時候都要從桶中拿到令牌,要不然就無法進行請求操作。
- 當沒有請求來時,桶中的令牌會越來越多,一直到桶被令牌裝滿爲止,多餘的令牌會被丟棄
- 當請求的速率大於令牌放入桶的速率,桶中的令牌會越來越少,直止桶變空爲止,此時的請求會等待新令牌的產生
漏桶算法 VS 令牌桶算法
- 漏桶算法是桶中有水就需要等待,桶滿就拒絕請求。而令牌桶是桶變空了需要等待令牌產生;
- 漏桶算法漏水的速率固定,令牌桶算法往桶中放令牌的速率固定;
- 令牌桶可以接收的瞬時流量比漏桶大,比如桶的容量爲100,令牌桶會裝滿100個令牌,當有瞬時80個併發過來時可以從桶中迅速拿到令牌進行處理,而漏桶的消費速率固定,當瞬時80個併發過來時,可能需要進行排隊等待;
介紹了算法,接下來我們介紹下Redis實現限流的幾種方式。
第一種:基於Redis的setNX的操作
我們在使用Redis的分佈式鎖的時候,大家都知道是依靠了setNX的指令,在CAS(Compare and swap)的操作的時候,同時給指定的key設置了過期實踐(expire),我們在限流的主要目的就是爲了在單位時間內,有且僅有N數量的請求能夠訪問我的代碼程序。所以依靠setnx可以很輕鬆的做到這方面的功能。
比如我們需要在10秒內限定20個請求,那麼我們在setnx的時候可以設置過期時間10,當請求的setnx數量達到20時候即達到了限流效果。代碼比較簡單就不做展示了。
當然這種做法的弊端是很多的,比如當統計1-10秒的時候,無法統計2-11秒之內,如果需要統計N秒內的M個請求,那麼我們的Redis中需要保持N個key等等問題。
第二種:基於Redis的數據結構zset
其實限流涉及的最主要的就是滑動窗口,上面也提到1-10怎麼變成2-11。其實也就是起始值和末端值都各+1即可。
而我們如果用Redis的list數據結構可以輕而易舉的實現該功能
我們可以將請求打造成一個zset數組,當每一次請求進來的時候,value保持唯一,可以用UUID生成,而score可以用當前時間戳表示,因爲score我們可以用來計算當前時間戳之內有多少的請求數量。而zset數據結構也提供了range方法讓我們可以很輕易的獲取到2個時間戳內有多少請求(解決了第一種方案中無法統計2-11秒的問題)。
示例代碼:
using System; using System.Threading; using ServiceStack.Redis; namespace IPCounter { class Program { static void Main(string[] args) { RedisClient client = new RedisClient("[email protected]:6379"); string key = "aa"; for (int i=0;i<100;i++) { long currentTime = ToUnixTimestampBySeconds(DateTime.Now); if (client.ContainsKey(key)) { var count = client.GetRangeFromSortedSetByHighestScore(key,currentTime-1,currentTime).Count; if(count>3) { Console.WriteLine("您的請求頻率太高了"); Console.ReadLine(); } count = client.GetRangeFromSortedSetByHighestScore(key, currentTime - 60, currentTime).Count; if (count > 30) { Console.WriteLine("您的請求頻率太高了"); Console.ReadLine(); } } string value = Guid.NewGuid().ToString(); long score = currentTime; client.AddItemToSortedSet(key, value, score); //清除2分鐘之前的記錄 var list = client.GetRangeFromSortedSetByHighestScore(key,0, currentTime-65); client.RemoveItemsFromSortedSet(key, list); Thread.Sleep(500); } Console.WriteLine("Hello World!"); } public static long ToUnixTimestampBySeconds(DateTime dt) { DateTimeOffset dto = new DateTimeOffset(dt); return dto.ToUnixTimeSeconds(); } } }
通過上述代碼可以做到滑動窗口的效果,並且能保證每N秒內至多M個請求,缺點就是zset的數據結構會越來越大。實現方式相對也是比較簡單的。
第三種:基於Redis的令牌桶算法
令牌桶算法提及到輸入速率和輸出速率,當輸出速率大於輸入速率,那麼就是超出流量限制了。
也就是說我們每訪問一次請求的時候,可以從Redis中獲取一個令牌,如果拿到令牌了,那就說明沒超出限制,而如果拿不到,則結果相反。
依靠上述的思想,我們可以結合Redis的List數據結構很輕易的做到這樣的代碼,只是簡單實現依靠List的leftPop方法來獲取令牌。
示例代碼:
// 輸出令牌
static void LimitRequest() { RedisClient client = new RedisClient("[email protected]:6379"); string key = "limitRate"; var result= client.PopItemFromList(key); if(result==null) { Console.WriteLine("系統繁忙,請稍後再試"); } else { Console.WriteLine("訪問成功"); } }
再依靠定時任務,定時往令牌桶List中加入新的令牌(使用List的rightPush方法),當然令牌也需要唯一性,這裏還是用UUID生成令牌:
// 10S的速率往令牌桶中添加UUID,保證唯一性
/// <summary> /// 比如我們速率限制是1分鐘100個,那麼就處理爲1分鐘內,桶中就只有100個令牌。 /// </summary> static void AddTokenToBucket() { string key = "limitRate"; RedisClient client = new RedisClient("[email protected]:6379"); var count = client.GetListCount(key); for(var i=0;i<100-count;i++) //需要判斷原來是否還有剩餘,有則相應扣減,確保桶中只有100個令牌 { client.AddItemToList(key, Guid.NewGuid().ToString()); } }
綜上,代碼實現起始都不是很難,針對這些限流方式我們可以在AOP或者filter中加入以上代碼,用來做到接口的限流,最終保護系統的穩定。