系統限流

系統限流

最近在系統中對一個資源訪問做了限流,寫篇文章聊聊這個話題。

爲什麼要限流

在衡量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);
}

博客原文

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