📌 限流并不是为了限制业务使用,而是服务的自我保护,防止预期外的风险事件,更好的为业务服务
限流是解决各种过载、雪崩等问题的有效手段。之前了解到令牌桶限流算法的概念,直观理解有一个后台线程去生成令牌,Go官方包有令牌桶限流器的封装,令牌并非靠另一个协程异步刷入而是"读写时生成"。
限流算法
在讲令牌桶实现之前,先看看常见的限流算法有窗口算法、滑动窗口算法、漏桶算法,令牌桶算法。
计数器
计数器的思想为:在一段时间内最多允许多少请求通过。 工程实践中计数器算法的实现,可以采用redis、ttl key + incrby的方式来实现窗口的限流。 计数器存在问题 计数器算法又叫固定窗口算法,在一个固定的时间,允许多少请求。计数器的实现比较简单,但有个很大的问题是没法防止边缘效应,如下图,假设阈值设置为500,请求刚好在两个窗口之间,那在4s-7s实际上允许了1000的请求量,两倍阈值的请求可能会直接打死系统。

❓ 那把窗口大小设置为1s能解决问题吗?
滑动窗口算法
为解决计数器算法的问题,引入滑动窗口的概念,限流判断的窗口不是固定的时间段,而是动态时间段的总和。比如上述的每5s是#一个窗口,在滑动窗口算法下就是"最近5s"的概念,滑动窗口算法累计最近5s的请求量,判断是否超过阈值。 滑动窗口能提供相对精准的限流、一定程度解决边缘问题,缺点就是实现相对复杂,需要存储更多小窗口的状态。
漏桶算法
漏桶算法的思维:先把请求放入桶中,如果桶满则拒绝请求,桶中的请求会均匀流出。 个人认为漏桶机制更适用于客户端限流,将请求匀速的发到服务端来保护下游。可类比消息队列的消费者模型,消费速率是相对恒定的。
令牌桶
令牌桶是指系统会以恒定速率生成令牌,放入令牌桶中,令牌桶满则多余的令牌丢弃,请求时需要先从令牌桶中获取令牌,获取失败则拒绝请求。
模拟令牌桶实现
按照令牌桶的定义,核心的能力
- 获取令牌,如果桶内有令牌,则取出一个令牌获取成功,否则获取失败
 - 令牌定时生成,如果桶满了则丢弃多余的令牌
 
定义结构体TokenBucket
            
            
              go
              
              
            
          
          type TokenBucket struct {
    Size int64         // 桶大小
    Unit time.Duration // 时间单位/最低1s/Unit生产Size个
    productPerSecond int64 // 每秒生成数量
    current          int64 // 当前数量
    ticker *time.Ticker // 定时器
    mu sync.Mutex
}
        定时生成令牌
            
            
              go
              
              
            
          
          func (tb *TokenBucket) startGenerate() {
    go func() {
        for tb.ticker != nil {
            // 定时器触发会写入该channel
            <-tb.ticker.C
            if tb.IsFull() {
                continue
            }
            tb.mu.Lock()
            if tb.current+tb.productPerSecond >= tb.Size {
                // 满了
                tb.current = tb.Size
            } else {
                tb.current = tb.current + tb.productPerSecond
            }
            tb.mu.Unlock()
        }
    }()
}
        获取令牌
            
            
              go
              
              
            
          
          func (tb *TokenBucket) GetToken() bool {
    tb.mu.Lock()
    defer tb.mu.Unlock()
    if tb.IsEmpty() {
        return false
    }
    tb.current = tb.current - 1
    return true
}
        模拟运行的效果 
time/rate实现
源码不到500行,建议大家阅读 核心思想:记录上一次生成token的时间,在下次获取token的根据时间间隔计算token量。
基本使用
            
            
              go
              
              
            
          
          // 参数1: 生成频率=>每s生成多少令牌
// 参数2: 桶容量
limiter := rate.NewLimiter(10, 100)
// 获取令牌成功
if limiter.Allow() {
     // 成功获取到令牌
     do()   
} else {
    return RateLimitErr
}
// 等待直到获取令牌成功 | 如果传入context超时了会返回err
err := limiter.Wait(context.Backgroud())
if err != nil {
    return err
}
// 成功获取
do()
        数据结构
            
            
              go
              
              
            
          
          type Limiter struct {
    mu     sync.Mutex
    limit  Limit
    burst  int
    tokens float64
    // last is the last time the limiter's tokens field was updated
    last time.Time
    // lastEvent is the latest time of a rate-limited event (past or future)
    lastEvent time.Time
}
        limit: 生成频率 = 每秒生成多少token burst:桶大小 tokens: 当前token量 last: 上次生成token时间 核心方法 我们最主要使用的是Allow()方法,返回bool表示获取令牌结果。部分场景需要阻塞式等待会调用Wait()直至获取到token。这两方法下层的核心实现均为reserveN(),reserveN()整个方法会比较长,但是去除边界校验和结果构造,代码就很简单清晰了
            
            
              go
              
              
            
          
          func (lim *Limiter) reserveN(t time.Time, n int, maxFutureReserve time.Duration) Reservation {
    // ...手动折叠
    
    t, tokens := lim.advance(t)
    // Calculate the remaining number of tokens resulting from the request.
    tokens -= float64(n)
    // Calculate the wait duration
    var waitDuration time.Duration
    if tokens < 0 {
        waitDuration = lim.limit.durationFromTokens(-tokens)
    }
    // Decide result
    ok := n <= lim.burst && waitDuration <= maxFutureReserve
    // ...手动折叠
}
        advance()返回t时刻桶有多少tokens,预扣减n个,如果token不足,即扣减后的tokens < 0,则再计算需要多长时间才能满足(durationFromTokens计算 生成指定token量 需要多久时间)。之后判断本次获取的token的结果是否为ok,Allow()方法传递的maxFutureReserve为0,所以只要出现token不足,waitDuration必然大于0,返回的结果ok的值就是false。 advance()代码如下
            
            
              go
              
              
            
          
          func (lim *Limiter) advance(t time.Time) (newT time.Time, newTokens float64) {
    // 取上一次生成的时间
    last := lim.last
    if t.Before(last) {
        last = t
    }
    // 判断时间间隔
    // Calculate the new number of tokens, due to time that passed.
    elapsed := t.Sub(last)
    // 这段时间间隔生成多少token
    delta := lim.limit.tokensFromDuration(elapsed)
    // 累计多少token
    tokens := lim.tokens + delta
    // 满了则直接用最大值
    if burst := float64(lim.burst); tokens > burst {
        tokens = burst
    }
    return t, tokens
}
        - 获取上一次生成时间
 - 计算时间间隔
 - 间隔内生成多少token
 - 累计token
 - 判断tokens是否超过桶的大小
 
令牌桶的分布式限流
模拟实现的令牌桶或者Go官方包time/rate的实现,本质都是单机的限流,很少会在生产中直接使用,一般会采用分布式限流的方案。 分布式限流需要依赖存储中间件来统一保存限流状态,技术选型上大部分会选择redis(高性能高可用),封装为服务或者SDK。 实践一般会按照qps进行限流,因此简单的实现,可以把 "每秒生成指定量级别的qps"抽象为"每秒一个redis key",调用incr,如果结果超过了超过了阈值qps,则表示限流,简易代码如下
            
            
              go
              
              
            
          
          type TokenBucket struct {
    Key string // 标识桶
    QPS int // 阈值
}
func (tb *TokenBucket) Allow(ctx context.Context) bool {
    redisKey := func() string {
        return fmt.Sprintf("%s:%d", tb.Key, time.Now().Unix())
    }
    cmd := GetClient().Incr(ctx, redisKey())
    if cmd.Err() != nil {
        // TODO: 容灾处理
    }
    if cmd.Val() > int64(tb.QPS) {
        return false
    }
    return true
}
        TokenBucket的key可以自由扩展的,比如服务限流可以考虑用【psm,cluster,method】的元组为基础生成唯一key。
和1s的窗口有什么区别? 如果放在客户端,和漏桶算法有什么区别?
以上是以qps为限流对象的简易实现,标准的令牌桶实现可以参考time/rate的方案,区别点在存储介质从内存转移到数据库,因为redis大部份时候是并发安全的,所以在实现不会特别复杂。