每次你调用第三方 API 遇到 429 Too Many Requests,背后都是一个限流器(Rate Limiter)在工作。限流器是保护系统稳定性的第一道防线——没有它,一次突发流量就能让整个服务雪崩。根据 Cloudflare 2025 年的数据,超过 60% 的 DDoS 攻击在应用层被限流策略有效拦截,而一个设计良好的限流器可以将 API 的 P99 延迟降低 40% 以上。
本文不讲理论空话,而是从零实现 3 种主流限流算法,给出完整的可运行代码,最后落地到 Redis 分布式方案。无论你是要保护自己的 API,还是需要在微服务架构中做全局流量治理,这篇文章都能给你直接可用的方案。
🔐 一、限流算法深度对比
在动手写代码之前,先搞清楚三种主流算法的本质区别。很多开发者只知道 Token Bucket,却不清楚它和滑动窗口的差异在哪里——这会导致在生产环境中选错方案。
1.1 Token Bucket(令牌桶)
令牌桶是最经典的限流算法。想象一个桶,系统以固定速率往桶里放令牌(Token),每个请求需要消耗一个令牌。桶有最大容量,满了就不再放。
核心优势: 允许突发流量(burst)。桶里积累的令牌可以让请求瞬间通过,适合「大部分时间平稳、偶尔有突发」的场景。
时间轴:
系统放令牌 → [🪙🪙🪙🪙🪙] → 桶满(max_tokens=5)
请求到来 → 取 1 个令牌 → [🪙🪙🪙🪙]
突发请求 → 连续取 5 个 → [空桶]
后续请求 → 桶空,拒绝 ❌
1.2 Sliding Window Log(滑动窗口日志)
记录每个请求的精确时间戳,窗口滑动时计算窗口内的请求数。精度最高,但内存开销大——需要存储窗口内每个请求的时间戳。
核心优势: 精确,没有边界问题。核心劣势: 内存开销 O(n),n 为窗口内请求数。
1.3 Sliding Window Counter(滑动窗口计数器)
结合了固定窗口和滑动窗口的优点。将时间分成小的子窗口,每个子窗口只记录计数。通过加权计算来近似滑动窗口的效果。
核心优势: 内存开销 O(1),精度接近滑动窗口日志。这是大多数生产环境的首选。
📊 算法对比总览
| 特性 | Token Bucket | Sliding Window Log | Sliding Window Counter |
|---|---|---|---|
| 内存开销 | O(1) | O(n) | O(m),m 为子窗口数 |
| 精确度 | 中 | 高 | 高 |
| 突发流量处理 | ✅ 允许 | ❌ 严格限制 | ❌ 严格限制 |
| 实现复杂度 | 低 | 中 | 中 |
| 分布式友好度 | ⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ |
| 适用场景 | API 网关、突发流量 | 精确计费、安全防护 | 通用限流 |
| 生产推荐度 | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
💡 提示: 如果你只能选一种算法,选 Sliding Window Counter。它在精度、内存和实现复杂度之间取得了最好的平衡。Token Bucket 适合需要允许突发流量的场景。
🚀 二、从零实现三种限流器
下面用纯 Node.js 实现三种限流器,每个都是完整的、可直接运行的代码。
2.1 Token Bucket 实现
// Token Bucket 限流器 — 支持突发流量
class TokenBucket {
/**
* @param {number} maxTokens - 桶最大容量
* @param {number} refillRate - 每秒补充的令牌数
*/
constructor(maxTokens, refillRate) {
this.maxTokens = maxTokens;
this.refillRate = refillRate;
this.tokens = maxTokens; // 初始满桶
this.lastRefillTime = Date.now();
}
// 补充令牌(惰性计算,只在取令牌时补充)
refill() {
const now = Date.now();
const elapsed = (now - this.lastRefillTime) / 1000; // 秒
const tokensToAdd = elapsed * this.refillRate;
this.tokens = Math.min(this.maxTokens, this.tokens + tokensToAdd);
this.lastRefillTime = now;
}
// 尝试消耗一个令牌,返回是否允许
tryConsume(tokens = 1) {
this.refill();
if (this.tokens >= tokens) {
this.tokens -= tokens;
return { allowed: true, remaining: Math.floor(this.tokens) };
}
// 计算需要等待多久才能有足够令牌
const waitTime = ((tokens - this.tokens) / this.refillRate) * 1000;
return {
allowed: false,
remaining: Math.floor(this.tokens),
retryAfter: Math.ceil(waitTime / 1000)
};
}
}
// 使用示例:每秒放 10 个令牌,桶最多存 20 个
const bucket = new TokenBucket(20, 10);
// 模拟请求
for (let i = 0; i < 25; i++) {
const result = bucket.tryConsume();
console.log(`请求 ${i + 1}: ${result.allowed ? '✅ 通过' : '❌ 拒绝'} (剩余: ${result.remaining})`);
}
运行这段代码,你会看到前 20 个请求全部通过(初始满桶),第 21 个开始被拒绝——这就是 Token Bucket 允许突发流量的特性。
2.2 Sliding Window Counter 实现
// Sliding Window Counter 限流器 — 生产级精度,O(1) 内存
class SlidingWindowCounter {
/**
* @param {number} maxRequests - 窗口内最大请求数
* @param {number} windowSizeMs - 窗口大小(毫秒)
* @param {number} subWindowCount - 子窗口数量(越多越精确)
*/
constructor(maxRequests, windowSizeMs, subWindowCount = 10) {
this.maxRequests = maxRequests;
this.windowSizeMs = windowSizeMs;
this.subWindowSizeMs = windowSizeMs / subWindowCount;
this.subWindowCount = subWindowCount;
// 用 Map 存储每个子窗口的计数,key 为子窗口的时间戳
this.counters = new Map();
}
// 获取当前子窗口的时间戳
getCurrentSubWindow() {
return Math.floor(Date.now() / this.subWindowSizeMs) * this.subWindowSizeMs;
}
// 清理过期的子窗口
cleanup(currentWindow) {
const cutoff = currentWindow - this.windowSizeMs;
for (const [timestamp] of this.counters) {
if (timestamp < cutoff) {
this.counters.delete(timestamp);
}
}
}
// 计算当前窗口内的请求数(加权计算)
getWindowRequestCount() {
const now = Date.now();
const currentWindowStart = Math.floor(now / this.windowSizeMs) * this.windowSizeMs;
const previousWindowStart = currentWindowStart - this.windowSizeMs;
let currentCount = 0;
let previousCount = 0;
for (const [timestamp, count] of this.counters) {
if (timestamp >= currentWindowStart) {
currentCount += count;
} else if (timestamp >= previousWindowStart) {
previousCount += count;
}
}
// 加权计算:当前窗口权重 + 上一窗口权重
const elapsedInCurrent = (now - currentWindowStart) / this.windowSizeMs;
const weight = previousCount * (1 - elapsedInCurrent) + currentCount;
return { weight, currentCount, previousCount };
}
// 尝试消费一个请求
tryConsume() {
const currentSubWindow = this.getCurrentSubWindow();
this.cleanup(currentSubWindow);
const { weight } = this.getWindowRequestCount();
if (weight < this.maxRequests) {
// 允许请求,增加计数
const current = this.counters.get(currentSubWindow) || 0;
this.counters.set(currentSubWindow, current + 1);
return {
allowed: true,
remaining: Math.max(0, Math.floor(this.maxRequests - weight - 1)),
limit: this.maxRequests
};
}
return {
allowed: false,
remaining: 0,
limit: this.maxRequests,
retryAfter: Math.ceil(this.subWindowSizeMs / 1000)
};
}
}
// 使用示例:60 秒内最多 100 个请求
const limiter = new SlidingWindowCounter(100, 60 * 1000);
// 模拟 105 个请求
let allowed = 0, rejected = 0;
for (let i = 0; i < 105; i++) {
const result = limiter.tryConsume();
if (result.allowed) allowed++;
else rejected++;
}
console.log(`通过: ${allowed}, 拒绝: ${rejected}`);
// 输出:通过: 100, 拒绝: 5
2.3 Express.js 中间件集成
把限流器包装成 Express 中间件,这才是实际项目中的用法:
// Express.js 限流中间件 — 按 IP 限流
const express = require('express');
const app = express();
// 基于 Sliding Window Counter 的限流中间件
function rateLimiter(options = {}) {
const {
windowMs = 60 * 1000, // 窗口大小:1 分钟
maxRequests = 100, // 窗口内最大请求数
keyGenerator = (req) => req.ip, // 限流 key,默认按 IP
message = '请求过于频繁,请稍后再试',
headers = true // 是否返回限流相关响应头
} = options;
// 用 Map 存储每个 key 的限流器实例
const limiters = new Map();
// 定期清理不活跃的限流器(避免内存泄漏)
setInterval(() => {
for (const [key, limiter] of limiters) {
if (limiter.counters.size === 0) {
limiters.delete(key);
}
}
}, 60 * 1000);
return (req, res, next) => {
const key = keyGenerator(req);
if (!limiters.has(key)) {
limiters.set(key, new SlidingWindowCounter(maxRequests, windowMs));
}
const limiter = limiters.get(key);
const result = limiter.tryConsume();
// 设置标准限流响应头
if (headers) {
res.set('X-RateLimit-Limit', String(result.limit));
res.set('X-RateLimit-Remaining', String(result.remaining));
}
if (!result.allowed) {
if (headers) {
res.set('Retry-After', String(result.retryAfter));
}
return res.status(429).json({
error: message,
retryAfter: result.retryAfter
});
}
next();
};
}
// 使用:全局限流
app.use(rateLimiter({
windowMs: 60 * 1000,
maxRequests: 100
}));
// 使用:特定路由更严格的限流
app.use('/api/login', rateLimiter({
windowMs: 15 * 60 * 1000, // 15 分钟
maxRequests: 5, // 最多 5 次
message: '登录尝试过多,请 15 分钟后再试',
keyGenerator: (req) => `login:${req.ip}`
}));
app.get('/api/data', (req, res) => {
res.json({ message: 'Hello, world!' });
});
app.listen(3000, () => console.log('Server running on port 3000'));
⚠️ 警告: 单机内存限流器在多实例部署时会失效——每个实例各自计数,限流阈值会被放大 N 倍(N 为实例数)。多实例必须用分布式方案。
💡 三、Redis 分布式限流实战
生产环境中,API 通常部署多个实例。这时候必须用 Redis 做集中式限流。下面给出两种 Redis 限流方案。
3.1 Redis Sliding Window(Lua 脚本,原子操作)
# rate_limiter.lua — Redis Lua 脚本,原子执行限流逻辑
# KEYS[1] = 限流 key
# ARGV[1] = 窗口大小(毫秒)
# ARGV[2] = 最大请求数
# ARGV[3] = 当前时间戳(毫秒)
local key = KEYS[1]
local window = tonumber(ARGV[1])
local max_requests = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
-- 删除窗口外的旧请求记录
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
-- 获取当前窗口内的请求数
local current_count = redis.call('ZCARD', key)
if current_count < max_requests then
-- 允许请求,添加当前时间戳
redis.call('ZADD', key, now, now .. ':' .. math.random(1000000))
redis.call('PEXPIRE', key, window)
return {1, max_requests - current_count - 1} -- {allowed, remaining}
else
-- 拒绝请求
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
local retry_after = 0
if #oldest > 0 then
retry_after = math.ceil((tonumber(oldest[2]) + window - now) / 1000)
end
return {0, retry_after} -- {denied, retry_after_seconds}
end
3.2 Node.js + Redis 完整实现
// Redis 分布式限流器 — 生产级实现
const Redis = require('ioredis');
const fs = require('fs');
const redis = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: 6379,
maxRetriesPerRequest: 3,
retryDelayOnFailover: 100
});
// 加载 Lua 脚本
const LUA_SCRIPT = fs.readFileSync('./rate_limiter.lua', 'utf8');
class RedisRateLimiter {
constructor(options = {}) {
this.windowMs = options.windowMs || 60 * 1000;
this.maxRequests = options.maxRequests || 100;
this.keyPrefix = options.keyPrefix || 'rl:';
this.scriptSha = null;
}
// 预加载 Lua 脚本(SCRIPT LOAD)
async init() {
this.scriptSha = await redis.script('LOAD', LUA_SCRIPT);
console.log('Rate limiter script loaded:', this.scriptSha);
}
async consume(identifier) {
const key = `${this.keyPrefix}${identifier}`;
const now = Date.now();
try {
// EVALSHA 执行预加载的脚本(减少网络传输)
const result = await redis.evalsha(
this.scriptSha,
1, // number of keys
key, // KEYS[1]
this.windowMs, // ARGV[1]
this.maxRequests, // ARGV[2]
now // ARGV[3]
);
const [allowed, remainingOrRetry] = result;
return {
allowed: allowed === 1,
remaining: allowed === 1 ? remainingOrRetry : 0,
retryAfter: allowed === 1 ? 0 : remainingOrRetry,
limit: this.maxRequests
};
} catch (err) {
// Redis 故障时降级放行(fail-open)
console.error('Rate limiter error, failing open:', err.message);
return { allowed: true, remaining: this.maxRequests, limit: this.maxRequests };
}
}
}
// Express 中间件封装
function distributedRateLimiter(options) {
const limiter = new RedisRateLimiter(options);
limiter.init(); // 预加载脚本
return async (req, res, next) => {
const key = (options.keyGenerator || ((r) => r.ip))(req);
const result = await limiter.consume(key);
res.set('X-RateLimit-Limit', String(result.limit));
res.set('X-RateLimit-Remaining', String(result.remaining));
if (!result.allowed) {
res.set('Retry-After', String(result.retryAfter));
return res.status(429).json({
error: 'Too many requests',
retryAfter: result.retryAfter
});
}
next();
};
}
module.exports = { RedisRateLimiter, distributedRateLimiter };
📊 Redis 方案 vs 内存方案性能对比
| 指标 | 内存限流 | Redis 限流 | Redis Cluster |
|---|---|---|---|
| 单次限流延迟 | < 0.1ms | 0.5-2ms | 1-3ms |
| 吞吐量(单实例) | ~500K req/s | ~100K req/s | ~300K req/s |
| 多实例一致性 | ❌ 不一致 | ✅ 强一致 | ✅ 强一致 |
| 内存占用 | 高(每实例独立) | 低(集中存储) | 低(分布式) |
| 容灾能力 | ❌ 实例重启丢失 | ⚠️ 单点风险 | ✅ 高可用 |
| 推荐场景 | 开发/测试 | 中小规模生产 | 大规模生产 |
⚡ 关键结论: 除非你的系统只有单实例且永远不需要扩展,否则请直接用 Redis 方案。单机内存限流器在扩容时会成为定时炸弹。
🔧 四、生产环境避坑指南
限流器看起来简单,但生产环境中有大量坑。以下是我在实际项目中踩过的最严重的几个。
4.1 坑点一:限流粒度不对
很多团队只做 IP 限流,这在以下场景会出问题:
- ✅ 按 API Key 限流: 适合 SaaS 服务,不同客户不同额度
- ✅ 按用户 ID 限流: 适合登录后的 API,防止单用户滥用
- ✅ 按接口 + 用户组合限流: 适合关键接口(如登录、支付)单独设限
- ❌ 只按 IP 限流: 公司出口 IP 下几百人共享限额,CDN 后 IP 不准确
// 多级限流策略 — 推荐的生产配置
const rateLimitConfig = {
// 第一层:全局限流(防 DDoS)
global: { windowMs: 60000, maxRequests: 10000 },
// 第二层:按 IP 限流(防爬虫)
perIp: { windowMs: 60000, maxRequests: 100 },
// 第三层:按用户限流(防滥用)
perUser: { windowMs: 60000, maxRequests: 50 },
// 第四层:关键接口单独限流
login: { windowMs: 900000, maxRequests: 5 }, // 15 分钟 5 次
payment: { windowMs: 60000, maxRequests: 3 }, // 1 分钟 3 次
};
4.2 坑点二:响应头不规范
限流响应头不标准会导致客户端无法正确处理限流。必须遵守这些规范:
// ✅ 正确的限流响应头
res.set('X-RateLimit-Limit', '100'); // 窗口内最大请求数
res.set('X-RateLimit-Remaining', '42'); // 剩余请求数
res.set('X-RateLimit-Reset', '1686240000'); // 窗口重置时间(Unix 时间戳)
res.set('Retry-After', '30'); // 被限流后等待秒数
// ❌ 错误:缺少 Retry-After,客户端只能盲目重试
// ❌ 错误:Remaining 返回负数
// ❌ 错误:Reset 用相对秒数而非 Unix 时间戳
4.3 坑点三:Redis 故障时直接拒绝所有请求
限流器挂了不应该让整个服务不可用。正确的策略是 fail-open(降级放行):
// ❌ 错误写法:Redis 挂了就拒绝所有请求
async consume(key) {
const result = await redis.evalsha(...); // 抛异常
// 异常未捕获 → 500 错误 → 所有请求失败
}
// ✅ 正确写法:降级放行 + 本地兜底
async consume(key) {
try {
const result = await redis.evalsha(...);
return result;
} catch (err) {
// 降级到本地内存限流
console.error('Redis unavailable, falling back to local limiter');
return this.localFallback.tryConsume(key);
}
}
⚠️ 警告: 永远不要让限流器成为系统的单点故障。限流的目的是保护系统,如果限流器本身让系统不可用,那就本末倒置了。
4.4 坑点四:忽略时钟同步
分布式限流依赖时间戳。如果多台服务器的时钟不同步(哪怕差几秒),限流计算就会出错。
# 检查服务器时钟偏差
chronyc tracking
# 配置 NTP 同步(Ubuntu)
sudo apt install chrony
sudo systemctl enable chrony
📌 记住: 分布式系统中,所有参与限流计算的节点必须用 NTP 同步时钟。时钟偏差超过 1 秒就可能导致限流失效。
✅ 总结与工具推荐
限流器是每个后端开发者必须掌握的基础设施。本文的核心要点:
- 🎯 算法选择: 通用场景用 Sliding Window Counter,需要突发流量用 Token Bucket
- 🎯 实现优先级: 先用成熟库(如
express-rate-limit、bottleneck),理解原理后再自研 - 🎯 分布式必须用 Redis: 单机内存方案在多实例下不可靠
- 🎯 多级限流: 全局 → IP → 用户 → 接口,层层防护
- 🎯 容灾设计: Redis 故障时 fail-open,不能让限流器成为单点故障
推荐工具库:
| 语言 | 库 | 特点 |
|---|---|---|
| Node.js | bottleneck |
功能最全,支持集群、Redis、动态调整 |
| Node.js | express-rate-limit |
Express 官方推荐,简单易用 |
| Python | limits |
支持多种后端(内存、Redis、MongoDB) |
| Go | go.uber.org/ratelimit |
Uber 开源,性能极高 |
| Java | Bucket4j |
Token Bucket 的 Java 实现,支持分布式 |
如果你正在构建 API 服务,今天就把限流加上。一个 429 响应远好过一个崩溃的服务。限流器的代码量不大,但它在系统稳定性中的权重,可能比你写的所有业务代码加起来都大。