为什么你需要限流?
在微服务架构和 API 设计中,限流(Rate Limiting)是保障系统稳定性的关键技术。想象一下,你的服务突然被恶意请求或异常流量冲击,如果没有限流,数据库连接池可能被耗尽,CPU 飙升,最终导致整个服务不可用。限流就像交通管制,它控制请求的速率,确保系统负载在可承受范围内。
常见的限流场景包括:
- 保护后端服务免受突发流量冲击
- 防止 API 被滥用(如爬虫、暴力破解)
- 保障多租户系统中每个租户的公平使用
- 平滑流量,避免高峰时段过载
本文将带你深入理解四种经典限流算法的原理与实现,并给出基于 Node.js 和 Redis 的实战代码,帮助你构建高可用的限流模块。
限流算法概览
限流算法本质上是对请求速率的控制,常见的有四种:
- 固定窗口(Fixed Window):简单,但存在临界问题。
- 滑动窗口(Sliding Window):解决固定窗口的临界问题,但实现稍复杂。
- 漏桶(Leaky Bucket):恒定速率处理请求,适合平滑流量。
- 令牌桶(Token Bucket):允许一定程度的突发流量,是最常用的算法。
下面我们逐一深入。
1. 固定窗口算法
原理
固定窗口将时间划分为固定大小的窗口(如 1 秒),每个窗口内允许一定数量的请求。窗口边界是固定的(如从 0 秒到 1 秒,1 秒到 2 秒)。
实现
以下是一个简单的 Node.js 实现:
class FixedWindowLimiter {
constructor(windowSizeMs, maxRequests) {
this.windowSizeMs = windowSizeMs;
this.maxRequests = maxRequests;
this.windowStart = Date.now();
this.count = 0;
}
allow() {
const now = Date.now();
// 如果当前时间超过窗口边界,重置窗口
if (now - this.windowStart >= this.windowSizeMs) {
this.windowStart = now;
this.count = 0;
}
if (this.count < this.maxRequests) {
this.count++;
return true;
}
return false;
}
}
临界问题
固定窗口的缺陷在于临界问题。假设窗口大小为 1 秒,最大请求数为 5。在 0.9 秒时来了 5 个请求,窗口重置后,1.1 秒又来了 5 个请求,那么 0.9 到 1.1 秒之间实际上处理了 10 个请求,超出了限制。
2. 滑动窗口算法
原理
滑动窗口将时间划分为更小的粒度(如秒),并记录每个小窗口的请求数。窗口随时间滑动,计算当前窗口总和。
实现
使用一个数组记录每个小窗口的计数:
class SlidingWindowLimiter {
constructor(windowSizeMs, maxRequests, precisionMs = 1000) {
this.windowSizeMs = windowSizeMs;
this.maxRequests = maxRequests;
this.precisionMs = precisionMs;
this.window = []; // 存储 [timestamp, count]
}
allow() {
const now = Date.now();
// 清理过期数据
while (this.window.length > 0 && now - this.window[0][0] > this.windowSizeMs) {
this.window.shift();
}
// 计算当前窗口总请求数
let total = 0;
for (const [ts, count] of this.window) {
total += count;
}
if (total < this.maxRequests) {
// 添加当前请求到窗口
const lastIndex = this.window.length - 1;
if (lastIndex >= 0 && now - this.window[lastIndex][0] < this.precisionMs) {
this.window[lastIndex][1]++;
} else {
this.window.push([now, 1]);
}
return true;
}
return false;
}
}
滑动窗口的精度取决于小窗口的粒度。粒度越小,精度越高,但内存占用也越大。
3. 漏桶算法
原理
漏桶算法将请求视为水滴,桶的容量有限,水滴以固定速率从桶底漏出(即处理请求)。如果桶满了,新的水滴会被丢弃。
实现
class LeakyBucketLimiter {
constructor(capacity, leakRatePerSec) {
this.capacity = capacity;
this.leakRatePerSec = leakRatePerSec;
this.water = 0;
this.lastTime = Date.now();
}
allow() {
const now = Date.now();
// 计算漏掉的水量
const leaked = ((now - this.lastTime) / 1000) * this.leakRatePerSec;
this.water = Math.max(0, this.water - leaked);
this.lastTime = now;
if (this.water < this.capacity) {
this.water++;
return true;
}
return false;
}
}
漏桶算法强制平滑流量,但无法应对突发流量,因为即使桶未满,处理速率也是恒定的。
4. 令牌桶算法
原理
令牌桶以固定速率向桶中添加令牌,每个请求需要消耗一个令牌。桶的容量有限,如果桶满则令牌被丢弃。与漏桶不同,令牌桶允许一定程度的突发流量(因为桶内可以累积令牌)。
实现
class TokenBucketLimiter {
constructor(capacity, refillRatePerSec) {
this.capacity = capacity;
this.tokens = capacity; // 初始装满令牌
this.refillRatePerSec = refillRatePerSec;
this.lastRefill = Date.now();
}
allow() {
this.refill();
if (this.tokens >= 1) {
this.tokens--;
return true;
}
return false;
}
refill() {
const now = Date.now();
const elapsedSec = (now - this.lastRefill) / 1000;
this.tokens = Math.min(this.capacity, this.tokens + elapsedSec * this.refillRatePerSec);
this.lastRefill = now;
}
}
令牌桶算法是业界最常用的算法,例如 Guava 的 RateLimiter 就是基于令牌桶的变体。
实战:基于 Redis 的分布式限流
单机限流无法应对多实例部署的场景,因此我们需要分布式限流。Redis 的 INCR 和 EXPIRE 命令可以轻松实现固定窗口限流。
固定窗口的 Redis 实现
const redis = require('redis');
const client = redis.createClient();
async function fixedWindowLimit(key, maxRequests, windowSec) {
const current = await client.incr(key);
if (current === 1) {
await client.expire(key, windowSec);
}
return current <= maxRequests;
}
但这种方式仍有临界问题。更优雅的方案是使用 Redis 的 ZSET 实现滑动窗口。
滑动窗口的 Redis 实现
async function slidingWindowLimit(userId, maxRequests, windowSec) {
const key = `rate:${userId}`;
const now = Date.now();
const oldest = now - windowSec * 1000;
const pipeline = client.multi();
pipeline.zremrangebyscore(key, 0, oldest); // 移除过期记录
pipeline.zadd(key, now, now); // 添加当前请求
pipeline.zcard(key); // 统计窗口内请求数
pipeline.expire(key, windowSec); // 设置过期时间
const results = await pipeline.exec();
const count = results[2];
return count <= maxRequests;
}
令牌桶的 Redis 实现(Lua 脚本)
为了原子性,我们使用 Lua 脚本:
-- KEYS[1]: 令牌桶 key
-- ARGV[1]: 容量
-- ARGV[2]: 每秒补充速率
-- ARGV[3]: 当前时间戳(秒)
-- ARGV[4]: 请求的令牌数(通常为1)
local bucket = KEYS[1]
local capacity = tonumber(ARGV[1])
local refillRate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local lastRefill = tonumber(redis.call('hget', bucket, 'lastRefill') or now)
local tokens = tonumber(redis.call('hget', bucket, 'tokens') or capacity)
-- 计算补充的令牌
local elapsed = math.max(0, now - lastRefill)
tokens = math.min(capacity, tokens + elapsed * refillRate)
if tokens >= requested then
tokens = tokens - requested
redis.call('hset', bucket, 'tokens', tokens)
redis.call('hset', bucket, 'lastRefill', now)
return 1
else
return 0
end
在 Node.js 中调用:
const script = `...`; // 上面的 Lua 脚本
const result = await client.eval(script, 1, 'user:123', capacity, refillRate, Math.floor(Date.now()/1000), 1);
最佳实践与避坑指南
选择合适的算法
- 如果系统需要平滑流量,选择漏桶。
- 如果希望允许突发流量,选择令牌桶。
- 如果是简单的 API 限流,固定窗口或滑动窗口已足够。
注意时钟同步
在分布式环境中,时间戳依赖可能导致问题。如果使用 Redis,确保服务器时间同步(NTP)。
设置合理的超时和过期时间
Redis 的 key 一定要设置过期时间,防止内存泄漏。
使用原子操作
避免竞态条件,使用 Lua 脚本或事务。
降级策略
当限流触发时,可以返回 429 状态码,并提供 Retry-After 头。
监控与告警
记录限流事件,监控指标,及时发现异常。
总结
限流是系统防护的重要防线,本文介绍了四种算法的原理与实现,并给出了分布式限流的实战方案。在实际项目中,建议结合具体场景选择合适的算法,并注意原子性和性能。下一步,你可以尝试将限流集成到 API 网关中,或者使用现成的中间件(如 Express-rate-limit)。
希望本文能帮助你构建稳定可靠的系统!