你的 API 在上线第一天就被爬虫打挂了——这不是假设,而是无数团队的真实经历。限流(Rate Limiting)是高并发系统的第一道防线,但「限流」两个字背后藏着至少四种截然不同的算法,每种在突发流量、平滑性和分布式一致性上的表现天差地别。选错算法,要么用户体验极差(正常请求被误杀),要么形同虚设(流量照样打穿后端)。
本文将从原理推导到 Redis 生产级实现,手把手带你掌握限流的核心技术栈。所有代码均基于 Node.js + Redis,完整可运行。
🔐 一、四种限流算法原理与特性对比
在动手写代码之前,必须先理解每种算法的数学本质和行为特征。很多团队直接照搬「令牌桶」的代码却不理解它和「漏桶」的区别,结果在突发流量场景下系统被意外打穿。
1.1 令牌桶(Token Bucket)—— 最通用的选择
令牌桶的核心思想:系统以固定速率向桶中放入令牌,每个请求消耗一个令牌。桶有最大容量,满了就丢弃新令牌。
它的关键特性是允许突发流量——如果桶里积累了 N 个令牌,瞬间可以处理 N 个请求。这在实际业务中非常有用:用户刷新页面后连续加载 5 张图片,不应该是 5 秒加载一张。
令牌桶工作原理示意:
时间轴: → → → → → → → → → → →
桶容量: [⬤⬤⬤⬤⬤] (最大5个令牌)
放入速率:每秒1个令牌
请求消耗:每个请求取1个令牌
场景1 - 稳定请求:
t=0: 桶满[⬤⬤⬤⬤⬤], 请求→取出⬤, 桶[⬤⬤⬤⬤] ✅
t=1: 桶[⬤⬤⬤⬤⬤], 请求→取出⬤, 桶[⬤⬤⬤⬤] ✅
场景2 - 突发请求:
t=0: 桶满[⬤⬤⬤⬤⬤], 连续5个请求 → 全部通过 ✅
t=0.5: 桶[空], 第6个请求 → 拒绝 ❌
1.2 漏桶(Leaky Bucket)—— 最平滑的选择
漏桶的请求以任意速率进入桶,但桶以固定速率流出处理。它能将突发流量完全「削峰」为平滑输出,但代价是延迟增加——所有请求都要排队等待。
漏桶适合对输出速率有严格要求的场景,比如向第三方 API 发送请求(对方有严格 QPS 限制)。但不适合对延迟敏感的用户请求。
1.3 滑动窗口日志(Sliding Window Log)—— 最精确的选择
每个请求记录精确的时间戳,判断窗口内的请求数是否超限。精度最高,但内存消耗巨大——如果每秒 1000 请求,需要存储窗口内所有时间戳。
1.4 滑动窗口计数器(Sliding Window Counter)—— 生产环境首选
这是对固定窗口计数器的改进,用加权计算近似滑动窗口效果。内存消耗极低(只需两个计数器),精度在绝大多数场景下足够。Nginx、Redis 的限流实现大多基于此算法。
滑动窗口计数器原理:
当前时间点落在"当前窗口"内,但需要考虑上一窗口的剩余权重:
上一窗口 [████████░░] 80% 已过 → 剩余权重 20%
当前窗口 [████░░░░░░] 40% 已过 → 100% 计入
有效请求数 ≈ 上一窗口计数 × 剩余权重 + 当前窗口计数
= 100 × 0.2 + 30
= 50
📊 四种算法特性对比
| 特性 | 令牌桶 | 漏桶 | 滑动窗口日志 | 滑动窗口计数器 |
|---|---|---|---|---|
| 突发流量处理 | ✅ 允许突发 | ❌ 完全削峰 | ✅ 精确控制 | ✅ 近似控制 |
| 内存消耗 | 低(2个变量) | 低(2个变量) | 高(存时间戳) | 低(2个计数器) |
| 实现复杂度 | ⭐⭐ | ⭐⭐ | ⭐⭐⭐ | ⭐⭐ |
| 分布式实现 | 简单 | 较难 | 简单 | 简单 |
| 精度 | 粒度级 | 粒度级 | 精确 | 近似 |
| 适用场景 | API 网关通用 | 第三方对接 | 小流量高精度 | 生产环境首选 |
⚡ 关键结论: 90% 的生产场景选择「滑动窗口计数器」或「令牌桶」就够了。滑动窗口日志只在极低 QPS 且需要精确控制的场景(如短信发送限制)才值得使用。
🚀 二、Redis 分布式限流实现
单机限流用内存计数即可,但分布式环境下多个实例必须共享计数状态。Redis 的 INCR + EXPIRE 原子操作天然适合这个场景。
2.1 滑动窗口计数器(推荐方案)
这是我在生产环境中验证过最稳定的方案,用 Redis Sorted Set 实现精确的滑动窗口:
// rate-limiter-sliding-window.js
// 滑动窗口计数器 - 基于 Redis Sorted Set 实现
const Redis = require('ioredis');
const redis = new Redis({ host: '127.0.0.1', port: 6379 });
/**
* 滑动窗口限流器
* @param {string} key - 限流键(如 user:123:api)
* @param {number} limit - 窗口内最大请求数
* @param {number} windowMs - 窗口大小(毫秒)
* @returns {Promise<{allowed: boolean, remaining: number, retryAfter: number}>}
*/
async function slidingWindowRateLimit(key, limit, windowMs) {
const now = Date.now();
const windowStart = now - windowMs;
// Lua 脚本保证原子性:移除过期记录 + 添加新记录 + 计数 + 设置过期
const luaScript = `
local key = KEYS[1]
local window_start = tonumber(ARGV[1])
local now = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local window_ms = tonumber(ARGV[4])
-- 移除窗口外的过期记录
redis.call('ZREMRANGEBYSCORE', key, '-inf', window_start)
-- 获取当前窗口内的请求数
local current_count = redis.call('ZCARD', key)
if current_count < limit then
-- 未超限:添加当前请求
redis.call('ZADD', key, now, now .. ':' .. math.random(1000000))
redis.call('PEXPIRE', key, window_ms)
return {1, limit - current_count - 1, 0}
else
-- 已超限:计算需要等待的时间
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
local retry_after = 0
if #oldest > 0 then
retry_after = tonumber(oldest[2]) + window_ms - now
end
return {0, 0, retry_after}
end
`;
const result = await redis.eval(
luaScript, 1, key,
windowStart.toString(), now.toString(),
limit.toString(), windowMs.toString()
);
return {
allowed: result[0] === 1,
remaining: result[1],
retryAfter: Math.max(0, result[2])
};
}
// 使用示例:每个用户每分钟最多 60 次请求
async function handleRequest(userId) {
const result = await slidingWindowRateLimit(
`ratelimit:user:${userId}`,
60, // 60 次
60000 // 60 秒窗口
);
if (!result.allowed) {
return {
status: 429,
headers: {
'X-RateLimit-Remaining': '0',
'Retry-After': Math.ceil(result.retryAfter / 1000).toString()
},
body: { error: '请求过于频繁,请稍后再试' }
};
}
return { status: 200, headers: { 'X-RateLimit-Remaining': result.remaining.toString() } };
}
📌 记住: 必须用 Lua 脚本保证原子性。如果用多条 Redis 命令拼接,在高并发下会出现竞态条件,实际通过的请求数可能超过限制。
2.2 令牌桶实现
令牌桶适合需要允许突发流量的 API 网关场景。核心难点在于「惰性填充」——不在定时器里放令牌,而是在每次请求时根据时间差计算应放多少:
// rate-limiter-token-bucket.js
// 令牌桶限流器 - Redis 实现惰性填充
const Redis = require('ioredis');
const redis = new Redis({ host: '127.0.0.1', port: 6379 });
/**
* 令牌桶限流器
* @param {string} key - 限流键
* @param {number} capacity - 桶容量(最大令牌数)
* @param {number} refillRate - 每秒补充的令牌数
* @param {number} tokens - 本次请求需要的令牌数(默认1)
*/
async function tokenBucketRateLimit(key, capacity, refillRate, tokens = 1) {
const luaScript = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local requested = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
-- 获取上次的令牌数和时间
local data = redis.call('HMGET', key, 'tokens', 'last_refill')
local current_tokens = tonumber(data[1]) or capacity
local last_refill = tonumber(data[2]) or now
-- 计算需要补充的令牌(惰性填充)
local elapsed = (now - last_refill) / 1000 -- 转换为秒
local new_tokens = math.min(capacity, current_tokens + elapsed * refill_rate)
-- 判断是否有足够的令牌
if new_tokens >= requested then
new_tokens = new_tokens - requested
redis.call('HMSET', key, 'tokens', new_tokens, 'last_refill', now)
redis.call('EXPIRE', key, math.ceil(capacity / refill_rate) * 2)
return {1, math.floor(new_tokens), 0}
else
-- 计算需要等待多久才有足够令牌
local deficit = requested - new_tokens
local wait_time = math.ceil(deficit / refill_rate * 1000)
redis.call('HMSET', key, 'tokens', new_tokens, 'last_refill', now)
return {0, math.floor(new_tokens), wait_time}
end
`;
const now = Date.now();
const result = await redis.eval(
luaScript, 1, key,
capacity.toString(), refillRate.toString(),
tokens.toString(), now.toString()
);
return {
allowed: result[0] === 1,
remaining: result[1],
retryAfter: result[2] // 毫秒
};
}
// 使用示例:API 网关限流,每秒 100 请求,允许突发 200
async function apiGatewayLimit(clientIp) {
const result = await tokenBucketRateLimit(
`gateway:ip:${clientIp}`,
200, // 桶容量:允许突发 200 请求
100, // 每秒补充 100 个令牌
1 // 每次消耗 1 个令牌
);
console.log(`IP ${clientIp}: allowed=${result.allowed}, remaining=${result.remaining}`);
return result;
}
2.3 多级限流策略
生产环境通常需要多级限流——先按 IP 限制防爬虫,再按用户限制防滥用,最后按接口限制保护后端资源:
// rate-limiter-multi-level.js
// 多级限流策略:IP → 用户 → 接口 三层防护
const Redis = require('ioredis');
const redis = new Redis({ host: '127.0.0.1', port: 6379 });
// 滑动窗口限流核心函数(复用之前的 Lua 脚本)
async function checkLimit(key, limit, windowMs) {
const now = Date.now();
const windowStart = now - windowMs;
const luaScript = `
local key = KEYS[1]
local window_start = tonumber(ARGV[1])
local now = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local window_ms = tonumber(ARGV[4])
redis.call('ZREMRANGEBYSCORE', key, '-inf', window_start)
local count = redis.call('ZCARD', key)
if count < limit then
redis.call('ZADD', key, now, now .. ':' .. math.random(1000000))
redis.call('PEXPIRE', key, window_ms)
return {1, limit - count - 1}
end
return {0, 0}
`;
const result = await redis.eval(luaScript, 1, key, windowStart.toString(), now.toString(), limit.toString(), windowMs.toString());
return { allowed: result[0] === 1, remaining: result[1] };
}
/**
* 多级限流中间件(Express/Koa 通用)
*
* 限流策略:
* - IP 级:每分钟 300 次(防爬虫)
* - 用户级:每分钟 60 次(防滥用)
* - 接口级:每秒 1000 次(保护后端)
*/
function multiLevelRateLimit(options = {}) {
const {
ipLimit = { limit: 300, window: 60000 }, // IP: 300次/分钟
userLimit = { limit: 60, window: 60000 }, // 用户: 60次/分钟
endpointLimit = { limit: 1000, window: 1000 }, // 接口: 1000次/秒
} = options;
return async function (req, res, next) {
const ip = req.ip || req.connection.remoteAddress;
const userId = req.user?.id || 'anonymous';
const endpoint = `${req.method}:${req.path}`;
// 第一层:IP 限流
const ipResult = await checkLimit(`rl:ip:${ip}`, ipLimit.limit, ipLimit.window);
if (!ipResult.allowed) {
res.set('X-RateLimit-Level', 'ip');
return res.status(429).json({ error: 'IP 请求过于频繁' });
}
// 第二层:用户限流
const userResult = await checkLimit(`rl:user:${userId}`, userLimit.limit, userLimit.window);
if (!userResult.allowed) {
res.set('X-RateLimit-Level', 'user');
return res.status(429).json({ error: '用户请求过于频繁' });
}
// 第三层:接口限流
const epResult = await checkLimit(`rl:endpoint:${endpoint}`, endpointLimit.limit, endpointLimit.window);
if (!epResult.allowed) {
res.set('X-RateLimit-Level', 'endpoint');
return res.status(429).json({ error: '接口繁忙,请稍后重试' });
}
// 设置限流头
res.set({
'X-RateLimit-Remaining-IP': ipResult.remaining.toString(),
'X-RateLimit-Remaining-User': userResult.remaining.toString(),
'X-RateLimit-Remaining-Endpoint': epResult.remaining.toString(),
});
next();
};
}
// Express 使用示例
const express = require('express');
const app = express();
// 全局应用多级限流
app.use(multiLevelRateLimit());
// 或者只对特定路由限流
app.post('/api/send-sms', multiLevelRateLimit({
ipLimit: { limit: 10, window: 60000 }, // SMS 接口更严格
userLimit: { limit: 5, window: 60000 },
}), (req, res) => {
res.json({ success: true });
});
💡 提示: 多级限流的顺序很重要——先检查成本最低的(IP 限流),再检查需要查询用户信息的(用户限流),最后检查业务级别的(接口限流)。这样可以尽早拒绝恶意请求,减少后端压力。
⚠️ 三、分布式限流的「坑」与最佳实践
3.1 Redis 集群下的限流一致性问题
在 Redis Cluster 模式下,同一个限流 key 可能在不同的分片上。虽然 Redis 会根据 key 的 hash 自动路由,但有几个容易踩的坑:
坑 1:Lua 脚本的 key 必须在同一 slot
Redis Cluster 要求 Lua 脚本中所有 key 必须属于同一个 slot。如果你的限流 key 包含用户 ID,正常使用没问题,但如果你想在一个脚本里同时操作多个 key(比如同时更新 IP 限流和用户限流),就需要用 Hash Tag:
// ❌ 错误:两个 key 可能在不同 slot
const key1 = `rl:ip:${ip}`;
const key2 = `rl:user:${userId}`;
// ✅ 正确:用 Hash Tag 确保在同一 slot(但通常不需要,因为是独立的限流检查)
const key1 = `rl:{${ip}}:ip`;
const key2 = `rl:{${ip}}:user`;
// ✅ 更好的做法:每层限流用独立的 Lua 脚本,不要混在一起
坑 2:Redis 主从切换导致计数丢失
Redis 异步复制意味着主节点写入成功后,如果主节点宕机,从节点提升为主节点时可能丢失最近的写入。在限流场景下,这意味着短暂的限流失效——可能有几十到几百个请求在主从切换期间漏过。
⚠️ 警告: 如果你的限流是安全相关的(如防暴力破解密码),不要只依赖 Redis。应该在应用层增加一层内存计数器作为兜底,或者使用 Redis 的 WAIT 命令强制同步复制(会增加延迟)。
3.2 限流响应的 HTTP 规范
很多团队的限流实现只返回一个 429 状态码,但缺少关键的响应头信息。根据 RFC 6585 和 draft-ietf-httpapi-ratelimit-headers,正确的做法是:
// rate-limit-headers.js
// 符合 RFC 规范的限流响应头
function setRateLimitHeaders(res, result, limit, windowSeconds) {
// 标准限流头(draft-ietf-httpapi-ratelimit-headers-07)
res.set({
'RateLimit-Limit': limit.toString(),
'RateLimit-Remaining': result.remaining.toString(),
'RateLimit-Reset': Math.ceil((Date.now() + windowSeconds * 1000) / 1000).toString(),
'Retry-After': result.allowed ? '0' : Math.ceil(windowSeconds).toString(),
});
}
// 中间件中的使用
async function rateLimitMiddleware(req, res, next) {
const result = await slidingWindowRateLimit(`rl:${req.ip}`, 100, 60);
// 设置标准限流头(无论是否超限都要设置)
setRateLimitHeaders(res, result, 100, 60);
if (!result.allowed) {
return res.status(429).json({
error: 'Too Many Requests',
message: '请求频率超过限制,请稍后重试',
retryAfter: Math.ceil(result.retryAfter / 1000)
});
}
next();
}
📌 记住: 即使请求通过了也要返回
RateLimit-Remaining头,这样前端可以在接近限制时主动降级(如减少轮询频率),而不是等到被拒绝才处理。
3.3 不同业务场景的限流策略
限流不是一刀切的——不同业务场景需要完全不同的参数配置:
| 场景 | 推荐算法 | 窗口 | 限制 | 原因 |
|---|---|---|---|---|
| 公开 API | 令牌桶 | 1秒 | 100 QPS | 允许突发,保持响应性 |
| 登录接口 | 滑动窗口 | 15分钟 | 5次 | 防暴力破解,严格限制 |
| 短信验证码 | 滑动窗口 | 60秒 | 1次/用户 | 防刷短信,成本控制 |
| 文件上传 | 令牌桶 | 1小时 | 10次 | 防滥用存储,允许偶尔批量 |
| 搜索接口 | 滑动窗口 | 1秒 | 20 QPS | 保护数据库,防止慢查询 |
| 第三方 Webhook | 漏桶 | 1秒 | 50 QPS | 平滑输出,保护下游 |
✅ 总结与工具推荐
限流看似简单,但在分布式环境下要做到精确、高性能、不误杀,需要对算法原理和 Redis 特性有深入理解。以下是关键建议:
- 默认选择滑动窗口计数器 —— 内存效率高、精度足够、实现简单,适合 90% 的场景
- 必须使用 Lua 脚本 —— 保证原子性是分布式限流的生命线,绝不能用多条命令拼接
- 多级限流分层防御 —— IP → 用户 → 接口,由粗到细,逐层过滤
- 返回标准限流头 —— 让客户端能感知剩余配额,主动做好降级准备
- 兜底策略不能少 —— Redis 宕机时,应用层内存计数器或令牌桶是最后的防线
⚡ 关键结论: 限流不是上线前临时加的功能,而是架构设计时就必须考虑的基础设施。在项目初期就引入限流中间件,远比线上被打挂后再补要轻松得多。
相关工具推荐:
- 🔧 Redis — 分布式限流的核心存储,建议使用 Redis 7.0+ 的 Function 特性替代 Lua 脚本
- 🔧 rate-limiter-flexible — Node.js 最成熟的限流库,支持多种后端(Redis、MongoDB、内存)
- 🔧 express-rate-limit — Express 生态最流行的限流中间件,简单场景开箱即用
- 🔧 Sentinel — 阿里开源的分布式流量治理组件,Java 生态首选
- 🔧 Cloudflare Rate Limiting — CDN 层限流,无需改代码即可防护