2. 限流基础概念

2.1 什么是限流

限流(Rate Limiting)是一种通过控制单位时间内允许通过的请求数量,来保护系统不被突发流量冲垮的防护手段。当请求速率超过预设阈值时,多余的请求会被直接拒绝、排队等待或降级处理。

限流的核心目标可以概括为三点:

  • 保护后端资源:防止数据库、缓存、微服务等下游组件因过载而崩溃。
  • 保障服务质量:确保有限的系统资源能被公平分配给合法用户,避免少数高并发请求挤占所有连接。
  • 成本控制:在云原生环境下,限流可以防止因流量异常导致的计算资源费用飙升。
关键认知:限流不是“阻止用户”,而是“管理流量”。一个设计良好的限流策略,应当在系统容量与用户体验之间找到平衡点。

2.2 常见限流算法概述

不同的业务场景对限流的精度、内存开销和突发处理能力有不同要求。下表对比了四种主流限流算法的核心差异:

算法 核心原理 精度 内存开销 是否支持突发 典型场景
计数器 固定时间窗口内累加计数,超限则拒绝 低(存在临界突变) 极低(1个计数器) 否 简单接口限流、非关键路径
滑动窗口 将时间窗口细分为多个小格,滑动统计 中(取决于分片数) 中(需存储多个子窗口计数) 否 API网关、通用HTTP限流
漏桶 请求先进入队列,以固定速率流出处理 高(严格平滑) 中(需维护队列) 否(强制削峰填谷) 数据库写入限流、消息生产限速
令牌桶 以固定速率生成令牌,请求需获取令牌才能通过 高 低(仅维护令牌数和时间戳) 是(允许一定突发) 微服务限流、网络流量整形

2.3 四种算法详解

2.3.1 计数器算法

计数器算法是最直观的限流实现。它将时间划分为固定的窗口(如1秒),每个窗口内维护一个计数器。请求到达时计数器加1,若计数器超过阈值则拒绝该请求。窗口结束时计数器归零。

典型问题——临界突变:假设阈值是100次/秒。如果在第1秒的最后10ms内通过了100个请求,紧接着在第2秒的前10ms内又通过了100个请求,那么这20ms内实际通过了200个请求,形成了流量尖峰。

// 计数器限流伪代码
type CounterLimiter struct {
    threshold int64
    count     int64
    windowStart time.Time
    mu         sync.Mutex
}

func (c *CounterLimiter) Allow() bool {
    c.mu.Lock()
    defer c.mu.Unlock()
    now := time.Now()
    if now.Sub(c.windowStart) >= time.Second {
        c.count = 0
        c.windowStart = now
    }
    if c.count >= c.threshold {
        return false
    }
    c.count++
    return true
}

2.3.2 滑动窗口算法

滑动窗口算法将固定窗口进一步细分为多个小格子(如将1秒分为10个100ms的格子)。每个格子独立计数,窗口随着时间推移向前滑动,统计当前窗口内所有格子的累计值。格子数量越多,精度越高,但内存开销也线性增长。

精度与内存的权衡:滑动窗口的精度 = 窗口总时长 / 子窗口数量。例如将1秒分为10个100ms子窗口,精度为100ms。如果需要毫秒级精度,则需要1000个子窗口,内存开销会显著增加。

2.3.3 漏桶算法

漏桶算法将请求比作水滴,系统是一个底部有漏洞的桶。水滴以任意速率流入桶中,但桶底以固定的速率漏水(处理请求)。如果桶满了,后续的水滴(请求)就会溢出(被拒绝)。

漏桶的核心特性是强制平滑流量——无论入口流量多么不均匀,出口速率始终恒定。这对于保护数据库连接池、消息队列等对写入速率敏感的资源非常有效。

// 漏桶限流伪代码(基于队列)
type LeakyBucket struct {
    capacity  int           // 桶容量
    rate      time.Duration // 每个请求的处理间隔
    queue     chan struct{}
    ticker    *time.Ticker
}

func (l *LeakyBucket) Allow() bool {
    select {
    case l.queue <- struct{}{}:
        return true
    default:
        return false // 桶满,拒绝
    }
}

// 后台协程以固定速率从队列中取出请求
func (l *LeakyBucket) Start() {
    go func() {
        for range l.ticker.C {
            <-l.queue
            // 处理请求
        }
    }()
}

2.3.4 令牌桶算法

令牌桶算法是目前应用最广泛的限流算法。系统以固定的速率向桶中添加令牌,桶有最大容量限制。每个请求到达时,需要从桶中获取一个令牌才能被处理。如果桶中没有令牌,请求被拒绝或等待。

与漏桶不同,令牌桶允许一定程度的突发流量:如果桶中积累了足够的令牌(例如系统空闲时),短时间内可以处理大量请求。

// 令牌桶限流伪代码(Go标准库rate包简化版)
type TokenBucket struct {
    rate     float64    // 每秒生成的令牌数
    capacity float64    // 桶容量
    tokens   float64    // 当前令牌数
    lastTime time.Time  // 上次更新时间
    mu       sync.Mutex
}

func (t *TokenBucket) Allow() bool {
    t.mu.Lock()
    defer t.mu.Unlock()
    now := time.Now()
    // 先补充令牌
    elapsed := now.Sub(t.lastTime).Seconds()
    t.tokens = math.Min(t.capacity, t.tokens + elapsed * t.rate)
    t.lastTime = now
    if t.tokens < 1 {
        return false
    }
    t.tokens--
    return true
}
四种限流算法核心对比 计数器 固定时间窗口 单计数器累加 窗口结束归零 存在临界突变 内存开销:极低 滑动窗口 多子窗口分片 滑动统计累计值 精度可控 内存与精度权衡 内存开销:中 漏桶 队列缓冲 固定速率流出 强制削峰填谷 不支持突发 内存开销:中 令牌桶 固定速率生成令牌 请求消耗令牌 支持突发流量 应用最广泛 内存开销:低 算法选择建议 • 简单场景、非关键路径 → 计数器(实现简单,但注意临界突变) • API网关、通用HTTP限流 → 滑动窗口(精度与内存的平衡) • 数据库写入、消息生产限速 → 漏桶(强制平滑) • 微服务、网络流量整形 → 令牌桶(支持突发,Go标准库内置) 算法演进时间线 计数器 滑动窗口 漏桶 令牌桶 简单 → 复杂 · 低精度 → 高精度 · 无突发 → 支持突发

2.4 限流与背压的关系

背压(Back Pressure)是一种从下游向上游传递压力信号的机制。当下游组件处理能力不足时,它会主动告知上游“请放慢发送速度”,从而形成一条从消费者到生产者的负反馈链路。

限流与背压的核心区别在于控制方向:

  • 限流:在入口处主动拒绝请求,是一种前置防御策略。系统在流量进入之前就设定好阈值,超出部分直接丢弃。
  • 背压:根据下游的实际处理能力动态调节上游速率,是一种后置反馈策略。系统不直接丢弃请求,而是让上游等待或降速。
实践误区:很多团队将限流和背压混为一谈。在Go语言中,channel的阻塞发送就是一种天然的背压机制——当接收方处理不过来时,发送方会被阻塞。但阻塞可能导致goroutine泄漏或死锁,因此生产环境中通常需要结合超时和限流来使用。
限流 vs 背压:两种流量控制策略 限流(前置防御) 上游请求 限流器 阈值判断 通过 拒绝/降级 后端处理 背压(后置反馈) 上游生产者 缓冲通道 (channel) 下游消费者 处理能力不足 → 阻塞信号 负反馈:减速/等待 限流:在入口处主动丢弃,保护系统不被冲垮 | 背压:通过反馈动态调节,让上下游速率匹配

2.5 限流算法的选型决策树

在实际项目中,选择哪种限流算法通常取决于以下三个维度:

  1. 是否需要支持突发流量?是 → 令牌桶;否 → 漏桶或滑动窗口。
  2. 对精度要求有多高?毫秒级 → 滑动窗口(细粒度分片)或令牌桶;秒级 → 计数器(需注意临界突变)。
  3. 内存是否敏感?嵌入式/IoT设备 → 计数器或令牌桶;服务端API网关 → 滑动窗口或令牌桶。
Go语言实践建议:Go标准库 golang.org/x/time/rate 提供了开箱即用的令牌桶实现,底层基于 time.Time 和浮点数计算,无需额外存储结构,是微服务限流的首选。对于需要精确控制窗口边界的场景(如秒杀系统),建议使用滑动窗口算法,并配合Redis的Sorted Set实现分布式限流。