限流器系列(3)--自適應限流

關注HHF技術博客公眾號

漏斗桶/令牌桶確實能夠保護系統不被拖垮, 但不管漏斗桶還是令牌桶, 其防護思路都是設定一個指標, 當超過該指標后就阻止或減少流量的繼續進入,當系統負載降低到某一水平后則恢復流量的進入。但其通常都是被動的,其實際效果取決于限流閾值設置是否合理,但往往設置合理不是一件容易的事情.

項目日常維護中, 經常能夠看到某某同學在群里說:xx系統429了, 然后經過一番查找后發現是一波突然的活動流量, 只能申請再新增幾臺機器. 過了幾天 OP 發現該集群的流量達不到預期又下掉了幾臺機器, 然后又開始一輪新的循環.

這里先不討論集群自動伸縮的問題. 這里提出一些問題

  1. 集群增加機器或者減少機器限流閾值是否要重新設置?
  2. 設置限流閾值的依據是什么?
  3. 人力運維成本是否過高?
  4. 當調用方反饋429時, 這個時候重新設置限流, 其實流量高峰已經過了重新評估限流是否有意義?

這些其實都是采用漏斗桶/令牌桶的缺點, 總體來說就是太被動, 不能快速適應流量變化

自適應限流

對于自適應限流來說, 一般都是結合系統的 Load、CPU 使用率以及應用的入口 QPS、平均響應時間和并發量等幾個維度的監控指標,通過自適應的流控策略, 讓系統的入口流量和系統的負載達到一個平衡,讓系統盡可能跑在最大吞吐量的同時保證系統整體的穩定性。

比較出名的自適應限流的實現是 Alibaba Sentinel. 不過由于提前沒有發現 Sentinel 有個 golang 版本的實現, 本篇文章就以 Kratos 的 BBR 實現探討自適應限流的原理.

Kratos 自適應限流

借鑒了 Sentinel 項目的自適應限流系統, 通過綜合分析服務的 cpu 使用率、請求成功的 qps 和請求成功的 rt 來做自適應限流保護。

  • cpu: 最近 1s 的 CPU 使用率均值,使用滑動平均計算,采樣周期是 250ms
  • inflight: 當前處理中正在處理的請求數量
  • pass: 請求處理成功的量
  • rt: 請求成功的響應耗時

限流公式

cpu > 800 AND (Now - PrevDrop) < 1s AND (MaxPass * MinRt * windows / 1000) < InFlight

  • MaxPass 表示最近 5s 內,單個采樣窗口中最大的請求數
  • MinRt 表示最近 5s 內,單個采樣窗口中最小的響應時間
  • windows 表示一秒內采樣窗口的數量,默認配置中是 5s 50 個采樣,那么 windows 的值為 10

kratos 中間件實現

func (b *RateLimiter) Limit() HandlerFunc {
    return func(c *Context) {
        uri := fmt.Sprintf("%s://%s%s", c.Request.URL.Scheme, c.Request.Host, c.Request.URL.Path)
        limiter := b.group.Get(uri)
        done, err := limiter.Allow(c)
        if err != nil {
            _metricServerBBR.Inc(uri, c.Request.Method)
            c.JSON(nil, err)
            c.Abort()
            return
        }
        defer func() {
            done(limit.DoneInfo{Op: limit.Success})
            b.printStats(uri, limiter)
        }()
        c.Next()
    }
}

使用方式

e := bm.DefaultServer(nil)
limiter := bm.NewRateLimiter(nil)
e.Use(limiter.Limit())
e.GET("/api", myHandler)

源碼實現

Allow

func (l *BBR) Allow(ctx context.Context, opts ...limit.AllowOption) (func(info limit.DoneInfo), error) {
    allowOpts := limit.DefaultAllowOpts()
    for _, opt := range opts {
        opt.Apply(&allowOpts)
    }
    if l.shouldDrop() { // 判斷是否觸發限流
        return nil, ecode.LimitExceed
    }
    atomic.AddInt64(&l.inFlight, 1) // 增加正在處理請求數
    stime := time.Since(initTime) // 記錄請求到來的時間
    return func(do limit.DoneInfo) {
        rt := int64((time.Since(initTime) - stime) / time.Millisecond) // 請求處理成功的響應時長
        l.rtStat.Add(rt) // 增加rtStat響應耗時的統計
        atomic.AddInt64(&l.inFlight, -1) // 請求處理成功后, 減少正在處理的請求數
        switch do.Op {
        case limit.Success:
            l.passStat.Add(1) // 處理成功后增加成功處理請求數的統計
            return
        default:
            return
        }
    }, nil
}

shouldDrop

func (l *BBR) shouldDrop() bool {
    // 判斷目前cpu的使用率是否達到設置的CPU的限制, 默認值800
    if l.cpu() < l.conf.CPUThreshold { 
        // 如果上一次舍棄請求的時間是0, 那么說明沒有限流的需求, 直接返回
        prevDrop, _ := l.prevDrop.Load().(time.Duration)
        if prevDrop == 0 {
            return false
        }
        // 如果上一次請求的時間與當前的請求時間小于1s, 那么說明有限流的需求
        if time.Since(initTime)-prevDrop <= time.Second {
            if atomic.LoadInt32(&l.prevDropHit) == 0 {
                atomic.StoreInt32(&l.prevDropHit, 1)
            }
            // 增加正在處理的請求的數量
            inFlight := atomic.LoadInt64(&l.inFlight)
            // 判斷正在處理的請求數是否達到系統的最大的請求數量
            return inFlight > 1 && inFlight > l.maxFlight()
        }
        // 清空當前的prevDrop
        l.prevDrop.Store(time.Duration(0))
        return false
    }
    // 增加正在處理的請求的數量
    inFlight := atomic.LoadInt64(&l.inFlight)
    // 判斷正在處理的請求數是否達到系統的最大的請求數量
    drop := inFlight > 1 && inFlight > l.maxFlight()
    if drop {
        prevDrop, _ := l.prevDrop.Load().(time.Duration)
        // 如果判斷達到了最大請求數量, 并且當前有限流需求
        if prevDrop != 0 {
            return drop
        }
        l.prevDrop.Store(time.Since(initTime))
    }
    return drop
}

maxFlight

該函數是核心函數. 其計算公式: MaxPass * MinRt * windows / 1000. maxPASS/minRT都是基于metric.RollingCounter來實現的, 限于篇幅原因這里就不再具體看其實現(想看的可以去看rolling_counter_test.go還是蠻容易理解的)

func (l *BBR) maxFlight() int64 {
    return int64(math.Floor(float64(l.maxPASS()*l.minRT()*l.winBucketPerSec)/1000.0 + 0.5))
}
  • winBucketPerSec: 每秒內的采樣數量,其計算方式:int64(time.Second)/(int64(conf.Window)/int64(conf.WinBucket)), conf.Window默認值10s, conf.WinBucket默認值100. 簡化下公式: 1/(10/100) = 10, 所以每秒內的采樣數就是10
// 單個采樣窗口在一個采樣周期中的最大的請求數, 默認的采樣窗口是10s, 采樣bucket數量100
func (l *BBR) maxPASS() int64 {
    rawMaxPass := atomic.LoadInt64(&l.rawMaxPASS)
    if rawMaxPass > 0 && l.passStat.Timespan() < 1 {
        return rawMaxPass
    }
    // 遍歷100個采樣bucket, 找到采樣bucket中最大的請求數
    rawMaxPass = int64(l.passStat.Reduce(func(iterator metric.Iterator) float64 {
        var result = 1.0
        for i := 1; iterator.Next() && i < l.conf.WinBucket; i++ {
            bucket := iterator.Bucket()
            count := 0.0
            for _, p := range bucket.Points {
                count += p
            }
            result = math.Max(result, count)
        }
        return result
    }))
    if rawMaxPass == 0 {
        rawMaxPass = 1
    }
    atomic.StoreInt64(&l.rawMaxPASS, rawMaxPass)
    return rawMaxPass
}

// 單個采樣窗口中最小的響應時間
func (l *BBR) minRT() int64 {
    rawMinRT := atomic.LoadInt64(&l.rawMinRt)
    if rawMinRT > 0 && l.rtStat.Timespan() < 1 {
        return rawMinRT
    }
    // 遍歷100個采樣bucket, 找到采樣bucket中最小的響應時間
    rawMinRT = int64(math.Ceil(l.rtStat.Reduce(func(iterator metric.Iterator) float64 {
        var result = math.MaxFloat64
        for i := 1; iterator.Next() && i < l.conf.WinBucket; i++ {
            bucket := iterator.Bucket()
            if len(bucket.Points) == 0 {
                continue
            }
            total := 0.0
            for _, p := range bucket.Points {
                total += p
            }
            avg := total / float64(bucket.Count)
            result = math.Min(result, avg)
        }
        return result
    })))
    if rawMinRT <= 0 {
        rawMinRT = 1
    }
    atomic.StoreInt64(&l.rawMinRt, rawMinRT)
    return rawMinRT
}

參考

  1. alibaba/Sentinel-系統自適應限流
  2. go-kratos/kratos-自適應限流保護
  3. alibaba/sentinel-golang-系統自適應流控
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,363評論 6 532
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,497評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,305評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,962評論 1 311
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,727評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,193評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,257評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,411評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,945評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,777評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,978評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,519評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,216評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,642評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,878評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,657評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,960評論 2 373