IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    这个限流库两个大bug存在了半年之久,没人发现?

    smallnest发表于 2023-12-05 14:22:26
    love 0

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

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

    最近我同事在做项目的时候,把这个库更新到最新的v0.3.0,发现在发包一段时间后,突然限流不起作用了,发包频率狂飙导致程序panic。
    很容易通过下面一个单元测试复现这个问题:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    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设计的本意是在rate的基础上留一点余地,不那么严格按照rate进行限流,不过因为v0.3.0代码的问题,导致slack的判断逻辑出现了问题:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    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, 自2021年2月提出来来,已经快三年了,这个bug还一直没有关闭,问题还一直存在着,看样子这个bug也不是那么容易找到根因和彻底解决。

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

    总结一下,如果你使用uber-go/ratelimit,一定记得:

    1. 使用较老的版本v0.2.0
    2. 不要设置slack=0, 默认或者设置一个非零的值

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

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

    当前Go官方也提供了一个扩展库golang.org/x/time/rate, 功能更强大,强大带来的负面效果就是使用起来比较复杂,复杂带来的效果就是可能带来一些的潜在的错误,不过在认真评估和测试后也是可以使用的。

    还有一些关注度不是那么高的第三库,还包括一些使用滑动窗口实现的限流库,还有分布式的限流库,如果你想了解更多,请参考《深入理解Go并发编程》这本书,专门有一章详细介绍限流库。



沪ICP备19023445号-2号
友情链接