跳到正文

限流算法详解 ​

一句话:限流用一个人为设定的速率上限保护系统,算法决定「超出上限的请求是排队、拒绝还是允许短时突发」,参数决定误杀和过载的边界。

适用:Nginx 1.24+、Sentinel 1.8+、Guava 33.x、Redis 7.x;默认值以各版本文档为准。

为什么需要 ​

下游的处理能力有上限。流量超过上限时,如果不主动拒绝,延迟会先上升,然后连接池耗尽、线程堆积,最终整个服务不可用。限流的作用是在过载前把请求挡掉一部分,让剩下的请求还能在可接受延迟内完成。

限流不是「越多越好」:阈值太低会误杀正常用户,太高则起不到保护作用。它需要和容量、排队策略、降级方案一起设计。

核心机制 ​

固定窗口与临界突刺 ​

每秒限 100 次,计数器按自然秒重置。如果第 1 秒的最后 0.2 秒来了 100 个请求,第 2 秒的前 0.2 秒又来了 100 个,0.4 秒内实际通过了 200 个,是限制值的两倍。

窗口切得再小,边界仍然存在。只要窗口是离散且独立的,临界时刻就可能叠加两个窗口的额度。

滑动窗口 ​

把 1 秒切成 N 个小格,统计最近 1 秒内所有格子的计数,旧格子滑出后不再计入。Sentinel 默认用 2 个格子、总窗口 1000ms;格子越细,统计越平滑,内存和计算也越多。

Sentinel 的 LeapArray 用环形数组复用格子:时间前进时重置旧格子,O(1) 定位和统计,不需要后台线程。

漏桶:恒定速率流出 ​

请求先进入桶(队列),桶以固定速率把请求放行。桶满后新请求被拒绝或丢弃。

  • 出口速率恒定,能把突发流量削平。
  • 不允许突发:即使下游有空闲,也只能按固定速率走。
  • 队列会引入延迟;队列越长,超时和内存风险越大。

Nginx 的 limit_req 是漏桶模型:rate 是流出速率,burst 是允许排队的突发量;加 nodelay 后,突发量会立即放行而不是排队,行为更接近「允许一定突发的令牌桶」。不加 nodelay 时,超出速率的请求会排队,超过 burst 才拒绝。

令牌桶:允许突发 ​

系统以固定速率往桶里放令牌,桶有容量上限。请求拿到令牌才能通过;没有令牌就拒绝或等待。

  • 流量低谷时令牌会攒起来,高峰时可以短时透支,所以允许突发。
  • 平均速率仍受令牌生成速率限制,突发上限是桶容量。
  • Guava RateLimiter 是令牌桶:SmoothBursty 默认最多存 1 秒的令牌(maxBurstSeconds=1.0),SmoothWarmingUp 在此基础上加预热。

四种算法对照 ​

算法突发流量出口形态典型实现
固定窗口临界时刻可翻倍离散计数简单计数器
滑动窗口可调,较平滑最近窗口统计Sentinel 默认
漏桶不允许,超出排队/拒绝恒定速率Nginx limit_req、队列
令牌桶允许,上限为桶容量平均速率 + 突发Guava RateLimiter、Gateway RedisRateLimiter

选型先问:下游能不能接受突发?能,用令牌桶;不能,用漏桶。需要精确统计最近窗口,用滑动窗口。

分布式限流 ​

单机限流每个节点各算各的,节点数一多,全局额度就是「单机额度 × 节点数」,流量倾斜时还会误杀。全局限流要把计数放到共享存储,常见做法是 Redis + Lua 令牌桶:

lua
-- KEYS[1]=limit key; ARGV: rate/s, capacity, now_ms, requested
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local tokens = tonumber(redis.call('HGET', KEYS[1], 'tokens') or capacity)
local last = tonumber(redis.call('HGET', KEYS[1], 'last') or now)
local delta = math.max(0, now - last)
tokens = math.min(capacity, tokens + delta * rate / 1000)
if tokens >= requested then
  redis.call('HSET', KEYS[1], 'tokens', tokens - requested)
  redis.call('HSET', KEYS[1], 'last', now)
  redis.call('PEXPIRE', KEYS[1], 60000)
  return 1
end
return 0

每次请求多一次 Redis 往返,延迟和 Redis 可用性都成了新问题。折中是「本地令牌桶 + 定期从 Redis 同步配额」,精度换性能;同步间隔越长,各节点越容易短暂超额。

实战场景 ​

一、固定窗口的临界突刺,把下游打回半秒一个坑 ​

现象:网关对某接口配“100 QPS”,实现是自然秒计数重置。大促开始那一刻,前一秒最后 200ms 通过 100 个请求、新一秒前 200ms 又通过 100 个——0.4 秒内实际放行 200 个。下游服务的连接池只有 30,直接报 cannot obtain connection,熔断打开,用户侧出现成片失败。

排查:窗口离散且独立时,临界叠加是必然,与窗口切多小无关。

定位层取证据判定
实际通过速率网关放行日志按 100ms 粒度统计,而非按秒秒内出现 2× 即命中临界突刺
计数实现是按自然秒重置还是滑动窗口重置式计数即固定窗口
下游承载曲线连接池等待、线程池拒绝与突刺时间点对齐即因果成立
窗口粒度成本格子越细统计越平滑,内存与计算也越多不能靠“把窗口切到毫秒”无限做
单机 vs 集群单机阈值 × 实例数是否远超下游容量见第三场景
text
Sentinel 默认把 1000ms 总窗口切成 2 个格子(LeapArray 环形数组复用,
时间前进时重置旧格子,O(1) 定位与统计);格子越细越平滑,成本越高。

对症做法:

yaml
# 1. 换滑动窗口统计(Sentinel 已是默认形态),不要用自研自然秒计数
flow:
  - resource: order-create
    grade: 1            # QPS
    count: 100
    controlBehavior: 2  # 匀速排队用于削峰,快速失败用于保护(见下)
nginx
# 2. Nginx 层用漏桶 + burst 控制瞬时并发
limit_req_zone $server_name zone=api:10m rate=100r/s;
location /api/order {
    limit_req zone=api burst=80 nodelay;   # 允许 80 的突发立即放行,超出才拒绝
    limit_req_status 429;                   # 明确 429,别返回 500 触发客户端重试
}
text
3. 阈值按下游容量倒算,而不是按“接口现在的量”:
   允许并发 ≈ 下游连接池大小 × 可接受排队时间 ÷ 单请求耗时,
   留 20%~30% 余量(经验值,需压测校准)。
4. 阈值要能在运行时改:来自配置中心,变更灰度到部分实例后全量;
   写死的阈值等于在真实故障面前没有工具。

复盘要点:判据是“下游连接池等待率不因为瞬时突刺上升”,并配一条“亚秒级通过速率”指标(每 100ms 一个桶)。把“按秒统计的 QPS”当成健康口径是这类问题的来源——它天然把突刺抹平,只有更细的粒度才能暴露临界叠加。

二、漏桶排队把超时窗口耗尽,客户端重试雪上加霜 ​

现象:为避免突发,接口用“匀速排队”模式(漏桶),队列上限 5000、恒定放行 200/s。流量高峰时队列排到 3000+,请求平均等待 15 秒,而网关超时是 5 秒:大量请求在网关侧超时返回,客户端按“超时=可重试”再发一遍,队列进一步加深,最终拒绝数暴涨。

排查:漏桶出口速率恒定,队列长度直接变成用户等待时间。

定位层取证据判定
队列长度与等待限流组件的排队数、平均等待时长等待接近超时的 1/3 即危险
超时与排队关系客户端超时 vs maxQueueingTimeMs排队上限必须小于上游超时,否则白排
失败语义被限流的响应码与是否带 Retry-After返回 500 会触发无条件重试
重试放大同一 traceId 的下游调用次数>1 说明超时被重试放大
线程占用排队是否占用容器线程排队型实现会把线程拖满
下游空闲度下游实际利用率漏桶不允许突发,空闲也用不上

对症做法:

yaml
# 1. 排队时间上限必须显著小于上游超时;超过就直接拒绝,而不是继续排队
flow:
  - resource: report-export
    count: 200
    controlBehavior: 2               # 匀速排队
    maxQueueingTimeMs: 800           # 上游超时 5s,这里只允许排 800ms
java
// 2. 拒绝要给出确定语义:429 + Retry-After,客户端据此退避
@ExceptionHandler(BlockException.class)
ResponseEntity<Void> onBlock(BlockException e) {
    metrics.blocked(e.getClass().getSimpleName());
    return ResponseEntity.status(429)
            .header("Retry-After", String.valueOf(ThreadLocalRandom.current().nextInt(1, 4)))
            .build();
}
text
3. 需要“下游空闲时能吃突发”的场景不要选漏桶,用令牌桶:
   桶容量决定允许的突发量,速率决定长期上限。
4. 排队型限流不占业务线程才安全:
   在入口层排队(网关/中间件),而不是在应用线程里 sleep 等待。
5. 重试策略要区分:429 按 Retry-After 退避且最多 1 次,
   5xx/超时的重试要检查幂等键——限流期间的重试是放大器。

复盘要点:判据是“被限流请求的客户端重试率接近 0”,这条依赖响应语义(429 + Retry-After)而不是依赖培训。同时要说清算法取舍:漏桶削峰最稳但不允许突发、队列带来延迟与内存风险;令牌桶允许突发但瞬时压力交给下游;滑动窗口计数简单但需要配拒绝策略。选错算法的表现是“限流生效了但业务更差”。

三、单机阈值 × 实例数:扩容把总量放大成三倍 ​

现象:应用侧限流配“单机 50 QPS”,4 个实例合计 200 QPS,与下游容量匹配。弹性扩容到 12 个实例后,总放行量变成 600 QPS,下游数据库连接池被打满,报表服务与在线交易一起超时。缩容后又恢复正常——问题只在扩容当天出现。

排查:单机阈值的总量随实例数漂移,这不是限流失效,而是语义错配。

定位层取证据判定
总放行量按服务聚合的放行 QPS vs 单机阈值 × 实例数相等即单机语义在起作用
实例数变化HPA 事件与总量上升时间是否对齐对齐即扩容引入
下游容量连接池大小、Threads_running、等待队列决定总量上限的真实依据
限流层级网关层是否有总量控制只有应用层单机限流必然漂移
集群限流成本Token Server 可用性与额外一跳精确总量的代价

对症做法:

yaml
# 方案 1(最省事):网关按服务维度做总量限流,与实例数无关
routes:
  - id: report-api
    predicates: [ Path=/api/report/** ]
    filters:
      - name: RequestRateLimiter
        args: { redis-rate-limiter.replenishRate: 200, redis-rate-limiter.burstCapacity: 260,
                key-resolver: "#{@tenantKeyResolver}" }

# 方案 2:需要全局精确值时用集群限流,接受 Token Server 的可用性成本
flow:
  - resource: report-query
    count: 200
    clusterMode: true
    clusterFailFallback: true      # Token Server 不可用时退回单机阈值,不能整体拒绝
    clusterThresholdType: 1        # 全局阈值
text
3. 单机阈值要按“实例数上限”折算,而不是按当前实例数:
   单机阈值 = 下游容量 ÷ maxReplicas × 安全系数(例如 0.8,经验值需校准)。
4. 与弹性伸缩联动:改 HPA 的 maxReplicas 时同步评审限流阈值,
   两者写在同一份容量配置里。
5. Token Server 故障时“退回单机”优于“整体拒绝”,
   但要保证退回后的总量仍不超过下游能承受的上限,
   否则宁可降级为部分请求失败。

复盘要点:判据是“下游观测到的入口 QPS 有硬上限,且不随实例数变化”。这条最好由网关或集群限流承担——把总量控制放在应用层单机上,等于把容量约束交给“当前有几个实例”这个最易变的量。多租户场景还要按租户维度限流,避免单客户吃满全局额度,见 单元化架构。

最小上手 ​

Nginx 按 IP 限流(漏桶,429 而不是默认 503):

nginx
limit_req_zone $binary_remote_addr zone=api:10m rate=100r/s;

server {
    location /api/ {
        limit_req zone=api burst=200 nodelay;
        limit_req_status 429;
    }
}

Guava 令牌桶:

java
RateLimiter limiter = RateLimiter.create(100.0); // 100 个令牌/秒
if (!limiter.tryAcquire(50, TimeUnit.MILLISECONDS)) {
    // 返回 429 或降级结果
}

验证:用 ab/wrk 或脚本并发打满,观察 200 和 429 的比例;nodelay 去掉后再压一次,观察延迟是否被排队拉长。

生产动作 ​

  • 状态码:限流拒绝返回 429,并带 Retry-After;Nginx 默认 limit_req_status 是 503,要显式改成 429。
  • 阈值来源:先压测出单实例安全 QPS,再留 20%–30% 余量;不要直接抄别人的数字。
  • 排队策略:能接受延迟就排队(漏桶),不能就快速拒绝(令牌桶/滑动窗口);排队要有最大等待时间,否则请求会堆到超时。
  • 预热:新实例刚启动时用 SmoothWarmingUp 或逐步放量,避免冷系统被瞬时打满。
  • 维度:按 IP、用户、API、租户分别限流;只按 IP 会误伤 NAT 后的用户,只按用户挡不住匿名流量。
  • 分布式:Redis 限流要监控延迟和失败;Redis 不可用时降级到本地限流,不能直接放行。限流 key 的 TTL 要大于桶从空到满的时间,否则过期重置会放大额度。
  • 排障:先看限流命中数和被限流的 key 分布,再判断阈值是否过低、key 是否倾斜。

边界 ​

  • 限流不等于熔断:限流控制入口速率,熔断根据错误率/延迟拒绝下游调用;两者要配合。
  • 隔离(线程池/信号量)也不等于限流:隔离限制某类请求占用的并发资源,限流限制速率;一个慢依赖仍可能拖垮共享线程池,需要单独隔离。
  • 漏桶不会让流量「更快」,只会让它更平滑;下游空闲时也不会加速。
  • 令牌桶的突发上限是桶容量,不是无限透支;桶太大等于没有限流。
  • 单机限流在弹性扩缩容下会漂移;全局精确限流又要接受 Redis 延迟。
  • 固定窗口的临界突刺不会因为「调小阈值」消失,只是把问题挪到更细的边界。

讲清楚 ​

限流先看下游能不能接受突发。固定窗口实现最简单,但窗口边界可能通过两倍额度;滑动窗口把窗口切细,统计最近一段时间的请求,Sentinel 默认就是这个思路。漏桶让请求排队、以恒定速率流出,适合保护不能被打突发打崩的下游;令牌桶按固定速率放令牌、允许攒到桶容量,适合网关这种需要容忍短时突发的入口。Guava RateLimiter 是令牌桶,Nginx limit_req 是漏桶。多节点要全局限流就用 Redis + Lua,但要接受每次请求多一次网络往返,或者用本地桶加定期同步配额做折中。

把一门技术讲透到既懂也会用