你的 API 在凌晨 3 点突然收到每秒 5000 次的异常请求,数据库连接池被打满,整个服务雪崩——这不是假设场景,而是每个后端开发者迟早会遇到的真实灾难。根据 Cloudflare 2025 年的报告,超过 67% 的 API 事故可以通过合理的限流(Rate Limiting)策略避免。限流不是可选的锦上添花,而是生产环境的生存底线。
本文不讲理论废话,直接从零手写三种核心限流算法,覆盖单机到分布式场景,给出可直接复制到生产环境的代码。
🔐 一、三种核心限流算法:原理与手写实现
1.1 令牌桶(Token Bucket)
令牌桶是最经典的限流算法。它以固定速率向桶中放入令牌,请求到来时消耗一个令牌,桶空则拒绝。它的核心优势是允许突发流量——只要桶中有足够的令牌,短时间内可以处理超过平均速率的请求。
// 令牌桶(Token Bucket)限流器 —— 手写实现
class TokenBucket {
/**
* @param {number} capacity - 桶容量(最大令牌数)
* @param {number} refillRate - 每秒补充的令牌数
*/
constructor(capacity, refillRate) {
this.capacity = capacity;
this.refillRate = refillRate;
this.tokens = capacity; // 初始满桶
this.lastRefill = Date.now();
}
// 尝试消费一个令牌,返回 true 表示允许
consume(tokens = 1) {
this._refill();
if (this.tokens >= tokens) {
this.tokens -= tokens;
return { allowed: true, remaining: this.tokens };
}
// 计算需要等待多久才能获得令牌
const waitMs = ((tokens - this.tokens) / this.refillRate) * 1000;
return { allowed: false, remaining: this.tokens, retryAfterMs: waitMs };
}
// 按时间差补充令牌
_refill() {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000; // 秒
const newTokens = elapsed * this.refillRate;
this.tokens = Math.min(this.capacity, this.tokens + newTokens);
this.lastRefill = now;
}
}
// 使用示例:每秒放行 10 个请求,桶容量 20(允许最多 20 的突发)
const bucket = new TokenBucket(20, 10);
function handleRequest(req) {
const result = bucket.consume();
if (result.allowed) {
return { status: 200, body: 'OK', remaining: result.remaining };
}
return {
status: 429,
body: 'Too Many Requests',
retryAfterMs: result.retryAfterMs
};
}
💡 **提示:**令牌桶适合大多数 API 场景。它既能控制平均速率,又允许合理的突发流量,用户体验最佳。
1.2 固定窗口计数器(Fixed Window Counter)
固定窗口是最简单的实现:按时间窗口计数,超限则拒绝。简单是优点也是致命缺陷——窗口边界突发问题。如果限制是每分钟 100 次,用户在第 59 秒发起 100 次、第 60 秒(新窗口)再发起 100 次,实际上 2 秒内就有 200 次请求。
// 固定窗口计数器 —— 简单但有边界问题
class FixedWindowCounter {
/**
* @param {number} maxRequests - 窗口内最大请求数
* @param {number} windowMs - 窗口大小(毫秒)
*/
constructor(maxRequests, windowMs) {
this.maxRequests = maxRequests;
this.windowMs = windowMs;
this.count = 0;
this.windowStart = Date.now();
}
consume() {
const now = Date.now();
// 窗口过期,重置计数
if (now - this.windowStart >= this.windowMs) {
this.count = 0;
this.windowStart = now;
}
if (this.count < this.maxRequests) {
this.count++;
return { allowed: true, remaining: this.maxRequests - this.count };
}
const retryAfterMs = this.windowMs - (now - this.windowStart);
return { allowed: false, remaining: 0, retryAfterMs };
}
}
⚠️ **警告:**固定窗口的窗口边界突发问题在生产中非常危险。除非你只是做快速原型验证,否则不要单独使用固定窗口。
1.3 滑动窗口日志(Sliding Window Log)
滑动窗口日志记录每个请求的时间戳,统计过去 N 秒内的请求数。精度最高,但内存消耗与请求数成正比——如果限制是每分钟 10000 次,就需要存储 10000 个时间戳。
// 滑动窗口日志 —— 精确但内存开销大
class SlidingWindowLog {
/**
* @param {number} maxRequests - 窗口内最大请求数
* @param {number} windowMs - 窗口大小(毫秒)
*/
constructor(maxRequests, windowMs) {
this.maxRequests = maxRequests;
this.windowMs = windowMs;
this.log = []; // 存储请求时间戳
}
consume() {
const now = Date.now();
const windowStart = now - this.windowMs;
// 清理过期时间戳
this.log = this.log.filter(ts => ts > windowStart);
if (this.log.length < this.maxRequests) {
this.log.push(now);
return { allowed: true, remaining: this.maxRequests - this.log.length };
}
// 最早的请求过期时间就是重试时间
const retryAfterMs = this.log[0] + this.windowMs - now;
return { allowed: false, remaining: 0, retryAfterMs };
}
}
1.4 算法对比
下表是三种核心算法的关键特性对比,帮助你根据场景做出选择:
| 特性 | 令牌桶 | 固定窗口 | 滑动窗口日志 |
|---|---|---|---|
| 突发容忍 | ✅ 支持 | ❌ 边界问题 | ❌ 严格限制 |
| 内存消耗 | ✅ 极低(2 个变量) | ✅ 极低(1 个计数器) | ❌ 与请求数成正比 |
| 实现复杂度 | ⭐⭐ 低 | ⭐ 最低 | ⭐⭐⭐ 中 |
| 精度 | ⭐⭐ 高 | ⭐ 低(边界问题) | ⭐⭐⭐ 最高 |
| 适合场景 | 通用 API | 快速原型 | 安全敏感接口 |
| 分布式支持 | ✅ 易实现 | ✅ 易实现 | ⚠️ 需要有序集合 |
⚡ **关键结论:**90% 的场景选择令牌桶就够了。安全敏感场景(如登录接口)用滑动窗口日志。
🚀 二、生产级实现:Redis 分布式限流
单机限流在多实例部署时失效——你有 4 个实例,每个限流 100 次/秒,实际总限制变成了 400 次/秒。分布式限流的核心是用 Redis 作为共享计数器。
2.1 基于 Redis 的滑动窗口限流(Lua 脚本)
Lua 脚本在 Redis 中原子执行,避免了并发竞态条件。这是生产环境中最推荐的方案:
-- sliding_window_rate_limit.lua
-- 基于 Redis 的滑动窗口限流(原子操作)
-- KEYS[1] = 限流键名(如 "rate:user:12345")
-- ARGV[1] = 窗口大小(毫秒)
-- ARGV[2] = 最大请求数
-- ARGV[3] = 当前时间戳(毫秒)
-- ARGV[4] = 唯一请求 ID(用于去重)
local key = KEYS[1]
local window = tonumber(ARGV[1])
local maxRequests = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requestId = ARGV[4]
local windowStart = now - window
-- 清理窗口外的过期成员
redis.call('ZREMRANGEBYSCORE', key, '-inf', windowStart)
-- 统计当前窗口内的请求数
local currentCount = redis.call('ZCARD', key)
if currentCount < maxRequests then
-- 未超限,添加当前请求
redis.call('ZADD', key, now, requestId)
redis.call('PEXPIRE', key, window)
return { 1, maxRequests - currentCount - 1 } -- allowed, remaining
else
-- 已超限,计算重试时间
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
local retryAfter = tonumber(oldest[2]) + window - now
return { 0, 0, retryAfter } -- denied, remaining=0, retryAfter
end
// Node.js 中调用 Redis Lua 限流脚本
import Redis from 'ioredis';
import { randomUUID } from 'crypto';
import { readFileSync } from 'fs';
const redis = new Redis({ host: '127.0.0.1', port: 6379 });
// 预加载 Lua 脚本,获取 SHA1 哈希(只需加载一次)
const luaScript = readFileSync('./sliding_window_rate_limit.lua', 'utf-8');
let scriptSha = null;
async function loadScript() {
scriptSha = await redis.script('LOAD', luaScript);
}
async function checkRateLimit(userId, { windowMs = 60000, maxRequests = 100 } = {}) {
const key = `rate:${userId}`;
const now = Date.now();
const requestId = randomUUID();
// 使用 EVALSHA 执行已缓存的脚本(性能更好)
const result = await redis.evalsha(
scriptSha || await (loadScript(), scriptSha),
1, // KEYS 数量
key, // KEYS[1]
windowMs, // ARGV[1]
maxRequests, // ARGV[2]
now, // ARGV[3]
requestId // ARGV[4]
);
const [allowed, remaining, retryAfter] = result;
return {
allowed: allowed === 1,
remaining,
retryAfter: retryAfter || null,
headers: {
'X-RateLimit-Limit': maxRequests,
'X-RateLimit-Remaining': remaining,
'X-RateLimit-Reset': Math.ceil((now + (retryAfter || windowMs)) / 1000),
...(retryAfter && { 'Retry-After': Math.ceil(retryAfter / 1000) })
}
};
}
2.2 Express/Koa 中间件封装
// rate-limit-middleware.js —— 通用限流中间件
export function createRateLimiter(options = {}) {
const {
windowMs = 60 * 1000, // 默认 1 分钟窗口
maxRequests = 100, // 默认每窗口 100 次
keyGenerator = (req) => req.ip, // 默认按 IP 限流
skipSuccessfulRequests = false,
message = '请求过于频繁,请稍后再试',
} = options;
return async function rateLimitMiddleware(req, res, next) {
const key = keyGenerator(req);
const result = await checkRateLimit(key, { windowMs, maxRequests });
// 设置标准限流响应头
for (const [header, value] of Object.entries(result.headers)) {
res.setHeader(header, value);
}
if (!result.allowed) {
return res.status(429).json({
error: message,
retryAfter: Math.ceil(result.retryAfter / 1000)
});
}
next();
};
}
// 使用示例
import express from 'express';
const app = express();
// 全局限流:每个 IP 每分钟 100 次
app.use(createRateLimiter());
// 登录接口严格限流:每个 IP 每 15 分钟 5 次
app.post('/api/login', createRateLimiter({
windowMs: 15 * 60 * 1000,
maxRequests: 5,
keyGenerator: (req) => `login:${req.ip}`,
message: '登录尝试过多,请 15 分钟后再试'
}), loginHandler);
// 按用户限流(需要认证后的用户 ID)
app.use('/api/', createRateLimiter({
maxRequests: 1000,
keyGenerator: (req) => `user:${req.user?.id || req.ip}`
}));
📌 **记住:**限流的 keyGenerator 至关重要。按 IP 限流容易被绕过(代理池),按用户 ID 限流更可靠但需要认证。生产中通常组合使用:IP 限流做第一层防御,用户 ID 限流做第二层。
💡 三、Nginx 层限流与多层防御架构
3.1 Nginx 原生限流配置
在应用层之前用 Nginx 做第一道限流是最高效的——请求根本不会到达你的 Node.js 进程:
# nginx.conf —— Nginx 层限流配置
http {
# 定义限流区域:按客户端 IP,每秒 10 个请求
# zone=addr:10m 表示共享内存 10MB,约能存储 16 万个 IP 状态
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
# 按用户自定义变量限流(如 API Key)
limit_req_zone $http_x_api_key zone=apikey_limit:10m rate=100r/s;
# 全局连接数限制
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
server {
listen 80;
# API 接口限流
location /api/ {
# burst=20: 允许突发 20 个请求排队
# nodelay: 突发请求立即处理,不排队等待
limit_req zone=api_limit burst=20 nodelay;
# 每个 IP 最多 50 个并发连接
limit_conn conn_limit 50;
# 自定义 429 响应
limit_req_status 429;
limit_req_log_level warn;
# 返回 Retry-After 头
add_header Retry-After 1 always;
proxy_pass http://backend;
}
# 登录接口更严格
location /api/auth/ {
limit_req zone=api_limit burst=3 nodelay;
proxy_pass http://backend;
}
}
}
⚠️ 警告:Nginx 的
limit_req使用的是漏桶算法(Leaky Bucket),不是令牌桶。漏桶会强制排队突发请求,可能增加延迟。设置nodelay可以让突发请求立即处理,但超限的请求仍然被拒绝。
3.2 多层限流架构设计
生产环境应该采用多层防御:
| 层级 | 技术 | 限流粒度 | 作用 |
|---|---|---|---|
| L1 - CDN/WAF | Cloudflare / AWS WAF | IP + 地域 | 抵御 DDoS、恶意爬虫 |
| L2 - 网关层 | Nginx / Kong | IP + 路径 | 快速拒绝超量请求,零应用开销 |
| L3 - 应用层 | Redis + 中间件 | 用户 ID + API Key | 精细化业务限流 |
| L4 - 数据库层 | 连接池配置 | 连接数 | 兜底保护,防止连接耗尽 |
// 多层限流策略示例:Express + 多级限流
import express from 'express';
import Redis from 'ioredis';
const app = express();
const redis = new Redis();
// L3-1: IP 级限流(宽松)
const ipLimiter = createRateLimiter({
windowMs: 60_000,
maxRequests: 200,
keyGenerator: (req) => `ip:${req.ip}`
});
// L3-2: 用户级限流(中等)
const userLimiter = createRateLimiter({
windowMs: 60_000,
maxRequests: 100,
keyGenerator: (req) => `user:${req.user?.id || req.ip}`
});
// L3-3: 接口级限流(严格,针对昂贵操作)
const expensiveLimiter = createRateLimiter({
windowMs: 60_000,
maxRequests: 10,
keyGenerator: (req) => `expensive:${req.user?.id || req.ip}`
});
// 组合使用
app.use('/api/', ipLimiter);
app.use('/api/', authenticate, userLimiter);
// 昂贵操作(如文件导出、AI 推理)单独限流
app.post('/api/export', expensiveLimiter, exportHandler);
app.post('/api/ai/analyze', expensiveLimiter, aiAnalyzeHandler);
🔧 四、避坑指南:生产中的限流陷阱
4.1 常见错误
// ❌ 错误:单机变量做限流,多实例失效
const counters = {};
function badRateLimit(ip) {
counters[ip] = (counters[ip] || 0) + 1;
return counters[ip] <= 100; // 4 个实例 = 实际 400 次/秒
}
// ✅ 正确:使用 Redis 共享状态
async function goodRateLimit(ip) {
const key = `rate:${ip}`;
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, 60); // 首次设置过期时间
}
return count <= 100;
}
// ❌ 错误:限流失败不返回 Retry-After 头
app.use((req, res, next) => {
if (isRateLimited(req)) {
return res.status(429).json({ error: 'Too many requests' });
// 客户端不知道该等多久,只能盲猜或直接放弃
}
next();
});
// ✅ 正确:返回标准限流头
app.use((req, res, next) => {
const result = checkRateLimit(req);
res.setHeader('X-RateLimit-Limit', result.limit);
res.setHeader('X-RateLimit-Remaining', result.remaining);
if (!result.allowed) {
res.setHeader('Retry-After', result.retryAfterSeconds);
res.setHeader('X-RateLimit-Reset', result.resetTimestamp);
return res.status(429).json({
error: 'Too many requests',
retryAfter: result.retryAfterSeconds
});
}
next();
});
4.2 关键注意事项
- ✅ 限流键设计要合理:API Key > 用户 ID > IP。优先用更精确的标识
- ❌ 不要用
Date.now()做分布式时间:多台机器时钟不同步会导致限流不准,用 Redis 的TIME命令 - ⚠️ 注意 Redis 故障降级:Redis 挂了怎么办?选择放行(宁可放过)还是拒绝(宁可错杀)?
- ✅ 记录限流日志:限流事件是安全分析的重要数据来源
- ❌ 不要对健康检查接口限流:
/health、/ready等探针接口必须排除
// Redis 故障降级策略
async function checkRateLimitWithFallback(key, options) {
try {
return await checkRateLimit(key, options);
} catch (err) {
// Redis 不可用时的降级策略
console.error('[RateLimit] Redis unavailable:', err.message);
// 方案 A: 降级到本地限流(保守,可能误拒)
// return localRateLimit(key, options);
// 方案 B: 放行(推荐,宁可放过不可错杀)
return { allowed: true, remaining: -1, degraded: true };
}
}
4.3 响应头标准
遵循 IETF RFC 6585 和 draft-ietf-httpapi-ratelimit-headers 标准,返回规范的限流响应头:
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1717776000
Retry-After: 42
Content-Type: application/json
{
"error": "请求过于频繁",
"retryAfter": 42,
"limit": 100,
"window": "60s"
}
✅ 总结
API 限流的核心是选择合适的算法并在正确的层级实施:
- 算法选择:令牌桶适用于 90% 的场景;安全敏感接口用滑动窗口日志
- 分布式方案:用 Redis Lua 脚本实现原子性限流,避免竞态条件
- 多层防御:Nginx 层快速拒绝 + 应用层精细化控制 + 数据库层兜底
- 标准响应:返回
X-RateLimit-*和Retry-After头,让客户端优雅处理
⚡ **关键结论:**不要等到被攻击了才想起限流。在项目初期就集成限流中间件,成本几乎为零,但能在关键时刻拯救整个服务。
🔧 相关工具推荐:
- bottleneck — Node.js 限流库,支持集群模式
- rate-limiter-flexible — 功能最全的 Node.js 限流库,支持 Redis/MongoDB/内存
- Nginx limit_req — Nginx 原生限流模块
- Kong Rate Limiting — API 网关限流插件
- jsjson.com JSON 格式化工具 — 调试 API 响应时格式化 JSON 数据