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 建议书中定义了两种漏桶变体:
- 漏桶作为计量器(Leaky Bucket as a Meter):用于流量监管(policing),决定数据包是否合规。
- 漏桶作为队列(Leaky Bucket as a Queue):用于流量整形(shaping),将突发流量平滑为恒定速率。
在限流语境下,我们通常讨论的是第二种——队列模型。
队列模型
漏桶的工作原理就像一个底部有小孔的水桶:
- 请求以任意速率流入桶中(加入队列)。
- 桶以恒定速率从底部漏出(处理请求)。
- 如果桶满了(队列已满),新来的请求被丢弃。
关键特性:无论输入多么突发,输出永远是恒定速率。
#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)的思路恰好与漏桶相反:
- 以恒定速率 r 向桶中添加令牌。
- 桶的容量为 b(最大令牌数)。
- 每个请求消耗一个令牌。
- 如果桶中没有令牌,请求被拒绝(或等待)。
关键区别:当系统空闲时,令牌会积累(最多到 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):
- CIR(Committed Information Rate):承诺信息速率。低于 CIR 的流量标记为绿色,保证转发。
- PIR(Peak Information Rate):峰值信息速率。CIR 到 PIR 之间的流量标记为黄色,尽力转发。高于 PIR 的流量标记为红色,直接丢弃。
这实际上是两个令牌桶串联:
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 是”日程表上下一个空闲时间点”。
- 每个请求需要占用 T 时间的处理槽位。
- 如果请求到达时日程表已经排满(TAT 远超当前时间),就拒绝。
- “排满”的定义是 TAT 超前量超过突发容忍度 tau。
当系统空闲时,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 集群下的注意事项
时钟偏差。 不同客户端的时钟可能不同步。可由客户端传入时间戳并做 NTP 同步,也可使用
redis.call('TIME')(但在 Lua 脚本中有 non-deterministic 副作用)。故障转移。 Redis 主从切换时可能丢失最近的写入(异步复制),限流器可能短暂”忘记”已消耗的令牌。安全敏感场景可用 WAIT 命令等待从节点确认。
热点键。 全局限流键可能成为热点。解决方案:将全局限制 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 平台:
- 全局限制:所有用户总共 100,000 req/s
- 企业用户限制:每个企业 10,000 req/s
- 单用户限制:每个用户 1,000 req/s
- 单 API 限制:每个 API 端点 500 req/s
这是一个树状的层级结构。Linux 内核的流量控制子系统(tc)中的 HTB(Hierarchical Token Bucket)就是解决这个问题的。
HTB 工作原理
HTB 将令牌桶组织成一棵树。每个节点有两个速率参数:
- rate:保证速率。父节点保证子节点至少能获得这个速率。
- ceil:上限速率。子节点在有空闲带宽时最多能使用的速率。
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;
}
}
}
关键参数解释:
- rate=10r/s:漏桶的漏出速率。
- burst=20:漏桶的容量。
- nodelay:这个参数很重要。没有
nodelay时,突发请求会被排队延迟处理(真正的漏桶行为)。有nodelay时,只要桶中有空间,请求立即转发——这实际上变成了令牌桶的行为。
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(原子操作最少)
参考资料
- Turner, J. S. “New Directions in Communications (or Which Way to the Information Age?)” IEEE Communications Magazine, 1986.
- ITU-T Recommendation I.371, “Traffic Control and Congestion Control in B-ISDN,” 2004.
- RFC 2697, “A Single Rate Three Color Marker,” 1999.
- RFC 2698, “A Two Rate Three Color Marker,” 1999.
- Hubert, B. “Linux Advanced Routing and Traffic Control HOWTO.”
- Cloudflare Blog, “How We Built Rate Limiting Capable of Scaling to Millions of Domains,” 2017.
- Stripe API Documentation, “Rate Limiting,” https://stripe.com/docs/rate-limits.
- Envoy Proxy Documentation, “Rate Limit Service,” https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/rate_limit_filter.