Golang

关注公众号 jb51net

关闭
首页 > 脚本专栏 > Golang > golang限流库两大bug

golang限流库两个大bug(半年之久无人提起)

作者:晁岳攀(鸟窝) 鸟窝聊技术

最近我的同事在使用uber-go/ratelimit[1]这个限流库的时候,遇到了两个大 bug,这两个 bug 都是在这个库的最新版本(v0.3.0)中存在的,而这个版本从 7 月初发布都已经过半年了,都没人提 bug,难道大家都没遇到过么

uber-go/ratelimit 库

我先前都是使用juju/ratelimit[2]这个限流库的,不过我不太喜欢这个库的复杂的“构造函数”,后来尝试了uber-go/ratelimit[3]这个库后,感觉 SDK 设计比较简单,而且使用起来也不错,就一直使用了。当时的版本是v0.2.0,而且我也不会设置它的slack参数,所以也相安无事。

最近我同事在做项目的时候,把这个库更新到最新的v0.3.0,发现在发包一段时间后,突然限流不起作用了,发包频率狂飙导致程序 panic。

通过单元测试复现

很容易通过下面一个单元测试复现这个问题:

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 的判断逻辑出现问题

这个单元测试尝试在第二个周期中不调用限流器,让它有机会进入 slack 判断的逻辑。这个库的 slack 设计的本意是在 rate 的基础上留一点余地,不那么严格按照 rate 进行限流,不过因为v0.3.0代码的问题,导致 slack 的判断逻辑出现了问题:

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):这个分支,你会发现后续调用Take基本都会进入这个分支,程序不会阻塞,只要调用Take都不会阻塞。可以看到当设置 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)。导致后续的每次 Take 都会进入这个分支,不会阻塞,导致程序疯狂发包,最终导致 panic。

周末的时候我给这个项目提了一个 bug, 它的一个维护者进行了修复,不过这个项目主要开发者已经对这个v0.3.0的实现丧失了信心,因为这个实现已经出现过一次类似的 bug,被他回滚后了,后来有被修复才合进来,现在有出现 bug 了。

不管作者修不修复,你一定要注意,使用这个库的v0.3.0一定小心,有可能踩到这个雷。

这个其中的一个大 bug。

其实我们对 slack 的有无不是那么关心的,那么我们使用ratelimit.WithoutSlack这个选项,把 slack 设置为 0,是不是就没问题了呢?

嗯,是的,不会再出现上面的 bug,而且在我的 mac 笔记本上跑的单元测试也每问题,但是!但是!但是!又出现了另外一个 bug。

我们把限流的速率修改为5000,结果在 Linux 测试机器上跑只能跑到接近2000,远远小于预期,那这还咋限流,流根本打不上去。

我的同事说把ratelimit版本降到v0.2.0,同时不要设置slack=0可以解决这个问题。

这就很奇怪了,经过一番排查,发现问题可能出在 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 月提出来来,已经快三年了,这个 bug 还一直没有关闭,问题还一直存在着,看样子这个 bug 也不是那么容易找到根因和彻底解决。

所以如果你要使用time.Sleep,请记得在 Linux 环境下,它的精度也就在1ms左右。所以ratelimit库如果依赖它做 5000 的限流,如果不好好设计的话,达不到限流的效果。

总结一下

如果你使用uber-go/ratelimit[5],一定记得:

其实我从juju/ratelimit切换到uber-go/ratelimit还有一个根本的原因。juju/ratelimit是基于令牌桶的限流,而uber-go/ratelimit基于漏桶的限流,或者说uber-go/ratelimit更像是整形(shaping),更符合我们使用的场景,我们想匀速的发送数据包,不希望有 Burst 或者突然的速率变化,我们的场景更看中的是匀速。

当然你也可以使用juju/ratelimit[6],这是 Canonical 公司贡献的一个限流库,版权是 LGPL 3.0 + 对 Go 更合适的条款,这也是 Canonical 公司统一对它们的 Go 项目的授权。它是一个基于令牌的限流库,其实用起来也可以,不过已经 4 年没有代码更新了。有一点我觉得不太爽的地方是它初始化就把桶填满了,导致的结果就是可能一开始使用这个桶获取令牌的速度超出你的预期,有可能导致一开始就发包速度很快,然后慢慢的才匀速,这个不是我想要的效果,但是我又每办法修改,所以我 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

还有一些关注度不是那么高的第三库,还包括一些使用滑动窗口实现的限流库,还有分布式的限流库,如果你想了解更多请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文