golang限流庫兩個大bug(半年之久無人提起)
uber-go/ratelimit 庫
我先前都是使用juju/ratelimit[2]這個限流庫的,不過我不太喜歡這個庫的復雜的“構造函數(shù)”,后來嘗試了uber-go/ratelimit[3]這個庫后,感覺 SDK 設計比較簡單,而且使用起來也不錯,就一直使用了。當時的版本是v0.2.0
,而且我也不會設置它的slack
參數(shù),所以也相安無事。
最近我同事在做項目的時候,把這個庫更新到最新的v0.3.0
,發(fā)現(xiàn)在發(fā)包一段時間后,突然限流不起作用了,發(fā)包頻率狂飆導致程序 panic。
通過單元測試復現(xiàn)
很容易通過下面一個單元測試復現(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)問題
這個單元測試嘗試在第二個周期中不調用限流器,讓它有機會進入 slack 判斷的邏輯。這個庫的 slack 設計的本意是在 rate 的基礎上留一點余地,不那么嚴格按照 rate 進行限流,不過因為v0.3.0
代碼的問題,導致 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ù)調用Take
基本都會進入這個分支,程序不會阻塞,只要調用Take
都不會阻塞??梢钥吹疆斣O置 slack>0 的時候才會進入這個分支,正好默認 slack=10。這個 bug 也可以推算出來。假設當前進入這個分支,當前時間是 now1,那么這次 Take 就會把newTimeOfNextPermissionIssue
設置為 now1-int64(t.maxSlack)
。
接下來再調用 Take,當前時間是 now2,now2 總是會比 now1 大一點,至少大幾納秒吧。這個時候我們計算分支的條件now-timeOfNextPermissionIssue > int64(t.maxSlack)
,這個條件肯定是成立的,因為now2-(now1-int64(t.maxSlack))
= (now2-now1) + int64(t.maxSlack)
> int64(t.maxSlack)
。導致后續(xù)的每次 Take 都會進入這個分支,不會阻塞,導致程序瘋狂發(fā)包,最終導致 panic。
周末的時候我給這個項目提了一個 bug, 它的一個維護者進行了修復,不過這個項目主要開發(fā)者已經(jīng)對這個v0.3.0
的實現(xiàn)喪失了信心,因為這個實現(xiàn)已經(jīng)出現(xiàn)過一次類似的 bug,被他回滾后了,后來有被修復才合進來,現(xiàn)在有出現(xiàn) bug 了。
不管作者修不修復,你一定要注意,使用這個庫的v0.3.0
一定小心,有可能踩到這個雷。
這個其中的一個大 bug。
其實我們對 slack 的有無不是那么關心的,那么我們使用ratelimit.WithoutSlack
這個選項,把 slack 設置為 0,是不是就沒問題了呢?
嗯,是的,不會再出現(xiàn)上面的 bug,而且在我的 mac 筆記本上跑的單元測試也每問題,但是!但是!但是!又出現(xiàn)了另外一個 bug。
我們把限流的速率修改為5000
,結果在 Linux 測試機器上跑只能跑到接近2000
,遠遠小于預期,那這還咋限流,流根本打不上去。
我的同事說把ratelimit
版本降到v0.2.0
,同時不要設置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 還一直沒有關閉,問題還一直存在著,看樣子這個 bug 也不是那么容易找到根因和徹底解決。
所以如果你要使用time.Sleep
,請記得在 Linux 環(huán)境下,它的精度也就在1ms左右。所以ratelimit
庫如果依賴它做 5000 的限流,如果不好好設計的話,達不到限流的效果。
總結一下
如果你使用uber-go/ratelimit[5],一定記得:
使用較老的版本
v0.2.0
不要設置
slack=0
, 默認或者設置一個非零的值
其實我從juju/ratelimit
切換到uber-go/ratelimit
還有一個根本的原因。juju/ratelimit
是基于令牌桶的限流,而uber-go/ratelimit
基于漏桶的限流,或者說uber-go/ratelimit
更像是整形(shaping),更符合我們使用的場景,我們想勻速的發(fā)送數(shù)據(jù)包,不希望有 Burst 或者突然的速率變化,我們的場景更看中的是勻速。
當然你也可以使用juju/ratelimit[6],這是 Canonical 公司貢獻的一個限流庫,版權是 LGPL 3.0 + 對 Go 更合適的條款,這也是 Canonical 公司統(tǒng)一對它們的 Go 項目的授權。它是一個基于令牌的限流庫,其實用起來也可以,不過已經(jīng) 4 年沒有代碼更新了。有一點我覺得不太爽的地方是它初始化就把桶填滿了,導致的結果就是可能一開始使用這個桶獲取令牌的速度超出你的預期,有可能導致一開始就發(fā)包速度很快,然后慢慢的才勻速,這個不是我想要的效果,但是我又每辦法修改,所以我 fork 了這個項目smallnest/ratelimit[7],可以在初始化限流器的時候,可以設置初始的令牌,比如將初始的令牌設置為零。
當前 Go 官方也提供了一個擴展庫golang.org/x/time/rate[8], 功能更強大,強大帶來的負面效果就是使用起來比較復雜,復雜帶來的效果就是可能帶來一些的潛在的錯誤,不過在認真評估和測試后也是可以使用的。
參考資料
[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
還有一些關注度不是那么高的第三庫,還包括一些使用滑動窗口實現(xiàn)的限流庫,還有分布式的限流庫,如果你想了解更多請關注腳本之家其它相關文章!