系統限流
最近在系統中對一個資源訪問做了限流,寫篇文章聊聊這個話題。
爲什麼要限流
在衡量Web系統性能的指標中,最重要的參數是極限QPS,它表示系統每秒最多能夠處理的Query數。從定義中可以知道QPS是一個描述系統處理速度的指標,當存在需要處理的流量(Query數)使得系統即使以極限QPS處理也無法在短時間內處理完所有流量,則稱這個段時間內系統處於承壓狀態。
當系統處於承壓狀態,容易理解超出系統能力的那部分流量無法正常處理,可系統能力範圍內的那部分流量也無法正常處理則比較反直覺。
假設某API的極限QPS是200,即理論上一分鐘可以處理的Query數爲12000(200 x 60),如果在持續一分鐘的時間內每秒收到300個Query,原以爲系統依舊能處理12000個Query,只有6000個Query無法處理,實際情況可能是前10s處理2000個Query後系統就處於異常狀態,接着其他Query都無法在正常時間內得到處理
由於系統承壓導致原本1分鐘可以處理12000個Query驟降爲2000,更致命的是接着的幾分鐘甚至幾十分鐘內系統QPS將持續保持一個相對較低的值,甚至系統完全無法正常運行,這種性能表現模式對大部分系統都比較常見,原因在後一小節分析。
大部分系統無法長時間處於高壓狀態,一旦承壓程度超過一定的閥值,系統性能表現會急劇下降,而爲了解決這一問題所普遍採用的方案是在系統中實現限流策略,始終保持系統壓力處於合理範圍從而讓系統整體效率最高。
QPS/TPS tips:
QPS全稱是Query Per Second,TPS則是Transaction Per Second,中文可以翻譯成每秒查詢數與每秒事務數,但查詢與事務兩者的定義用來形容Web Server的request與response都不是完美契合,可概念相似,即我們可以把對Web Server的QPS描述理解成每秒能夠完成的request與response數量,後文也就使用Query指代Request與Response
承壓導致性能驟降的原因
系統承壓會導致性能表現下降嚴重甚至無法提供服務,導致這一切的原因是由於一定時間內用來達成目標的可用資源(CPU、內存、帶寬、IO等)不足進而出現相互爭奪資源的情況,最終導致目標一直無法達成,且爭奪過程本身也會對資源造成額外的負擔。
舉個例子,假設某CPU每秒可提供的計算能力用數值100表示,某類型任務1s完成需要的計算能力爲1,那麼此CPU每秒最多完成100個該任務,而當CPU每秒收到200個任務時:
- 1s後,100算力平分給200個任務,每個任務完成度是0.5
- 2s後,100算力平分給400個任務(1s:200 + 2s:200),1s:200完成度0.75,2s:200完成度0.5
- 3s後,100算力平分給600個任務(1s:200 + 2s:200 + 3s:200),1s:200完成度0.91,2s:200完成度0.66,3s:200完成度0.16
- 4s後,100算力平分給800個任務(…),1s:200全部完成,後幾秒的任務完成度依舊小於1
- 繼續一段時間後發現任務越來越難完成,從外部看CPU雖然滿負荷運行但已經完全無法完成任務
100算力每次都能平分給所有任務只是理想模型,現實情況是任務之間爭奪CPU有額外開銷,100個任務爭奪100算力的CPU也許每個任務只能得到0.8,而200個任務爭奪100算力可能導致每個任務只有0.3。
上述CPU例子中,如果外部任務數下降後系統能夠恢復正常,勉強能接受,可現實情況往往是系統的部分承壓會牽連其他部分。比如上訴的任務其實是HTTP請求,由於CPU資源不夠導致系統堆積了1萬請求,那這1萬個請求涉及的內存需求能否被滿足?1萬請求的內存需求可以滿足,10萬呢?如果不能很好的解決承壓問題,系統遲早被拖垮。
限流場景
通過前面兩節已經明白限流的原因是爲了避免系統過載,但沒有明確主體,即誰保護誰。第一個“誰”默認是當前系統,而針對第二個“誰”我認爲有兩種選擇:被依賴方、自己。
保護被依賴方
保護被依賴方指的是作爲調用方控制自己調用被依賴方的頻率,不讓因自己調用產生的壓力超過被依賴方極限承壓能力,用可持續發展的方式讓被依賴方持續提供服務。
除了保護被依賴方的正常運行,這麼做還可以保護自己,假設自己是一個Web Server,向外提供的API接口平均QPS是1000,這其中有一個接口依賴的第三方服務QPS只有100,如果我們不保護這個第三方服務,當其承壓時可能會拖垮自己,因此需要在這個接口內部保護此第三方服務讓其不處於承壓狀態。
上訴場景中保護第三方服務的目的是避免拖垮自己,但也有其他更直接原因:
- 第三方服務缺少自我保護能力,這導致第三方服務無法在承壓狀態正常工作
- 第三方服務的QPS與自己差距較大,如果自己能支持QPS也只有100,保護該第三方就沒多大必要
在寫這篇文章的同時,經過思考認爲大部分情況下保護被依賴方是一個僞需求,主要原因如下:
- 爲了保護自己而保護被依賴方可轉換成直接保護自己
原本能支持1000QPS的接口受到被依賴方的QPS限制只能支持100QPS,可以由保護被依賴方變成保護自己,即人爲控制自己系統中該接口的調用QPS不超過100,間接也對被依賴方產生保護效果
- 被依賴方不確定會被哪些以及多少依賴方依賴,從而使得對被依賴方的保護措施是做無用功
假設存在極限QPS爲200的系統A,而它被B、C系統依賴,哪怕B、C系統爲A做了保護措施也不一定能避免A承壓異常,因爲B、C只能控制自己調用A的QPS不超過200,但兩者加起來卻可能達到400
- 被依賴方實現自我保護是上策
一個被依賴方被多方依賴,如果堅持讓依賴方做流量保護,那麼這些依賴方之間會增加耦合度,因爲它們一定存在共享內容,比如上述B、C依賴A的例子,B、C爲保護A的QPS不超過200,它們之間肯定會共享A的QPS信息,這種做法導致B、C系統之間增加了耦合度,所以被依賴方自我保護是上策
保護自己
保護被依賴方是一個不被推薦的方式,保護自己則是比較可取的方式,保護自己的目的是確保系統處於一定承壓狀態時依舊可正常運行。
假設系統某接口在沒做限流保護時的極限QPS是200,緊接着的一分鐘系統面臨QPS壓力保持在300,未做限流保護時,系統這一分鐘內能完成的Query數遠小於12000,而通過限流保護可使系統在這一分鐘內能夠完成的Query數儘可能接近12000,另外的6000丟棄或者排隊。
這種自我限流保護可以讓系統在承壓狀態保持正常運行不至於癱瘓,一旦壓力緩和,系統隨即恢復正常。
自我限流保護的基本原理是控制爭奪資源的Query數不超過極限值,且實施控制的成本要儘可能的小,比如一個沒加限流控制邏輯的接口極限QPS是200,增加了限流控制後極限QPS變爲198,這幾乎沒影響。
限流的兩個維度
我認爲限流可以從兩個主要維度考慮,分別是速率與併發,它們與前文反覆提到的QPS有很大聯繫。QPS100是指每秒可以處理100個Query,但並未說明併發數與平均處理時間,100QPS可以理解成併發爲1時每個Query處理花費10ms,也可以理解成併發爲2時每個Query處理花費20ms,又或者是併發爲100時每個Query處理花費1s。
上述3種併發與平均處理時間的假設關係不一定成立,一般而言系統極限QPS是指系統在某個合理範圍內的併發下能得到的最大值,當併發數超過或者低於該範圍時QPS都會下降,因爲併發數減少會導致資源利用不完全,因此處理時間不會線性縮短,而併發數的增加會導致資源不夠用,從而處理時間變長。
併發數、平均處理時長和QPS,由於平均處理時長受限於具體的業務邏輯,無法直接控制與優化,故我們從併發數與QPS(速率)着手進行限流。
速率
限制速率是控制系統的實際QPS,假設系統某API的極限QPS是100,速率控制可以在入口處保證每秒最多准入90個Query,超出部分丟棄或排隊。如果沒有異常情況,被准入的90個Query都應該能在這1秒內被處理完,因此係統不會堆積處理中的Query,也就不會拖垮系統。
併發
光有上述速率限制還不夠可靠,因爲一些不可預知的外部因素可能導致系統可用資源波動。緊接前面速率的例子,原本系統CPU的100算力都會被用於處理准入的90個Query,所以都能在1秒內處理完,但這時系統中出現一個其他任務爭奪CPU,它持續30秒佔用了50算力,此情況下入口處持續每秒准入90個Query,由於算力只有之前的一半,這被准入的90個Query在1秒後完成度只有0.5,下一秒又來了90個Query,最終系統這個接口將處於癱瘓狀態,且不確定會不會引發其它問題,也不確定多久能恢復。
爲避免上述問題,我們需要在速率的背後加上一道併發限制的關卡,速率限制1秒內准入90個Query,接着併發限制依據前1秒准入的90個Query完成情況決定這次放行多少,這樣可以緩解系統承壓狀態。
速率VS併發
從上面速率與併發的討論中,可以知道只有速率控制不足以規避風險,那隻用併發控制是否可以達成目標?答案是也不可靠,因爲併發控制無法很好的處理瞬時併發流量,而速率控制可以有效平衡瞬時併發流量的時間分佈。
針對QPS1000的系統接口做速率控制,入口每秒准入1000個Query,但准入的細節前面並沒有提及,可以是一秒開始的瞬間把1000個Query放進去,也可以是每1毫秒放入一個Query,這兩種不同的策略對系統的運行產生的影響相差甚大,因爲瞬間准入1000個Query可能將系統直接擊垮,細水長流的方式則不會讓系統有任何波瀾。
只做速率控制無法良好應對資源抖動,只做併發控制無法很好處理瞬時流量,所以一個追求極致的系統應該是這兩道保險措施都加上。
我個人認爲速率控制可以保守一點,即比極限QPS小,而併發控制可以激進一些,並且當實際流量達到到併發控制的閥值時,系統可以實施一些其它策略,比如暫停准入一小段時間。
限流算法
爲了實現前面的速率與併發控制,我們需要一個合適且性能優秀的算法,這裏主要介紹漏桶與令牌桶算法,
漏桶
圖片來源阿里某博客
漏桶算法與令牌桶算法都是形象易理解的,漏桶算法是不管外部流量以何種併發與速率到達系統,都把它們暫存在一個“桶”中,同時這個桶以恆定速率往外流出流量,桶滿的時候丟棄或排隊到達系統的流量,用漏桶比喻這個過程非常形象和貼切。
令牌桶
圖片來源阿里某博客
漏桶算法在任何情況下都保證流量以恆定速率准入,但這不一定是最佳,假設某API使用漏桶實現限流且支持的QPS爲100,某瞬間有20個Query到達系統需要被處理,漏桶會保持每10ms准入一個Query,假設此情況下每個Query處理完成的平均時間爲200ms,則這20個Query共花費6100ms,計算方式是20個Query的等待時間(10 + 20 + 30 + … + 200 = 2100)加上執行時間(200 * 20 = 4000),實際上我們可以同時准入20個Query,這時每個Query的平均響應也許由200ms變爲220ms,但20個Query最終花費的時間只有4400ms,通過該優化我們讓用戶節省了30%的等待時間。
令牌桶就是可以滿足上述場景中的期望的算法,允許一定量的瞬時併發同時又限制整體速率。令牌桶算法單獨維護一個令牌桶,桶中初始令牌數爲b(burst),每次准入必須消耗一個令牌,同時會有人以恆定速率往桶中添加令牌且保持令牌數小於等於b。
針對前面的100QPS希望瞬時准入支持20的場景,我們可以藉助一個burst=20且令牌添加速率爲10ms/個的令牌桶實現,當系統瞬間收到100Query的行爲是立即准入20個,隨後每10ms准入一個。
限流的實現
解釋完漏桶和令牌桶限流算法後,我們用代碼來實現一番,主要包含兩段代碼:
- 藉助Redis實現分佈式令牌桶算法做速率限制
雖然在令牌桶算法中把加令牌操作描述爲由“另外一個人”完成,可此方式必須依賴定時器且效率低下,所以這裏採用的是一個優化方案:“將加令牌操作合併在取令牌操作中”
- 模仿計算機PV原語實現併發限制
把允許的併發數作爲資源S看待
P(S)表示申請一個資源,S減1,若減1後S>=0則准入;若減1後S<0,表示已無資源可用,將自己放入阻塞隊列
V(S)表示釋放一個資源,S加1,若加1後S<=0,從阻塞隊列上取第一個准入
令牌桶限制速率
以下是一個用於速率限流的令牌桶模塊示例,代碼主要爲了傳達核心邏輯,但展現完整的速率限流方案。
// rateLimit.js
const _ = require('lodash');
const BB = require('bluebird');
const lib = require(process.env.lib);
const redis = lib.redis.createClient();
const keyPrefix = 'lib:rate-limit';
const id2Limit = {};
// 在Redis中用hset存儲了一個對象{rate, burst, permits, last_ms}數據
// 令牌桶的核心邏輯在luaScript中,每次eval調用即申請ARGV[1]個令牌
const luaScript = `
local apply_permits = tonumber(ARGV[1])
local current_ms = tonumber(ARGV[2])
local rate_limit = redis.call("HMGET", KEYS[1], "burst", "rate", "permits", "last_ms")
local burst = tonumber(rate_limit[1])
local rate = tonumber(rate_limit[2])
local permits = tonumber(rate_limit[3])
local last_ms = tonumber(rate_limit[4])
local reverse_permits = math.floor(((current_ms - last_ms) / 1000) * rate)
local remaining_permits = math.min(reverse_permits + permits, burst)
if (reverse_permits > 0) then
redis.call("HSET", KEYS[1], "last_ms", current_ms)
end
local result = 0
if (remaining_permits - apply_permits >= 0) then
result = apply_permits
redis.call("HSET", KEYS[1], "permits", remaining_permits - apply_permits)
else
redis.call("HSET", KEYS[1], "permits", remaining_permits)
end
return result
`;
exports.init = init; // 初始化令牌桶
exports.applyPermits = applyPermits; // 申請令牌
exports.waitPermits = waitPermits; // 等待令牌,在超時時間內自動重試申請令牌
async function init(id, rate, burst) {
let key = `${keyPrefix}:${id}`; // redis中速率控制的key
let args = _.flatten(_.toPairs({
rate, // 令牌桶添加令牌的速率(個/s),計算時採用毫秒
burst, // 令牌桶最大令牌數,決定瞬時併發數
permits: burst, // 令牌桶中當前的令牌
last_ms: Date.now() // 最後一次添加令牌的毫秒時間戳
}));
await redis.hmsetAsync(key, ...args);
id2Limit[id] = {
rate
};
}
async function applyPermits(id, count = 1) {
let key = `${keyPrefix}:${id}`;
return await redis.evalAsync(luaScript, 1, key, count, Date.now()) === count;
}
async function waitPermits(id, count = 1, timeout) {
if (_.isEmpty(id2Limit[id])) {
throw new Error(`rate limit not initialized id:${id}`);
}
let key = `${keyPrefix}:${id}`;
let {
rate
} = id2Limit[id];
return await new BB(async(resolve, reject) => {
let hasPermits = false;
let timeouted = false;
// 等待令牌超時
setTimeout(() => {
resolve(false);
timeouted = true;
}, timeout);
try {
do {
// 在此處申請令牌過程中恰好超時,會導致count個令牌浪費
hasPermits = await applyPermits(id, count);
if (!hasPermits) {
// 未申請到令牌,隨機延遲一定時間後再嘗試申請
await _sleep(Math.floor(1000 / rate + Math.random() * 200));
}
}
while (!hasPermits && !timeouted);
if (hasPermits && !timeouted) {
resolve(true);
}
}
catch (err) {
reject(err);
}
});
// 模擬sleep
async function _sleep(ms) {
return await new BB((resolve) => {
setTimeout(() => resolve(), ms);
});
}
}
PV限制併發
以下是一個用於限制fn併發調用數的PV代碼示例,代碼主要爲了傳達核心邏輯,但不是完整的併發限流方案。
const _ = require('lodash');
const BB = require('bluebird');
const lib = require(process.env.lib);
const redis = lib.redis.createClient();
const keyPrefix = 'lib:invoke-limit';
const id2Item = {};
exports.init = init; // 初始化id對應的併發數(資源數)
exports.invoke = invoke; // 某id對應的一次調用
async function init(id, concurrency) {
let key = `${keyPrefix}:${id}`;
await redis.setAsync(key, concurrency); // 此處concurrency即資源S
id2Item[id] = {
queue: []
};
}
async function invoke(id, timeout, fn) {
if (_.isEmpty(id2Item[id])) {
throw new Error(`invoke limit not initialized id:${id}`);
}
let {
queue
} = id2Item[id];
let fulfiled = false;
try {
return await new BB(async(resolve, reject) => {
try {
if (await _P(id) >= 0) {
return resolve(await fn());
}
queue.push(async() => {
try {
// 假設在被wakeup前已經超時,不應該執行fn
!fulfiled && resolve(await fn());
}
catch (err) {
reject(err);
}
});
setTimeout(() => {
reject(new Error(`overload id:${id}`));
}, timeout);
}
catch (err) {
reject(err);
}
});
}
finally {
fulfiled = true;
if (await _V(id) <= 0) {
let wakeup = queue.shift(); // 從等待隊列首部進行喚醒
wakeup && await wakeup();
}
}
}
// 此處是id對應的資源S執行P操作
async function _P(id) {
let key = `${keyPrefix}:${id}`;
return await redis.incrbyAsync(key, -1);
}
// 此處是id對應的資源S執行V操作
async function _V(id) {
let key = `${keyPrefix}:${id}`;
return await redis.incrbyAsync(key, 1);
}