golang限流庫兩個大bug(半年之久無人提起)
uber-go/ratelimit 庫
我先前都是使用juju/ratelimit[2]這個限流庫的,不過我不太喜歡這個庫的復(fù)雜的“構(gòu)造函數(shù)”,后來嘗試了uber-go/ratelimit[3]這個庫后,感覺 SDK 設(shè)計比較簡單,而且使用起來也不錯,就一直使用了。當時的版本是v0.2.0,而且我也不會設(shè)置它的slack參數(shù),所以也相安無事。
最近我同事在做項目的時候,把這個庫更新到最新的v0.3.0,發(fā)現(xiàn)在發(fā)包一段時間后,突然限流不起作用了,發(fā)包頻率狂飆導(dǎo)致程序 panic。
通過單元測試復(fù)現(xiàn)
很容易通過下面一個單元測試復(fù)現(xiàn)這個問題:
func TestLimiter(t *testing.T) {
limiter := ratelimit.New(1, ratelimit.Per(time.Second), ratelimit.WithSlack(1))
for i := 0; i < 25; i++ {
if i == 1 {
time.Sleep(2 * time.Second)
}
limiter.Take()
fmt.Println(time.Now().Unix(), i) // burst
}
}slack 的判斷邏輯出現(xiàn)問題
這個單元測試嘗試在第二個周期中不調(diào)用限流器,讓它有機會進入 slack 判斷的邏輯。這個庫的 slack 設(shè)計的本意是在 rate 的基礎(chǔ)上留一點余地,不那么嚴格按照 rate 進行限流,不過因為v0.3.0代碼的問題,導(dǎo)致 slack 的判斷邏輯出現(xiàn)了問題:
func (t *atomicInt64Limiter) Take() time.Time {
var (
newTimeOfNextPermissionIssue int64
now int64
)
for {
now = t.clock.Now().UnixNano()
timeOfNextPermissionIssue := atomic.LoadInt64(&t.state)
switch {
case timeOfNextPermissionIssue == 0 || (t.maxSlack == 0 && now-timeOfNextPermissionIssue > int64(t.perRequest)):
// if this is our first call or t.maxSlack == 0 we need to shrink issue time to now
newTimeOfNextPermissionIssue = now
case t.maxSlack > 0 && now-timeOfNextPermissionIssue > int64(t.maxSlack):
// a lot of nanoseconds passed since the last Take call
// we will limit max accumulated time to maxSlack
newTimeOfNextPermissionIssue = now - int64(t.maxSlack)
default:
// calculate the time at which our permission was issued
newTimeOfNextPermissionIssue = timeOfNextPermissionIssue + int64(t.perRequest)
}
if atomic.CompareAndSwapInt64(&t.state, timeOfNextPermissionIssue, newTimeOfNextPermissionIssue) {
break
}
}
sleepDuration := time.Duration(newTimeOfNextPermissionIssue - now)
if sleepDuration > 0 {
t.clock.Sleep(sleepDuration)
return time.Unix(0, newTimeOfNextPermissionIssue)
}
// return now if we don't sleep as atomicLimiter does
return time.Unix(0, now)
}原理分析
一旦進入case t.maxSlack > 0 && now-timeOfNextPermissionIssue > int64(t.maxSlack):這個分支,你會發(fā)現(xiàn)后續(xù)調(diào)用Take基本都會進入這個分支,程序不會阻塞,只要調(diào)用Take都不會阻塞??梢钥吹疆斣O(shè)置 slack>0 的時候才會進入這個分支,正好默認 slack=10。這個 bug 也可以推算出來。假設(shè)當前進入這個分支,當前時間是 now1,那么這次 Take 就會把newTimeOfNextPermissionIssue設(shè)置為 now1-int64(t.maxSlack)。
接下來再調(diào)用 Take,當前時間是 now2,now2 總是會比 now1 大一點,至少大幾納秒吧。這個時候我們計算分支的條件now-timeOfNextPermissionIssue > int64(t.maxSlack),這個條件肯定是成立的,因為now2-(now1-int64(t.maxSlack)) = (now2-now1) + int64(t.maxSlack) > int64(t.maxSlack)。導(dǎo)致后續(xù)的每次 Take 都會進入這個分支,不會阻塞,導(dǎo)致程序瘋狂發(fā)包,最終導(dǎo)致 panic。
周末的時候我給這個項目提了一個 bug, 它的一個維護者進行了修復(fù),不過這個項目主要開發(fā)者已經(jīng)對這個v0.3.0的實現(xiàn)喪失了信心,因為這個實現(xiàn)已經(jīng)出現(xiàn)過一次類似的 bug,被他回滾后了,后來有被修復(fù)才合進來,現(xiàn)在有出現(xiàn) bug 了。
不管作者修不修復(fù),你一定要注意,使用這個庫的v0.3.0一定小心,有可能踩到這個雷。
這個其中的一個大 bug。
其實我們對 slack 的有無不是那么關(guān)心的,那么我們使用ratelimit.WithoutSlack這個選項,把 slack 設(shè)置為 0,是不是就沒問題了呢?
嗯,是的,不會再出現(xiàn)上面的 bug,而且在我的 mac 筆記本上跑的單元測試也每問題,但是!但是!但是!又出現(xiàn)了另外一個 bug。
我們把限流的速率修改為5000,結(jié)果在 Linux 測試機器上跑只能跑到接近2000,遠遠小于預(yù)期,那這還咋限流,流根本打不上去。
我的同事說把ratelimit版本降到v0.2.0,同時不要設(shè)置slack=0可以解決這個問題。
這就很奇怪了,經(jīng)過一番排查,發(fā)現(xiàn)問題可能出在 Go 標準庫的time.Sleep上。
我們使用time.Sleep 休眠 50 微秒的話,在 Go 1.16 之前,Linux 機器上基本上實際會休眠 80、90 微秒,但是在 Go 1.16 之后,Linux 機器上 1 毫秒,差距巨大,在 Windows 機器上,Go 1.16 之前是 1 毫秒,之后是 14 毫秒,差距也是巨大的。我在蘋果的 MacPro M1 的機器測試,就沒有這個問題。
這個 bug 記錄在issues#44343[4], 自 2021 年 2 月提出來來,已經(jīng)快三年了,這個 bug 還一直沒有關(guān)閉,問題還一直存在著,看樣子這個 bug 也不是那么容易找到根因和徹底解決。
所以如果你要使用time.Sleep,請記得在 Linux 環(huán)境下,它的精度也就在1ms左右。所以ratelimit庫如果依賴它做 5000 的限流,如果不好好設(shè)計的話,達不到限流的效果。
總結(jié)一下
如果你使用uber-go/ratelimit[5],一定記得:
使用較老的版本
v0.2.0不要設(shè)置
slack=0, 默認或者設(shè)置一個非零的值
其實我從juju/ratelimit切換到uber-go/ratelimit還有一個根本的原因。juju/ratelimit是基于令牌桶的限流,而uber-go/ratelimit基于漏桶的限流,或者說uber-go/ratelimit更像是整形(shaping),更符合我們使用的場景,我們想勻速的發(fā)送數(shù)據(jù)包,不希望有 Burst 或者突然的速率變化,我們的場景更看中的是勻速。
當然你也可以使用juju/ratelimit[6],這是 Canonical 公司貢獻的一個限流庫,版權(quán)是 LGPL 3.0 + 對 Go 更合適的條款,這也是 Canonical 公司統(tǒng)一對它們的 Go 項目的授權(quán)。它是一個基于令牌的限流庫,其實用起來也可以,不過已經(jīng) 4 年沒有代碼更新了。有一點我覺得不太爽的地方是它初始化就把桶填滿了,導(dǎo)致的結(jié)果就是可能一開始使用這個桶獲取令牌的速度超出你的預(yù)期,有可能導(dǎo)致一開始就發(fā)包速度很快,然后慢慢的才勻速,這個不是我想要的效果,但是我又每辦法修改,所以我 fork 了這個項目smallnest/ratelimit[7],可以在初始化限流器的時候,可以設(shè)置初始的令牌,比如將初始的令牌設(shè)置為零。
當前 Go 官方也提供了一個擴展庫golang.org/x/time/rate[8], 功能更強大,強大帶來的負面效果就是使用起來比較復(fù)雜,復(fù)雜帶來的效果就是可能帶來一些的潛在的錯誤,不過在認真評估和測試后也是可以使用的。
參考資料
[1]
uber-go/ratelimit: https://github.com/uber-go/ratelimit
[2]
juju/ratelimit: https://github.com/juju/ratelimit
[3]
uber-go/ratelimit: https://github.com/uber-go/ratelimithttps://github.com/uber-go/ratelimit
[4]
issues#44343: https://github.com/golang/go/issues/44343
[5]
uber-go/ratelimit: https://github.com/uber-go/ratelimit
[6]
juju/ratelimit: https://github.com/juju/ratelimit
[7]
smallnest/ratelimit: https://github.com/smallnest/ratelimit
[8]
golang.org/x/time/rate: https://pkg.go.dev/golang.org/x/time/rate
還有一些關(guān)注度不是那么高的第三庫,還包括一些使用滑動窗口實現(xiàn)的限流庫,還有分布式的限流庫,如果你想了解更多請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
gtoken替換jwt實現(xiàn)sso登錄的問題小結(jié)
這篇文章主要介紹了gtoken替換jwt實現(xiàn)sso登錄,主要介紹了替換jwt的原因分析及gtoken的優(yōu)勢,本文給大家介紹的非常詳細,對大家的學(xué)習或工作具有一定的參考借鑒價值,需要的朋友可以參考下2022-05-05

