现代 Web 应用与分布式 API 时刻面临着高并发流量波动的严峻考验。无论是营销活动带来的突发流量洪峰、网络爬虫的高频采集、自动化系统的重试风暴,还是恶意分布式拒绝服务(DoS)攻击,缺乏流量整形(Traffic Shaping)保护的 API 都是极其脆弱的系统单点。
**限流(Rate Limiting)与节流(Throttling)**是保障系统高可用性的基石架构。限流主要控制“客户端在特定时间窗口内允许发起的最大请求总数”,而节流则主动调控“系统的整体处理吞吐与速率”,以保护下游数据库与核心服务,并维护多租户之间的计算资源公平性。
本文将深度对比主流限流算法的数学模型,分析分布式架构下的并发瓶颈,并基于 Redis 与 Lua 脚本实现一套生产级的高性能原子令牌桶限流引擎。
主流限流算法核心对比
每种限流算法在计算复杂度、内存占用以及突发流量容忍度之间均有各自的工程权衡。
+---------------------------+-------------------+--------------------+------------------------+
| 算法类型 | 内存空间复杂度 | 突发流量承载能力 | 窗口边界计算精度 |
+---------------------------+-------------------+--------------------+------------------------+
| 固定窗口计数器 (Fixed) | 每客户端 O(1) | 极易产生 2倍突发 | 窗口临界点存在严重漏洞 |
| 滑动窗口日志 (Log) | 每请求 O(N) | 严格平滑精准 | 绝对完美的时间精度 |
| 滑动窗口计数器 (Counter) | 每客户端 O(1) | 平滑加权平均 | 近似估算(约 99% 精度)|
| 令牌桶 (Token Bucket) | 每客户端 O(1) | 允许容量 C 内突发 | 恒定速度精确补充 |
| 漏桶 (Leaky Bucket) | 队列深度 O(N) | 强制平滑恒定流出 | 队列满载时直接丢弃 |
+---------------------------+-------------------+--------------------+------------------------+
1. 固定窗口计数器 (Fixed Window Counter)
将时间划分为固定的离散区间(例如 60 秒),每个请求累加计数器。当计数超过配额 $L$ 时拒绝后续请求。
- 边界临界漏洞: 若客户端在第 59 秒发送 100 次请求,紧接着在第 61 秒又发送 100 次请求,系统在短短 2 秒内处理了 200 次请求,瞬时负载超出预设容量一倍,极易导致下游服务雪崩。
2. 令牌桶算法 (Token Bucket)
作为当前 AWS API Gateway、Cloudflare 等主流网关的事实标准,令牌桶拥有固定容量 $C$,并以恒定速率 $R$(每秒填充令牌数)向桶内注入令牌。
每个有效请求必须获取并扣减 1 个令牌方可执行;若桶内令牌耗尽,服务端直接返回 HTTP 429 Too Many Requests。该机制既保障了长期稳态速率不超过 $R$,又优雅支持了容量 $C$ 之内的瞬时突发请求。
生产级实战:Redis + Lua 脚本原子令牌桶
在多节点的分布式无状态服务中,如果在各节点本地内存中独立计数会导致限流配额被严重稀释。将状态下沉到 Redis 是标准做法,但朴素的 Read-Modify-Write 存在严重的并发竞态条件(Race Condition)。
通过将令牌计算与扣减逻辑封装在原子的 Redis Lua 脚本中,可确保绝对的数据一致性:
-- KEYS[1]: 限流桶键名 (如 "ratelimit:user_1024")
-- ARGV[1]: 桶最大容量 (burst limit)
-- ARGV[2]: 每秒令牌填充速率
-- ARGV[3]: 当前 Unix 时间戳 (包含毫秒的小数)
-- ARGV[4]: 消耗令牌数 (通常为 1)
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local data = redis.call("HMGET", key, "tokens", "last_updated")
local tokens = tonumber(data[1])
local last_updated = tonumber(data[2])
if tokens == nil then
tokens = capacity
last_updated = now
else
local elapsed = math.max(0, now - last_updated)
tokens = math.min(capacity, tokens + (elapsed * refill_rate))
last_updated = now
end
if tokens >= requested then
tokens = tokens - requested
redis.call("HMSET", key, "tokens", tokens, "last_updated", last_updated)
local ttl = math.ceil(capacity / refill_rate) * 2
redis.call("EXPIRE", key, ttl)
return {1, math.floor(tokens)} -- 放行: [1, 剩余令牌数]
else
redis.call("HMSET", key, "tokens", tokens, "last_updated", last_updated)
return {0, math.floor(tokens)} -- 拦截: [0, 剩余令牌数]
end
TypeScript 中间件集成示例
import { createClient } from 'redis';
const redis = createClient({ url: process.env.REDIS_URL });
export interface RateLimitResult {
allowed: boolean;
remainingTokens: number;
retryAfterSeconds?: number;
}
export async function checkRateLimit(
clientId: string,
capacity: number = 60,
refillRate: number = 1.0 // 1 令牌/秒 = 持续 60 次/分钟
): Promise<RateLimitResult> {
const key = `ratelimit:${clientId}`;
const now = Date.now() / 1000;
const [allowed, remaining] = (await redis.evalSha(LUA_SCRIPT_SHA, {
keys: [key],
arguments: [
capacity.toString(),
refillRate.toString(),
now.toString(),
'1'
],
})) as [number, number];
if (allowed === 1) {
return { allowed: true, remainingTokens: remaining };
}
const retryAfter = Math.ceil((1 - remaining) / refillRate);
return {
allowed: false,
remainingTokens: 0,
retryAfterSeconds: Math.max(1, retryAfter),
};
}
标准化 HTTP 响应头
向客户端透明传递限流元数据,能够指导前端与第三方 API 消费者合理执行退避重试(Exponential Backoff):
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 12
RateLimit-Limit: 60
RateLimit-Remaining: 0
RateLimit-Reset: 12
{
"error": "rate_limit_exceeded",
"message": "已达到请求速率上限,请稍后重试。",
"retry_after_seconds": 12
}