土法炼钢兴趣小组的算法知识备份

限流算法:令牌桶 / 漏桶 / GCRA

目录

2021 年双十一,某电商平台的支付网关在零点瞬间收到了平时 500 倍的流量。后端数据库连接池在 3 秒内耗尽,级联故障在 10 秒内扩散到整个微服务集群。最终恢复花了 47 分钟,直接经济损失以亿计。

事后复盘的第一条改进措施:在网关层加限流。

限流不是什么高深的概念。它的本质就是一句话:控制单位时间内通过系统的请求数量。 但是,“如何优雅地控制”这个问题,催生出了一系列精巧的算法——从最朴素的计数器,到令牌桶、漏桶、GCRA,再到分布式场景下的 Redis + Lua 原子脚本。

这篇文章从最简单的方案讲起,逐步推导出每种算法的设计动机和数学本质,然后给出完整的 C 语言实现,最后讨论生产环境中的工程细节。

一、限流问题:为什么需要限流

限流的三个核心场景

API 网关层限流。 这是最常见的场景。面向公网的 API 必须防御恶意调用和突发流量。Stripe 的 API 限制是每秒 100 次请求;GitHub API 对未认证用户限制每小时 60 次。限流在这里的作用是保护后端服务不被压垮。

微服务间限流。 在微服务架构中,服务 A 调用服务 B,服务 B 调用服务 C。如果服务 A 突然产生大量请求,服务 B 和 C 可能级联崩溃。限流在这里的作用是隔离故障域,防止雪崩。

DDoS 防御。 分布式拒绝服务攻击的本质就是用海量请求耗尽目标资源。限流是 DDoS 防御的第一道防线。Cloudflare 在边缘节点上对每个 IP 做限流,在流量进入源站之前就将恶意请求拦截。

限流的度量维度

限流不仅仅是”每秒 N 个请求”这么简单。实际系统中,限流通常是多维度的:

维度 示例 适用场景
请求速率 100 req/s API 网关
并发数 最多 50 个并行请求 数据库连接池
带宽 100 Mbps CDN、流媒体
CPU 时间 每个请求最多 500ms CPU Serverless
请求体大小 单个请求最大 10MB 文件上传
费用 每月 $100 额度 云服务 API

本文主要讨论请求速率限流,这是最基础也最通用的场景。

限流的位置

在一个典型的请求链路中,限流可以发生在多个层次:

客户端 -> CDN/WAF -> 负载均衡 -> API 网关 -> 微服务 -> 数据库
           |            |           |           |
          L3/L4限流    连接数限流   L7限流     应用层限流

越靠前的限流越粗粒度但越高效(在网络层丢包比在应用层处理请求快几个数量级),越靠后的限流越精细但开销越大。好的系统设计会在多个层次做限流,形成纵深防御。

二、固定窗口计数器

最朴素的方案

固定窗口计数器是最直观的限流算法:把时间划分成固定长度的窗口(比如 1 秒),每个窗口维护一个计数器,每来一个请求计数器加 1,当计数器超过阈值时拒绝请求。

#include <time.h>
#include <stdint.h>
#include <stdbool.h>

typedef struct {
    int64_t  window_start;   /* 当前窗口起始时间(秒) */
    int64_t  count;          /* 当前窗口内的请求计数 */
    int64_t  limit;          /* 窗口内允许的最大请求数 */
    int64_t  window_size;    /* 窗口大小(秒) */
} fixed_window_t;

bool fixed_window_allow(fixed_window_t *fw) {
    int64_t now = time(NULL);
    int64_t window = now / fw->window_size * fw->window_size;

    if (window != fw->window_start) {
        fw->window_start = window;
        fw->count = 0;
    }

    if (fw->count < fw->limit) {
        fw->count++;
        return true;
    }
    return false;
}

代码极其简洁,只需要两个变量:窗口起始时间和计数器。时间复杂度 O(1),空间复杂度 O(1)。

边界突发问题

但固定窗口有一个严重的问题:边界突发。

假设限制是每秒 100 个请求。在第 0.9 秒到第 1.0 秒之间来了 100 个请求(窗口 1 的配额),然后在第 1.0 秒到第 1.1 秒之间又来了 100 个请求(窗口 2 的配额)。从任意一个窗口看,都没有超限。但在 0.9 秒到 1.1 秒这 0.2 秒的真实时间窗口内,实际通过了 200 个请求——是限制的 2 倍。

时间轴:
|------- 窗口 1 --------|------- 窗口 2 --------|
0.0                   1.0                     2.0
                 [100个请求][100个请求]
                  ^0.9s      ^1.1s
                 <--- 0.2s 内通过 200 个 --->

这不是理论问题。实际系统中,流量高峰经常出现在窗口边界附近(比如整点、整分),此时边界突发会导致后端瞬间收到两倍于预期的流量。

三、滑动窗口日志与滑动窗口计数器

滑动窗口日志

解决边界突发的一种方案是滑动窗口日志(Sliding Window Log):记录每个请求的精确时间戳,每次新请求到来时,统计过去一个窗口内的请求数量。

#include <stdlib.h>
#include <time.h>
#include <stdbool.h>

typedef struct {
    double  *timestamps;   /* 环形缓冲区,存储请求时间戳 */
    int      capacity;     /* 缓冲区容量 */
    int      head;         /* 头指针 */
    int      tail;         /* 尾指针 */
    int      count;        /* 当前请求数 */
    int      limit;        /* 窗口内允许的最大请求数 */
    double   window_size;  /* 窗口大小(秒) */
} sliding_log_t;

static double now_sec(void) {
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return ts.tv_sec + ts.tv_nsec * 1e-9;
}

/* 清理过期的时间戳 */
static void evict_expired(sliding_log_t *sl, double now) {
    double cutoff = now - sl->window_size;
    while (sl->count > 0 && sl->timestamps[sl->head] <= cutoff) {
        sl->head = (sl->head + 1) % sl->capacity;
        sl->count--;
    }
}

bool sliding_log_allow(sliding_log_t *sl) {
    double now = now_sec();
    evict_expired(sl, now);

    if (sl->count < sl->limit) {
        sl->timestamps[sl->tail] = now;
        sl->tail = (sl->tail + 1) % sl->capacity;
        sl->count++;
        return true;
    }
    return false;
}

滑动窗口日志的精度是完美的——它精确地知道过去一个窗口内有多少个请求。但代价是空间:需要存储窗口内每个请求的时间戳。如果限制是每秒 10 万个请求,就需要存储 10 万个时间戳(约 800KB)。对于每个用户、每个 API 端点都需要独立限流的场景,内存消耗会变得不可接受。

滑动窗口计数器

滑动窗口计数器(Sliding Window Counter)是精度与内存之间的折中方案。它结合了固定窗口的低内存和滑动窗口的准确性:

核心思想:用当前窗口和上一个窗口的加权计数来近似滑动窗口。

typedef struct {
    int64_t  prev_window;    /* 上一个窗口的起始时间 */
    int64_t  prev_count;     /* 上一个窗口的请求计数 */
    int64_t  curr_window;    /* 当前窗口的起始时间 */
    int64_t  curr_count;     /* 当前窗口的请求计数 */
    int64_t  limit;          /* 窗口内允许的最大请求数 */
    int64_t  window_size;    /* 窗口大小(秒) */
} sliding_counter_t;

bool sliding_counter_allow(sliding_counter_t *sc) {
    int64_t now = time(NULL);
    int64_t curr_win = now / sc->window_size * sc->window_size;

    if (curr_win != sc->curr_window) {
        sc->prev_count  = (curr_win - sc->curr_window == sc->window_size)
                          ? sc->curr_count : 0;
        sc->prev_window = sc->curr_window;
        sc->curr_window = curr_win;
        sc->curr_count  = 0;
    }

    /* 计算当前时刻在窗口内的位置比例 */
    double elapsed = (double)(now - curr_win) / sc->window_size;
    /* 加权估计:上一个窗口的"剩余"部分 + 当前窗口的计数 */
    double estimated = sc->prev_count * (1.0 - elapsed) + sc->curr_count;

    if (estimated < sc->limit) {
        sc->curr_count++;
        return true;
    }
    return false;
}

只需要 4 个变量(两个窗口的起始时间和计数),空间复杂度 O(1)。Cloudflare 的博客中证明,这种近似的误差不超过实际限制的 0.003%——在实际场景中完全可以接受。

三种窗口算法对比

算法 精度 空间 时间 边界突发
固定窗口 O(1) O(1) 最多 2x
滑动窗口日志 精确 O(N) O(N)
滑动窗口计数器 近似 O(1) O(1) 极小

四、漏桶算法

起源:ATM 网络

漏桶算法(Leaky Bucket)最早由 Jonathan Turner 在 1986 年提出,后来被 ITU-T 标准化,用于 ATM(Asynchronous Transfer Mode)网络中的流量整形。ITU-T I.371 建议书中定义了两种漏桶变体:

在限流语境下,我们通常讨论的是第二种——队列模型。

队列模型

漏桶的工作原理就像一个底部有小孔的水桶:

  1. 请求以任意速率流入桶中(加入队列)。
  2. 桶以恒定速率从底部漏出(处理请求)。
  3. 如果桶满了(队列已满),新来的请求被丢弃。

关键特性:无论输入多么突发,输出永远是恒定速率。

#include <stdbool.h>
#include <time.h>

typedef struct {
    double   water;          /* 桶中当前水量 */
    double   capacity;       /* 桶的容量(最大排队数) */
    double   leak_rate;      /* 漏出速率(请求/秒) */
    double   last_leak_time; /* 上次漏水时间 */
} leaky_bucket_t;

static double monotonic_sec(void) {
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return ts.tv_sec + ts.tv_nsec * 1e-9;
}

static void leak(leaky_bucket_t *lb) {
    double now = monotonic_sec();
    double elapsed = now - lb->last_leak_time;
    double leaked = elapsed * lb->leak_rate;

    lb->water -= leaked;
    if (lb->water < 0) lb->water = 0;
    lb->last_leak_time = now;
}

bool leaky_bucket_allow(leaky_bucket_t *lb) {
    leak(lb);

    if (lb->water < lb->capacity) {
        lb->water += 1.0;
        return true;
    }
    return false;   /* 桶满,拒绝 */
}

漏桶的局限

漏桶的恒定输出速率既是优点也是缺点。

优点:完美的流量整形。下游服务永远只看到恒定速率的请求流,不会出现突发。这在网络带宽管理场景中非常有价值。

缺点:无法利用空闲容量。假设系统限制每秒 100 个请求,过去 10 秒内一个请求都没有。此时突然来了 200 个请求,漏桶只会以每秒 100 的速率放行,另外 100 个要么排队等待要么被丢弃。但系统明明有 10 秒的空闲容量,完全有能力处理这 200 个请求。

这就是令牌桶要解决的问题。

令牌桶与漏桶对比

五、令牌桶算法

核心思想

令牌桶(Token Bucket)的思路恰好与漏桶相反:

  1. 以恒定速率 r 向桶中添加令牌。
  2. 桶的容量为 b(最大令牌数)。
  3. 每个请求消耗一个令牌。
  4. 如果桶中没有令牌,请求被拒绝(或等待)。

关键区别:当系统空闲时,令牌会积累(最多到 b 个)。当突发流量到来时,可以一次性消耗积累的令牌,允许短暂的突发。

令牌桶参数:
  r = 令牌产生速率(tokens/second)
  b = 桶容量(burst size)

稳态行为:
  平均速率 <= r
  瞬时突发 <= b
  突发持续时间 <= b/r 秒

惰性计算

实际实现中,不需要真的用一个定时器每隔 1/r 秒添加一个令牌。可以用惰性计算(lazy evaluation):在每次请求到来时,根据距上次访问的时间差,计算应该补充多少令牌。

typedef struct {
    double   tokens;         /* 当前令牌数 */
    double   capacity;       /* 桶容量(最大令牌数) */
    double   rate;           /* 令牌产生速率(tokens/sec) */
    double   last_refill;    /* 上次补充时间 */
} token_bucket_t;

static void refill(token_bucket_t *tb) {
    double now = monotonic_sec();
    double elapsed = now - tb->last_refill;
    double new_tokens = elapsed * tb->rate;

    tb->tokens += new_tokens;
    if (tb->tokens > tb->capacity)
        tb->tokens = tb->capacity;
    tb->last_refill = now;
}

bool token_bucket_allow(token_bucket_t *tb, double cost) {
    refill(tb);

    if (tb->tokens >= cost) {
        tb->tokens -= cost;
        return true;
    }
    return false;
}

注意 cost 参数——不同的请求可以消耗不同数量的令牌。比如 GET 请求消耗 1 个令牌,POST 请求消耗 5 个,文件上传消耗 10 个。这给了限流策略更多的灵活性。

双速率令牌桶(CIR/PIR)

在电信网络中(RFC 2698),令牌桶被扩展为双速率三色标记器(Two Rate Three Color Marker):

这实际上是两个令牌桶串联:

typedef struct {
    token_bucket_t  cir_bucket;   /* CIR 令牌桶 */
    token_bucket_t  pir_bucket;   /* PIR 令牌桶 */
} dual_rate_tb_t;

typedef enum { GREEN, YELLOW, RED } color_t;

color_t dual_rate_classify(dual_rate_tb_t *dr, double cost) {
    refill(&dr->pir_bucket);
    refill(&dr->cir_bucket);

    /* PIR 桶没令牌 -> 红色(丢弃) */
    if (dr->pir_bucket.tokens < cost)
        return RED;

    /* CIR 桶没令牌 -> 黄色(尽力转发) */
    if (dr->cir_bucket.tokens < cost) {
        dr->pir_bucket.tokens -= cost;
        return YELLOW;
    }

    /* 两个桶都有令牌 -> 绿色(保证转发) */
    dr->cir_bucket.tokens -= cost;
    dr->pir_bucket.tokens -= cost;
    return GREEN;
}

这种分层的流量分类在 QoS(Quality of Service)中非常有用:绿色流量进入高优先级队列,黄色流量进入普通队列,红色流量直接丢弃。

六、GCRA:通用信元速率算法

从令牌桶到虚拟调度

GCRA(Generic Cell Rate Algorithm)是 ITU-T I.371 标准中定义的算法,最初用于 ATM 网络的信元速率监管。它在数学上等价于令牌桶,但实现方式完全不同——更优雅,也更适合分布式场景。

GCRA 的核心概念是 TAT(Theoretical Arrival Time)——理论到达时间。它不维护令牌计数,而是维护一个”下一个请求最早应该到达的时间”。

思路很简单:如果限制是每秒 10 个请求(即每个请求的间隔是 0.1 秒),那么: - 第 1 个请求在 t=0 到达,允许。下一个请求最早应该在 t=0.1 到达。 - 第 2 个请求在 t=0.05 到达,比 TAT 早了 0.05 秒。但如果允许一定的突发(比如 5 个请求的突发窗口 = 0.5 秒),那么只要提前量不超过突发窗口就允许。 - TAT 更新为 max(TAT, now) + emission_interval。

GCRA 的两个参数

GCRA 参数:
  T = emission interval = 1/r(发送间隔)
  tau = burst tolerance = T * (b - 1)(突发容忍度)

等价的令牌桶参数:
  r = 令牌速率 = 1/T
  b = 桶容量 = tau/T + 1

GCRA 实现

typedef struct {
    double   tat;             /* Theoretical Arrival Time */
    double   emission_interval; /* T = 1/rate */
    double   burst_tolerance;   /* tau = T * (burst - 1) */
} gcra_t;

void gcra_init(gcra_t *g, double rate, int burst) {
    g->tat = 0;
    g->emission_interval = 1.0 / rate;
    g->burst_tolerance = g->emission_interval * (burst - 1);
}

bool gcra_allow(gcra_t *g) {
    double now = monotonic_sec();

    /* 新的 TAT:取 TAT 和当前时间的较大值,加上发送间隔 */
    double new_tat = (g->tat > now ? g->tat : now) + g->emission_interval;

    /* 如果新的 TAT 超过当前时间 + 突发容忍度,则拒绝 */
    if (new_tat - now > g->emission_interval + g->burst_tolerance) {
        return false;
    }

    g->tat = new_tat;
    return true;
}

注意这个实现只需要一个状态变量:TAT。与令牌桶的两个变量(令牌数 + 上次刷新时间)相比,GCRA 更加紧凑。在分布式场景中,这意味着只需要对一个值做原子操作。

GCRA 的直觉理解

把 GCRA 想象成一个日程表:

当系统空闲时,TAT 落后于当前时间。此时即使突发请求到来,TAT 也不会立刻超过容忍度——这就是 GCRA 允许突发的机制。空闲越久,积累的”信用”越多(但上限是 tau)。

七、数学等价性:令牌桶与 GCRA

等价性证明

令牌桶和 GCRA 在数学上是完全等价的。

状态映射。 设令牌桶在时刻 t 有 tokens(t) 个令牌,GCRA 的理论到达时间为 TAT(t)。令 T = 1/r,tau = T*(b-1)。两者的映射关系为:

TAT = t + T * (b - tokens(t))
tokens(t) = b - (TAT - t) / T

允许条件等价。 令牌桶要求 tokens(t) >= 1,代入映射:

b - (TAT - t) / T >= 1
(TAT - t) / T <= b - 1
TAT - t <= T * (b - 1) = tau

GCRA 的允许条件是 new_tat - t <= T + tau,其中 new_tat = max(TAT, t) + T。当 TAT <= t 时(系统空闲),new_tat - t = T <= T + tau,必然允许。当 TAT > t 时,new_tat - t = TAT - t + T,允许条件即 TAT - t <= tau,与上式一致。

更新规则等价。 令牌桶执行 tokens -= 1,GCRA 执行 TAT = max(TAT, t) + T。代入映射可验证两者产生的新状态一一对应。

何时选择哪种

场景 推荐算法 原因
单机限流 令牌桶 直觉清晰,调参容易
分布式限流 GCRA 只需原子操作一个值
网络 QoS 双速率令牌桶 支持多级分类
精确计量 GCRA 数学性质更好分析

八、分布式限流:Redis + Lua

单机限流的局限

前面所有算法都假设在单进程内运行。但在分布式系统中,请求可能命中不同的服务实例。如果每个实例独立限流,总体限制就是 N * limit(N 是实例数),这完全不是我们想要的。

分布式限流需要一个共享的状态存储。Redis 是最常见的选择,因为它快(微秒级延迟)、支持原子操作、运维成熟。

竞态条件

如果用 Redis 做分布式限流,最容易犯的错误是 GET-then-SET 模式:

# 错误示范:存在竞态条件
tokens = redis.get("rate:user:123")
if tokens > 0:
    redis.set("rate:user:123", tokens - 1)  # TOCTOU 漏洞
    allow()
else:
    reject()

两个请求可能同时读到 tokens = 1,然后都减 1,实际消耗了 2 个令牌但只扣了 1 个。这是经典的 TOCTOU(Time Of Check to Time Of Use)漏洞。

解决方案:用 Lua 脚本保证原子性。Redis 保证 Lua 脚本的执行是原子的——在脚本执行期间不会有其他命令插入。

令牌桶 Redis + Lua 实现

-- token_bucket.lua
-- KEYS[1] = rate limiter key
-- ARGV[1] = capacity (burst size)
-- ARGV[2] = rate (tokens per second)
-- ARGV[3] = now (current timestamp in microseconds)
-- ARGV[4] = cost (tokens to consume, default 1)

local key       = KEYS[1]
local capacity  = tonumber(ARGV[1])
local rate      = tonumber(ARGV[2])
local now       = tonumber(ARGV[3])
local cost      = tonumber(ARGV[4]) or 1

-- 获取当前状态
local data = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens      = tonumber(data[1]) or capacity
local last_refill = tonumber(data[2]) or now

-- 补充令牌
local elapsed    = math.max(0, now - last_refill) / 1000000.0
local new_tokens = math.min(capacity, tokens + elapsed * rate)

-- 判断是否允许
local allowed = 0
local new_cost = cost
if new_tokens >= cost then
    new_tokens = new_tokens - cost
    allowed = 1
end

-- 更新状态
redis.call('HMSET', key,
    'tokens', tostring(new_tokens),
    'last_refill', tostring(now))

-- 设置过期时间,防止无限占用内存
-- 过期时间 = 桶填满所需时间 * 2
local ttl = math.ceil(capacity / rate * 2)
redis.call('EXPIRE', key, ttl)

-- 返回:[是否允许, 剩余令牌数]
return {allowed, tostring(new_tokens)}

GCRA Redis + Lua 实现

GCRA 的 Redis 实现更加简洁,因为只需要一个值(TAT):

-- gcra.lua
-- KEYS[1] = rate limiter key
-- ARGV[1] = emission_interval (microseconds)
-- ARGV[2] = burst_tolerance (microseconds)
-- ARGV[3] = now (current timestamp in microseconds)

local key               = KEYS[1]
local emission_interval = tonumber(ARGV[1])
local burst_tolerance   = tonumber(ARGV[2])
local now               = tonumber(ARGV[3])

local tat = tonumber(redis.call('GET', key)) or now

-- new_tat = max(tat, now) + emission_interval
local new_tat = math.max(tat, now) + emission_interval

-- 允许条件: new_tat - now <= emission_interval + burst_tolerance
local allow_at = now - burst_tolerance
local diff = new_tat - now

if diff > emission_interval + burst_tolerance then
    -- 限流:计算需要等待的时间
    local retry_after = diff - emission_interval - burst_tolerance
    return {0, tostring(retry_after)}
end

-- 允许:更新 TAT
redis.call('SET', key, tostring(new_tat))

-- TTL: 突发窗口 + 发送间隔,转换为秒并向上取整
local ttl = math.ceil((emission_interval + burst_tolerance) / 1000000)
redis.call('EXPIRE', key, ttl + 1)

-- 返回: [是否允许, 剩余突发容量(微秒)]
local remaining = emission_interval + burst_tolerance - diff
return {1, tostring(remaining)}

Redis 集群下的注意事项

  1. 时钟偏差。 不同客户端的时钟可能不同步。可由客户端传入时间戳并做 NTP 同步,也可使用 redis.call('TIME')(但在 Lua 脚本中有 non-deterministic 副作用)。

  2. 故障转移。 Redis 主从切换时可能丢失最近的写入(异步复制),限流器可能短暂”忘记”已消耗的令牌。安全敏感场景可用 WAIT 命令等待从节点确认。

  3. 热点键。 全局限流键可能成为热点。解决方案:将全局限制 R 分配到 N 个键上,每个键限制 R/N。

九、完整 C 实现

令牌桶完整实现

/* token_bucket.c -- 完整的令牌桶实现 */
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <string.h>
#include <time.h>
#include <math.h>
#include <pthread.h>
#include <errno.h>

/* ========== 时间工具 ========== */

static double clock_now(void) {
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9;
}

/* ========== 令牌桶结构 ========== */

typedef struct token_bucket {
    double           tokens;       /* 当前令牌数 */
    double           capacity;     /* 桶容量(最大令牌数) */
    double           rate;         /* 令牌产生速率(tokens/sec) */
    double           last_refill;  /* 上次补充时间戳 */
    pthread_mutex_t  lock;         /* 线程安全锁 */
} token_bucket_t;

/* 初始化令牌桶 */
int tb_init(token_bucket_t *tb, double rate, double capacity) {
    if (rate <= 0 || capacity <= 0) {
        errno = EINVAL;
        return -1;
    }

    tb->tokens      = capacity;   /* 初始满桶 */
    tb->capacity    = capacity;
    tb->rate        = rate;
    tb->last_refill = clock_now();

    if (pthread_mutex_init(&tb->lock, NULL) != 0)
        return -1;

    return 0;
}

/* 销毁令牌桶 */
void tb_destroy(token_bucket_t *tb) {
    pthread_mutex_destroy(&tb->lock);
}

/* 内部:补充令牌(调用者持有锁) */
static void tb_refill_locked(token_bucket_t *tb) {
    double now     = clock_now();
    double elapsed = now - tb->last_refill;

    if (elapsed > 0) {
        tb->tokens += elapsed * tb->rate;
        if (tb->tokens > tb->capacity)
            tb->tokens = tb->capacity;
        tb->last_refill = now;
    }
}

/* 尝试消耗令牌 */
bool tb_consume(token_bucket_t *tb, double cost) {
    if (cost <= 0) return true;

    pthread_mutex_lock(&tb->lock);
    tb_refill_locked(tb);

    bool allowed = false;
    if (tb->tokens >= cost) {
        tb->tokens -= cost;
        allowed = true;
    }

    pthread_mutex_unlock(&tb->lock);
    return allowed;
}

/* 查询当前令牌数(不消耗) */
double tb_tokens(token_bucket_t *tb) {
    pthread_mutex_lock(&tb->lock);
    tb_refill_locked(tb);
    double t = tb->tokens;
    pthread_mutex_unlock(&tb->lock);
    return t;
}

/* 查询需要等待多久才能获得指定数量的令牌 */
double tb_wait_time(token_bucket_t *tb, double cost) {
    pthread_mutex_lock(&tb->lock);
    tb_refill_locked(tb);

    double wait = 0;
    if (tb->tokens < cost) {
        double deficit = cost - tb->tokens;
        wait = deficit / tb->rate;
    }

    pthread_mutex_unlock(&tb->lock);
    return wait;
}

/* 阻塞等待直到获得令牌 */
bool tb_consume_wait(token_bucket_t *tb, double cost, double timeout_sec) {
    double deadline = clock_now() + timeout_sec;

    while (1) {
        if (tb_consume(tb, cost))
            return true;

        double wait = tb_wait_time(tb, cost);
        double remaining = deadline - clock_now();

        if (remaining <= 0)
            return false;  /* 超时 */

        if (wait > remaining)
            wait = remaining;

        /* 精确等待 */
        struct timespec ts;
        ts.tv_sec  = (time_t)wait;
        ts.tv_nsec = (long)((wait - ts.tv_sec) * 1e9);
        nanosleep(&ts, NULL);
    }
}

/* ========== 测试 ========== */

int main(void) {
    token_bucket_t tb;
    int i, allowed;

    /* 基础测试:10 tokens/sec, burst 5 */
    tb_init(&tb, 10.0, 5.0);
    printf("=== 基础测试 ===\n");
    for (i = 0; i < 6; i++) {
        bool ok = tb_consume(&tb, 1.0);
        printf("  请求 %d: %s (剩余 %.1f)\n",
               i + 1, ok ? "允许" : "拒绝", tb_tokens(&tb));
    }
    struct timespec w1 = {0, 300000000};  /* 等待 0.3s */
    nanosleep(&w1, NULL);
    printf("  等待 0.3s 后,令牌数: %.1f\n", tb_tokens(&tb));
    tb_destroy(&tb);

    /* 突发测试:100 tokens/sec, burst 20 */
    tb_init(&tb, 100.0, 20.0);
    printf("\n=== 突发测试 ===\n");
    struct timespec w2 = {1, 0};
    nanosleep(&w2, NULL);
    for (i = 0, allowed = 0; i < 30; i++)
        if (tb_consume(&tb, 1.0)) allowed++;
    printf("  发送 30 请求,允许 %d 个(预期约 20)\n", allowed);
    tb_destroy(&tb);

    /* 可变消耗测试 */
    tb_init(&tb, 50.0, 10.0);
    printf("\n=== 可变消耗测试 ===\n");
    printf("  消耗 3: %s\n", tb_consume(&tb, 3.0) ? "允许" : "拒绝");
    printf("  消耗 5: %s\n", tb_consume(&tb, 5.0) ? "允许" : "拒绝");
    printf("  消耗 5: %s (预期拒绝)\n", tb_consume(&tb, 5.0) ? "允许" : "拒绝");
    tb_destroy(&tb);

    printf("\n所有测试完成。\n");
    return 0;
}

GCRA 完整实现

/* gcra.c -- 完整的 GCRA 实现 */
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <string.h>
#include <time.h>
#include <math.h>
#include <pthread.h>
#include <errno.h>

/* ========== 时间工具 ========== */

static double clock_now(void) {
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9;
}

/* ========== GCRA 结构 ========== */

typedef struct gcra {
    double           tat;                /* Theoretical Arrival Time */
    double           emission_interval;  /* T = 1/rate */
    double           burst_tolerance;    /* tau = T * (burst - 1) */
    double           rate;               /* 请求速率 */
    int              burst;              /* 突发容量 */
    pthread_mutex_t  lock;               /* 线程安全锁 */
} gcra_t;

/* 初始化 GCRA */
int gcra_init(gcra_t *g, double rate, int burst) {
    if (rate <= 0 || burst <= 0) {
        errno = EINVAL;
        return -1;
    }

    g->emission_interval = 1.0 / rate;
    g->burst_tolerance   = g->emission_interval * (burst - 1);
    g->tat               = 0;   /* 初始 TAT 为 0,表示空闲 */
    g->rate              = rate;
    g->burst             = burst;

    if (pthread_mutex_init(&g->lock, NULL) != 0)
        return -1;

    return 0;
}

/* 销毁 GCRA */
void gcra_destroy(gcra_t *g) {
    pthread_mutex_destroy(&g->lock);
}

/* 请求结果 */
typedef struct {
    bool    allowed;       /* 是否允许 */
    double  retry_after;   /* 如果拒绝,需要等待的秒数 */
    double  remaining;     /* 剩余突发容量(等价令牌数) */
    double  reset_after;   /* TAT 回到当前时间之前需要的秒数 */
} gcra_result_t;

/* 判断请求是否允许 */
gcra_result_t gcra_check(gcra_t *g) {
    gcra_result_t result;
    double now = clock_now();

    pthread_mutex_lock(&g->lock);

    /* 如果 TAT 落后于当前时间,说明系统空闲 */
    double tat_or_now = g->tat > now ? g->tat : now;
    double new_tat = tat_or_now + g->emission_interval;
    double diff = new_tat - now;
    double limit = g->emission_interval + g->burst_tolerance;

    if (diff > limit) {
        /* 拒绝:TAT 太超前 */
        result.allowed     = false;
        result.retry_after = diff - limit;
        result.remaining   = 0;
        result.reset_after = g->tat - now;
    } else {
        /* 允许:更新 TAT */
        g->tat = new_tat;
        result.allowed     = true;
        result.retry_after = 0;
        result.remaining   = (limit - diff) / g->emission_interval;
        result.reset_after = new_tat - now;
    }

    pthread_mutex_unlock(&g->lock);
    return result;
}

/* 简单的允许/拒绝判断 */
bool gcra_allow(gcra_t *g) {
    gcra_result_t r = gcra_check(g);
    return r.allowed;
}

/* 阻塞等待直到请求被允许 */
bool gcra_allow_wait(gcra_t *g, double timeout_sec) {
    double deadline = clock_now() + timeout_sec;

    while (1) {
        gcra_result_t r = gcra_check(g);
        if (r.allowed)
            return true;

        if (clock_now() + r.retry_after > deadline)
            return false;  /* 超时 */

        struct timespec ts;
        ts.tv_sec  = (time_t)r.retry_after;
        ts.tv_nsec = (long)((r.retry_after - ts.tv_sec) * 1e9);
        nanosleep(&ts, NULL);
    }
}

/* 查询当前状态(不消耗) */
double gcra_remaining(gcra_t *g) {
    double now = clock_now();
    pthread_mutex_lock(&g->lock);

    double tat_or_now = g->tat > now ? g->tat : now;
    double diff = tat_or_now + g->emission_interval - now;
    double limit = g->emission_interval + g->burst_tolerance;
    double remaining = (limit - diff) / g->emission_interval;

    if (remaining < 0) remaining = 0;
    pthread_mutex_unlock(&g->lock);
    return remaining;
}

/* ========== 测试 ========== */

int main(void) {
    gcra_t g;
    int i, allowed;

    /* 基础测试:10 req/s, burst 5 */
    gcra_init(&g, 10.0, 5);
    printf("=== GCRA 基础测试 ===\n");
    for (i = 0; i < 7; i++) {
        gcra_result_t r = gcra_check(&g);
        printf("  请求 %d: %s (剩余 %.1f)\n",
               i + 1, r.allowed ? "允许" : "拒绝", r.remaining);
    }
    gcra_destroy(&g);

    /* 恢复测试 */
    gcra_init(&g, 10.0, 5);
    printf("\n=== GCRA 恢复测试 ===\n");
    for (i = 0; i < 5; i++) gcra_allow(&g);
    printf("  消耗 5 个后: 剩余 %.1f\n", gcra_remaining(&g));
    struct timespec w = {0, 300000000};
    nanosleep(&w, NULL);
    printf("  等待 0.3s 后: 剩余 %.1f\n", gcra_remaining(&g));
    for (i = 0, allowed = 0; i < 5; i++)
        if (gcra_allow(&g)) allowed++;
    printf("  再发 5 个,允许 %d 个(预期约 3)\n", allowed);
    gcra_destroy(&g);

    /* 等价性测试 */
    gcra_init(&g, 20.0, 10);
    printf("\n=== 等价性测试 ===\n");
    int total = 0; allowed = 0;
    double start = clock_now();
    while (clock_now() - start < 1.0) {
        total++;
        if (gcra_allow(&g)) allowed++;
        struct timespec d = {0, 10000000};
        nanosleep(&d, NULL);
    }
    printf("  1s 内发送 %d,允许 %d(预期约 30)\n", total, allowed);
    gcra_destroy(&g);

    printf("\n所有 GCRA 测试完成。\n");
    return 0;
}

编译和运行

# 编译令牌桶
gcc -O2 -Wall -o token_bucket token_bucket.c -lpthread -lm

# 编译 GCRA
gcc -O2 -Wall -o gcra gcra.c -lpthread -lm

# 运行测试
./token_bucket
./gcra

十、层级限流:Linux tc/HTB

从单级到层级

实际系统中的限流往往不是单层的。考虑一个 SaaS 平台:

这是一个树状的层级结构。Linux 内核的流量控制子系统(tc)中的 HTB(Hierarchical Token Bucket)就是解决这个问题的。

HTB 工作原理

HTB 将令牌桶组织成一棵树。每个节点有两个速率参数:

         root (100 Mbps)
         /           \
    企业 A            企业 B
  rate=40M          rate=40M
  ceil=80M          ceil=80M
   /    \             /    \
用户1   用户2      用户3   用户4
r=10M   r=10M      r=10M   r=10M
c=30M   c=30M      c=30M   c=30M

当企业 B 完全空闲时,企业 A 可以借用企业 B 的带宽,最多使用到 ceil=80M。当企业 B 恢复活跃时,带宽会被公平地回收。

tc 命令配置示例

# 创建 HTB 根队列,总带宽 100Mbps
tc qdisc add dev eth0 root handle 1: htb default 30
tc class add dev eth0 parent 1: classid 1:1 htb rate 100mbit burst 15k

# 企业 A:保证 40Mbps,最高 80Mbps
tc class add dev eth0 parent 1:1 classid 1:10 htb \
    rate 40mbit ceil 80mbit burst 15k

# 企业 A 下用户 1:保证 10Mbps,最高 30Mbps
tc class add dev eth0 parent 1:10 classid 1:11 htb \
    rate 10mbit ceil 30mbit burst 10k

# 过滤器:将流量分配到对应的类
tc filter add dev eth0 parent 1: protocol ip prio 1 \
    u32 match ip src 10.0.1.0/24 flowid 1:11

带宽借用的数学

HTB 中节点有三个状态:

状态 含义 令牌桶关系
CAN_SEND rate 桶有令牌 保证速率内
MAY_BORROW rate 桶空,ceil 桶有令牌 可以借用父节点带宽
CANT_SEND ceil 桶也空了 必须等待

当一个节点处于 MAY_BORROW 状态时,它从父节点”借”令牌。这个借用是递归的——如果父节点也没有多余令牌,就继续向上借直到根节点。这种设计用令牌桶一个简单的抽象,同时实现了保证带宽、带宽共享和上限控制。

十一、生产实践

Nginx limit_req

Nginx 的 limit_req 模块使用的是漏桶算法:

http {
    # 定义限流区域:10MB 共享内存,每个 IP 每秒 10 个请求
    limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

    server {
        location /api/ {
            # burst=20 允许 20 个请求排队
            # nodelay 表示不等待,直接处理突发请求
            limit_req zone=api burst=20 nodelay;

            # 返回 429 而不是默认的 503
            limit_req_status 429;

            proxy_pass http://backend;
        }
    }
}

关键参数解释:

Envoy Rate Limiting

Envoy 使用独立的限流服务(rate limit service),通过 gRPC 与数据面通信。配置示例:

http_filters:
- name: envoy.filters.http.ratelimit
  typed_config:
    "@type": type.googleapis.com/envoy.extensions.filters.http.ratelimit.v3.RateLimit
    domain: production
    rate_limit_service:
      grpc_service:
        envoy_grpc:
          cluster_name: rate_limit_service

这种架构的优势是限流逻辑与数据面解耦,可以灵活地实现复杂的限流策略。劣势是引入了额外的网络延迟和故障点。

Cloudflare 的边缘限流

Cloudflare 在全球 300+ 个边缘节点上做限流。他们不追求全局一致:每个节点独立做限流(滑动窗口计数器),全局限制按权重分配到各节点,节点之间异步交换统计信息并定期调整配额。这是最终一致的方案——短暂的超限可以接受,因为精确一致性的代价(跨大洲的同步通信)太高。

Stripe API 限流

Stripe 的限流策略是公开透明的,也是业界的标杆:

HTTP/1.1 429 Too Many Requests
Retry-After: 1
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1620000000

Stripe 使用令牌桶算法,并在 HTTP 响应头中返回限流信息:

Header 含义
X-RateLimit-Limit 窗口内的总配额
X-RateLimit-Remaining 剩余配额
X-RateLimit-Reset 配额重置的 Unix 时间戳
Retry-After 建议的重试等待时间(秒)

客户端可以据此实现自适应限流:配额充足时全速请求,配额紧张时按比例降速,配额耗尽时等到重置。这种客户端协作式的限流,远比服务端单方面拒绝优雅。

十二、工程陷阱与个人观点

工程陷阱总结

陷阱 说明 解决方案
时钟回拨 NTP 调整导致时间回退,令牌桶可能产生负的 elapsed 使用 CLOCK_MONOTONIC 而不是 CLOCK_REALTIME
浮点精度 长时间运行后浮点累积误差 定期用整数重置;或全程使用整数微秒
整数溢出 微秒级时间戳用 int32 会在约 35 分钟后溢出 使用 int64_t 或 uint64_t
初始突发 令牌桶初始满桶,第一秒允许 burst + rate 个请求 按需设置初始令牌数为 0
热重启丢状态 进程重启后限流器状态清零 持久化到 Redis 或共享内存
多核竞争 pthread_mutex 在高并发下成为瓶颈 使用 per-thread 令牌桶 + 周期性同步
Redis 延迟毛刺 Redis 偶发的慢查询导致限流判断延迟 本地缓存 + 异步同步;设置合理超时
限流风暴 大量客户端同时重试 客户端添加随机 jitter
粒度不匹配 全局限流无法区分正常用户和恶意用户 多维度限流:IP + 用户 + API
降级策略缺失 限流服务本身挂了怎么办 fail-open 或 fail-closed,取决于业务
Lua 脚本阻塞 复杂的 Lua 脚本阻塞 Redis 主线程 保持脚本简短;避免循环
分布式时钟偏差 不同节点时钟不同步 使用服务端时间或 NTP 同步

个人观点

关于算法选择。 我见过太多团队在限流算法的选择上纠结不已。实话说,对于 99% 的场景,滑动窗口计数器就够了。令牌桶和 GCRA 主要的优势在于”允许突发”——如果你的业务确实需要允许短暂的突发,那么选择令牌桶或 GCRA。否则,别过度设计。

关于分布式限流的精度。 很多人追求分布式限流的精确一致性。但限流本身就是一个近似的保护机制,不是计费系统。如果你的限制是 1000 req/s,实际通过了 1050 个,这真的有问题吗?Cloudflare 的做法是对的——最终一致就够了。

关于 fail-open vs fail-closed。 当限流服务本身出现故障时,应该放行所有请求还是拒绝所有请求?如果是支付系统,fail-closed 更安全。如果是内容展示页面,fail-open 更好。没有标准答案,取决于业务优先级。

关于客户端限流。 服务端限流是最后的防线,但好的系统设计应该让限流尽量少触发。客户端应该主动限流:读取 X-RateLimit-Remaining 头,在接近限制时自动降速。

关于令牌桶和 GCRA 的工程选择。 单机用令牌桶,概念直观、调试方便。分布式用 GCRA,只需原子操作一个值,Lua 脚本更短,出 bug 的概率更低。

关于限流的位置。 限流应该尽量前置且分层防御:CDN 做 IP 级限流,网关做用户级限流,应用层做 API 级限流。在 CDN 层拦截恶意请求的成本是纳秒级的,在应用层是毫秒级的,在数据库层才发现过载的成本可能是秒级的。

算法选择决策树

需要限流
  |
  +-- 只需要粗粒度控制?
  |     |
  |     +-- 是 -> 固定窗口计数器(最简单)
  |     |
  |     +-- 否 -> 需要允许突发?
  |                  |
  |                  +-- 否 -> 滑动窗口计数器(精确且省内存)
  |                  |
  |                  +-- 是 -> 分布式?
  |                              |
  |                              +-- 否 -> 令牌桶(直观)
  |                              |
  |                              +-- 是 -> GCRA + Redis Lua(原子操作最少)

参考资料

  1. Turner, J. S. “New Directions in Communications (or Which Way to the Information Age?)” IEEE Communications Magazine, 1986.
  2. ITU-T Recommendation I.371, “Traffic Control and Congestion Control in B-ISDN,” 2004.
  3. RFC 2697, “A Single Rate Three Color Marker,” 1999.
  4. RFC 2698, “A Two Rate Three Color Marker,” 1999.
  5. Hubert, B. “Linux Advanced Routing and Traffic Control HOWTO.”
  6. Cloudflare Blog, “How We Built Rate Limiting Capable of Scaling to Millions of Domains,” 2017.
  7. Stripe API Documentation, “Rate Limiting,” https://stripe.com/docs/rate-limits.
  8. Envoy Proxy Documentation, “Rate Limit Service,” https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/rate_limit_filter.

上一篇: 滑动窗口与流控 下一篇: 负载均衡算法

相关阅读: - 滑动窗口与流控 - TCP 拥塞控制


By .