這裏主要討論以下幾種方式,有更好的建議,歡迎留言
- 基於數據庫(Mysql爲例)的統計進行限流
- 基於redis自增長及過期策略的限流
- 基於內存(linkedlist爲例)的限流
- 基於木桶算法的限流
本次介紹,主要關注實現策略、控制粒度及時間窗口問題,示例代碼中可能存在編碼不規範的情況,請忽略
1. 基於數據庫的統計進行限流
基於數據庫的統計進行限流主要思想是,將每次的信息連同時間寫入一條數據庫記錄,然後根據時間範圍統計信息,決策是否需要限流。舉例如下:
場景:
密碼1分鐘內輸入錯誤次數超限,凍結賬號。
要點:
1. 登錄失敗時,將用戶的登錄名、登錄時間寫入數據庫記錄。
2.登錄前根據登錄名,查詢近1分鐘登錄失敗的次數。
3.判斷登錄次數是否大於設定的超限值,如果大於則進行凍結處理,如果未超過,正常執行登錄判斷(登錄失敗時執行第一要點)。
代碼要點:
select count(登錄名) from 登錄錯誤記錄表 where 登錄名='登錄名' and 登錄時間 > DATE_SUB(NOW(), INTERVAL 1 MINUTE)
分析:
此方案的粒度控制在於代碼邏輯的堆砌和數據庫的設計,較爲靈活,時間窗口爲最近一分鐘(假定),屬於滑動時間窗口;由於時基於數據庫的,分佈式部署的情況下也能有效,不足之處在於會增加數據庫的壓力。
2. 基於redis自增長及過期策略的限流
基於redis自增長及過期策略主要思想是:在redis中維護一個key,並設置過期時間,每次控制前讀取該key,如果該key對應的值大於了限制值,則被限制,如果不大於,則通過redis的incr對key進行自增長處理,如果爲空,則重新維護key,並設置過期時間。如此往復達到限流目的。
場景:
對接口進行會話級別的訪問控制
要點
1. 讀取key,根據key對應值value的不同情況進行處理:
a. value不存在,設置set key 及過期時間
b. value大於限定值,限流
c. valuex不大於限定值,value自增長1
代碼要點:
涉及到的redis命令如下:
## 設置key的值5秒過期
set kkk 1 EX 5
### 獲取key的值
get kkk
### 自增長
incr kkk
### 手動設置過期時間
expire kkk 5
代碼示例
private boolean accessCheck(HttpSession session) {
String key = "ACCESS"+session.getId();
long current = 1;
// key不存在,進行設置
if( valueOperations.get(key) == null){
//一步到位
//valueOperations.set(key,1,5,TimeUnit.SECONDS);
// 也可以分兩步設置
valueOperations.increment(key);
valueOperations.getOperations().expire(key,5,TimeUnit.SECONDS);
}else {
// key已經存在了,自增+1
valueOperations.increment(key);
current = Long.parseLong(valueOperations.get(key));
}
if(current > 20){
throw new RuntimeException("訪問次數過於多");
}
return true;
}
分析:
基於redis同樣可以適用分佈式環境,時間窗口固定,因redis特性,可適應較大流量衝擊,控制粒度在於設計。
3. 基於內存(linkedlist爲例)的限流
基於內存的限流主要策略是在內存中維護一個時間窗口數據,基於對數據的統計進行限流。
場景:
對接口調用頻度進行控制
要點
1.維護一個以時間正序的列表(不是正序需要掃描全部數據)
2.去除時間窗口之外數據
3.末尾處加入最新時間
代碼要點:
以linkedlist爲例
private boolean accessCheckByLinkedList() {
synchronized(accessList) {
Date data = null;
Date now = new Date();
if(accessList.size() > 0){
data = accessList.getFirst();
while (data != null && now.getTime() - data.getTime() > 5000) {
accessList.removeFirst();
if(accessList.size() > 0){
data = accessList.getFirst();
}else {
data = null;
}
}
if (accessList.size() > 5) {
throw new RuntimeException( "訪問次數過於多");
}
}
accessList.add(new Date());
}
return true;
}
分析:
基於內存的限流,其粒度控制依賴於設計的是否高明,因爲實在內存中處理的,需要考慮線程間同步(採用同步的存儲如ConcurrentHashMap,可能不需要處理同步問題,但是要注意排序問題)及併發問題。控制粒度爲應用級別,能從應用層面控制當前訪問的壓力,避免負載均衡策略失效或異常情況下帶來的大流量衝擊。
4. 基於木桶算法的限流
基於木桶算法的限流策略主要是維護一個固定大小的“池”,根據場景消耗“池”的存量,並不斷的向內添加,但永遠維護“池”中存量不會超過最大值
場景:
應用級別接口請求頻度控制
要點
1.增加“池”中存量,存量達到最大值,暫停增加
2.使用“池”中存量,存量小於0限流
3.保證使用“池”不會溢出,也不會透支
代碼要點:
基於該策略,以下完整代碼描述了整個過程,其中的各頻度控制僅爲實驗效果而定。
public class BucketAlgorithmTest {
public static void main(String[] args) throws InterruptedException {
// 定時流入
new Thread(()-> {
while (true){
Bucket.add();
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
Thread.sleep(200);// 如果直接默認初始值爲滿的,此處可省略
// 定時流出,模擬控制域
new Thread(()->{
int errorcout = 0;
while (errorcout < 10) {
try {
Bucket.get();
Thread.sleep(40);
} catch (Exception e) {
errorcout ++;
e.printStackTrace();
try {
Thread.sleep(100);
}catch (Exception ee){
}
}
}
}).start();
}
}
class Bucket{
/**
* 當前存量
*/
private static Integer currentSize = 0;
/**
* 容量
*/
private final static int totalSize = 20;
/**
* 流入
*/
public static void add(){
synchronized (currentSize) {
if(currentSize < totalSize) {
currentSize++;
System.out.println("[添加++++]可用" + Bucket.getCurrentSize());
}
}
}
/**
* 流出
*/
public static void get(){
synchronized (currentSize) {
if(currentSize > 0) {
currentSize--;
System.out.println("[使用---]可用" + Bucket.getCurrentSize());
}else {
throw new RuntimeException("當前沒有剩餘連接可供使用");
}
}
}
public static Integer getCurrentSize(){
return currentSize;
}
}
爲方便理解,這裏給補充一張運行結果方便理解
分析:
木桶原理算法控制粒度可調性較大,依賴對算法原理的理解程度,在峯值大流量衝擊下,可能造成恢復時間增長(相對而言),該策略也類似是滑動“時間”窗口(其實滑動的是流量,這裏簡單理解爲滑動窗口吧)。該策略的數據既可以在內存中,也可以在數據庫中,也可以在redis等緩存中,使用場景也可以根據需要靈活設定,可應用級,也可以在負載層面作爲負載策略的擴充。
簡單總結對比一下
方案 | 時間窗口 | 基於 | 分佈式環境 | 應用範圍 |
---|---|---|---|---|
基於數據庫 | 滑動 | 數據庫 | 適用 | 登錄控制 |
基於Redis | 固定 | Redis | 適用 | 接口訪問控制 |
內存 | 滑動 | 內存 | 不適用 | 應用級別訪問控制 |
木桶算法 | 滑動 | 內存/Redis/數據庫 | 適用 | 根據應用位置而定 |