Go 分布式令牌桶限流 + 兜底策略

上篇文章提到固定時間窗口限流無法處理突然請求洪峰情況,本文講述的令牌桶線路算法則可以比較好的處理此場景睦番。

工作原理

  1. 單位時間按照一定速率勻速的生產(chǎn) token 放入桶內(nèi)类茂,直到達到桶容量上限。
  2. 處理請求托嚣,每次嘗試獲取一個或多個令牌巩检,如果拿到則處理請求,失敗則拒絕請求示启。
image

優(yōu)缺點

優(yōu)點

可以有效處理瞬間的突發(fā)流量兢哭,桶內(nèi)存量 token 即可作為流量緩沖區(qū)平滑處理突發(fā)流量。

缺點

實現(xiàn)較為復(fù)雜夫嗓。

代碼實現(xiàn)

core/limit/tokenlimit.go

分布式環(huán)境下考慮使用 redis 作為桶和令牌的存儲容器迟螺,采用 lua 腳本實現(xiàn)整個算法流程冲秽。

redis lua 腳本

-- 每秒生成token數(shù)量即token生成速度
local rate = tonumber(ARGV[1])
-- 桶容量
local capacity = tonumber(ARGV[2])
-- 當(dāng)前時間戳
local now = tonumber(ARGV[3])
-- 當(dāng)前請求token數(shù)量
local requested = tonumber(ARGV[4])
-- 需要多少秒才能填滿桶
local fill_time = capacity/rate
-- 向下取整,ttl為填滿時間的2倍
local ttl = math.floor(fill_time*2)
-- 當(dāng)前時間桶容量
local last_tokens = tonumber(redis.call("get", KEYS[1]))
-- 如果當(dāng)前桶容量為0,說明是第一次進入,則默認容量為桶的最大容量
if last_tokens == nil then
last_tokens = capacity
end
-- 上一次刷新的時間
local last_refreshed = tonumber(redis.call("get", KEYS[2]))
-- 第一次進入則設(shè)置刷新時間為0
if last_refreshed == nil then
last_refreshed = 0
end
-- 距離上次請求的時間跨度
local delta = math.max(0, now-last_refreshed)
-- 距離上次請求的時間跨度,總共能生產(chǎn)token的數(shù)量,如果超多最大容量則丟棄多余的token
local filled_tokens = math.min(capacity, last_tokens+(delta*rate))
-- 本次請求token數(shù)量是否足夠
local allowed = filled_tokens >= requested
-- 桶剩余數(shù)量
local new_tokens = filled_tokens
-- 允許本次token申請,計算剩余數(shù)量
if allowed then
new_tokens = filled_tokens - requested
end
-- 設(shè)置剩余token數(shù)量
redis.call("setex", KEYS[1], ttl, new_tokens)
-- 設(shè)置刷新時間
redis.call("setex", KEYS[2], ttl, now)

return allowed

令牌桶限流器定義

type TokenLimiter struct {
    // 每秒生產(chǎn)速率
    rate int
    // 桶容量
    burst int
    // 存儲容器
    store *redis.Redis
    // redis key
    tokenKey       string
    // 桶刷新時間key
    timestampKey   string
    // lock
    rescueLock     sync.Mutex
    // redis健康標(biāo)識
    redisAlive     uint32
    // redis故障時采用進程內(nèi) 令牌桶限流器
    rescueLimiter  *xrate.Limiter
    // redis監(jiān)控探測任務(wù)標(biāo)識
    monitorStarted bool
}

func NewTokenLimiter(rate, burst int, store *redis.Redis, key string) *TokenLimiter {
    tokenKey := fmt.Sprintf(tokenFormat, key)
    timestampKey := fmt.Sprintf(timestampFormat, key)

    return &TokenLimiter{
        rate:          rate,
        burst:         burst,
        store:         store,
        tokenKey:      tokenKey,
        timestampKey:  timestampKey,
        redisAlive:    1,
        rescueLimiter: xrate.NewLimiter(xrate.Every(time.Second/time.Duration(rate)), burst),
    }
}

獲取令牌

image
func (lim *TokenLimiter) reserveN(now time.Time, n int) bool {
    // 判斷redis是否健康
    // redis故障時采用進程內(nèi)限流器
    // 兜底保障
    if atomic.LoadUint32(&lim.redisAlive) == 0 {
        return lim.rescueLimiter.AllowN(now, n)
    }
    // 執(zhí)行腳本獲取令牌
    resp, err := lim.store.Eval(
        script,
        []string{
            lim.tokenKey,
            lim.timestampKey,
        },
        []string{
            strconv.Itoa(lim.rate),
            strconv.Itoa(lim.burst),
            strconv.FormatInt(now.Unix(), 10),
            strconv.Itoa(n),
        })
    // redis allowed == false
    // Lua boolean false -> r Nil bulk reply
    // 特殊處理key不存在的情況
    if err == redis.Nil {
        return false
    } else if err != nil {
        logx.Errorf("fail to use rate limiter: %s, use in-process limiter for rescue", err)
        // 執(zhí)行異常,開啟redis健康探測任務(wù)
        // 同時采用進程內(nèi)限流器作為兜底
        lim.startMonitor()
        return lim.rescueLimiter.AllowN(now, n)
    }

    code, ok := resp.(int64)
    if !ok {
        logx.Errorf("fail to eval redis script: %v, use in-process limiter for rescue", resp)
        lim.startMonitor()
        return lim.rescueLimiter.AllowN(now, n)
    }

    // redis allowed == true
    // Lua boolean true -> r integer reply with value of 1
    return code == 1
}

redis 故障時兜底策略

兜底策略的設(shè)計考慮得非常細節(jié)煮仇,當(dāng) redis 不可用的時候劳跃,啟動單機版的 ratelimit 做備用限流,確闭愕妫基本的限流可用刨仑,服務(wù)不會被沖垮。

// 開啟redis健康探測
func (lim *TokenLimiter) startMonitor() {
    lim.rescueLock.Lock()
    defer lim.rescueLock.Unlock()
    // 防止重復(fù)開啟
    if lim.monitorStarted {
        return
    }

    // 設(shè)置任務(wù)和健康標(biāo)識
    lim.monitorStarted = true
    atomic.StoreUint32(&lim.redisAlive, 0)
    // 健康探測
    go lim.waitForRedis()
}

// redis健康探測定時任務(wù)
func (lim *TokenLimiter) waitForRedis() {
    ticker := time.NewTicker(pingInterval)
    // 健康探測成功時回調(diào)此函數(shù)
    defer func() {
        ticker.Stop()
        lim.rescueLock.Lock()
        lim.monitorStarted = false
        lim.rescueLock.Unlock()
    }()

    for range ticker.C {
        // ping屬于redis內(nèi)置健康探測命令
        if lim.store.Ping() {
            // 健康探測成功夹姥,設(shè)置健康標(biāo)識
            atomic.StoreUint32(&lim.redisAlive, 1)
            return
        }
    }
}

項目地址

https://github.com/zeromicro/go-zero

歡迎使用 go-zerostar 支持我們杉武!

微信交流群

關(guān)注『微服務(wù)實踐』公眾號并點擊 交流群 獲取社區(qū)群二維碼。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末辙售,一起剝皮案震驚了整個濱河市轻抱,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌旦部,老刑警劉巖祈搜,帶你破解...
    沈念sama閱讀 216,372評論 6 498
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異士八,居然都是意外死亡容燕,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,368評論 3 392
  • 文/潘曉璐 我一進店門婚度,熙熙樓的掌柜王于貴愁眉苦臉地迎上來蘸秘,“玉大人,你說我怎么就攤上這事蝗茁〈茁玻” “怎么了?”我有些...
    開封第一講書人閱讀 162,415評論 0 353
  • 文/不壞的土叔 我叫張陵哮翘,是天一觀的道長颈嚼。 經(jīng)常有香客問我,道長饭寺,這世上最難降的妖魔是什么阻课? 我笑而不...
    開封第一講書人閱讀 58,157評論 1 292
  • 正文 為了忘掉前任,我火速辦了婚禮佩研,結(jié)果婚禮上柑肴,老公的妹妹穿的比我還像新娘霞揉。我一直安慰自己旬薯,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 67,171評論 6 388
  • 文/花漫 我一把揭開白布适秩。 她就那樣靜靜地躺著绊序,像睡著了一般硕舆。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上骤公,一...
    開封第一講書人閱讀 51,125評論 1 297
  • 那天抚官,我揣著相機與錄音,去河邊找鬼阶捆。 笑死凌节,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的洒试。 我是一名探鬼主播倍奢,決...
    沈念sama閱讀 40,028評論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼垒棋!你這毒婦竟也來了卒煞?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 38,887評論 0 274
  • 序言:老撾萬榮一對情侶失蹤叼架,失蹤者是張志新(化名)和其女友劉穎畔裕,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體乖订,經(jīng)...
    沈念sama閱讀 45,310評論 1 310
  • 正文 獨居荒郊野嶺守林人離奇死亡扮饶,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,533評論 2 332
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了垢粮。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片贴届。...
    茶點故事閱讀 39,690評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖蜡吧,靈堂內(nèi)的尸體忽然破棺而出毫蚓,到底是詐尸還是另有隱情,我是刑警寧澤昔善,帶...
    沈念sama閱讀 35,411評論 5 343
  • 正文 年R本政府宣布元潘,位于F島的核電站,受9級特大地震影響君仆,放射性物質(zhì)發(fā)生泄漏翩概。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,004評論 3 325
  • 文/蒙蒙 一返咱、第九天 我趴在偏房一處隱蔽的房頂上張望钥庇。 院中可真熱鬧,春花似錦咖摹、人聲如沸评姨。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,659評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽吐句。三九已至胁后,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間嗦枢,已是汗流浹背攀芯。 一陣腳步聲響...
    開封第一講書人閱讀 32,812評論 1 268
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留文虏,地道東北人侣诺。 一個月前我還...
    沈念sama閱讀 47,693評論 2 368
  • 正文 我出身青樓,卻偏偏與公主長得像氧秘,于是被迫代替她去往敵國和親紧武。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,577評論 2 353

推薦閱讀更多精彩內(nèi)容