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

 更新时间:2023年12月20日 10:18:43   作者:晁岳攀(鸟窝) 鸟窝聊技术  
最近我的同事在使用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],一定记得:

  • 使用较老的版本v0.2.0

  • 不要设置slack=0, 默认或者设置一个非零的值

其实我从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

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

相关文章

  • 在go中进行单元测试的案例分享

    在go中进行单元测试的案例分享

    这篇文章主要介绍了使用Go进行单元测试的实现,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2024-01-01
  • Go构建WiFi局域网聊天室示例详解

    Go构建WiFi局域网聊天室示例详解

    这篇文章主要为大家介绍了Go构建WiFi局域网聊天室示例详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2023-12-12
  • Go ORM的封装解决方式详解

    Go ORM的封装解决方式详解

    这篇文章主要为大家介绍了Go ORM的封装解决方式详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2023-01-01
  • 浅谈Go1.18中的泛型编程

    浅谈Go1.18中的泛型编程

    本文主要介绍了Go1.18中的泛型编程,文中通过示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2021-12-12
  • Go语言 init函数的具体使用

    Go语言 init函数的具体使用

    init()函数是Go语言中一种特殊的函数,用于在包被导入时执行一次性的初始化操作,本文就来介绍一下Go语言 init函数的具体使用,感兴趣的可以了解一下
    2024-09-09
  • CGO编程基础快速入门

    CGO编程基础快速入门

    这篇文章主要为大家介绍了CGO编程基础快速入门示例详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-12-12
  • Go语言map元素的删除和清空

    Go语言map元素的删除和清空

    本文主要介绍了Go语言map元素的删除和清空,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2023-04-04
  • go切片的copy和view的使用方法

    go切片的copy和view的使用方法

    这篇文章主要介绍了go切片的copy和view的使用方法,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2019-11-11
  • Go语言中实现enum枚举的方法详解

    Go语言中实现enum枚举的方法详解

    枚举,即 enum,可用于表示一组范围固定的值,它能助我们写出清晰、安全的代码,那么你是否了解过 Go 中的枚举呢?下面就跟随小编一起来学习一下Go语言中实现enum枚举的常用方法吧
    2024-02-02
  • golang查看CPU使用率与内存的方法详解

    golang查看CPU使用率与内存的方法详解

    这篇文章主要给大家介绍了golang查看CPU使用率与内存的方法,以及拓展介绍源码里//go:指令,文中有详细的代码示例以及图文介绍,需要的朋友可以参考下
    2023-10-10

最新评论