根据 Cloudflare 2025 年度报告,全球 DDoS 攻击同比增长 117%,其中针对 API 端点的应用层攻击占比首次超过 60%。一个没有限流保护的 API,就像一扇没有锁的门——不仅暴露在恶意攻击下,还可能被合法用户的突发流量冲垮。限流(Rate Limiting)是 API 网关的第一道防线,也是每一位后端开发者必须掌握的核心基础设施技能。本文将从算法原理出发,用 TypeScript 实现四种经典限流方案,并给出基于 Redis 的分布式生产级解决方案。
📌 **记住:**限流不是「可选功能」,而是 API 的生存必需品。每一个面向外部的 API 端点都应该有明确的流量控制策略。
📊 一、为什么 API 必须有限流?
1.1 真实案例:无限流的代价
2024 年,某知名 SaaS 平台因一个客户端 Bug 导致每秒发送超过 50 万次重复请求,数据库连接池在 30 秒内耗尽,整个平台宕机 4 小时,直接损失超过 200 万美元。事后复盘,根本原因就是缺少 API 层限流。
无限流的 API 面临三重风险:
- ❌ 资源耗尽:CPU、内存、数据库连接被打满
- ❌ 级联故障:一个服务的过载波及整个微服务链路
- ❌ 账单暴增:Serverless 场景下,一个失控脚本可能产生天价账单
1.2 限流的核心指标
在设计限流方案前,需要明确三个核心维度:
| 维度 | 说明 | 常见取值 |
|---|---|---|
| 请求速率(Rate) | 单位时间允许的最大请求数 | 100 req/s, 1000 req/min |
| 突发容量(Burst) | 短时间内允许超过均值的请求数 | 通常为 Rate 的 1.5-3 倍 |
| 粒度(Scope) | 限流的范围:全局、用户、IP、API Key | 按 API Key 最常见 |
💡 **提示:**限流粒度的选择直接影响用户体验。全局限流简单但粗糙,按用户/API Key 限流更公平但实现复杂。生产环境推荐按 API Key + 端点的组合粒度进行限流。
🔐 二、四大限流算法原理与实现
2.1 固定窗口计数器(Fixed Window Counter)
最简单的限流方案:将时间划分为固定大小的窗口(如每分钟),每个窗口维护一个计数器,超过阈值就拒绝请求。
// 固定窗口计数器实现
class FixedWindowRateLimiter {
private count = 0;
private windowStart = Date.now();
constructor(
private readonly maxRequests: number,
private readonly windowMs: number
) {}
tryAcquire(): boolean {
const now = Date.now();
// 窗口过期,重置计数器
if (now - this.windowStart >= this.windowMs) {
this.count = 0;
this.windowStart = now;
}
if (this.count < this.maxRequests) {
this.count++;
return true; // 允许请求
}
return false; // 拒绝请求
}
}
// 使用示例:每分钟最多 100 次请求
const limiter = new FixedWindowRateLimiter(100, 60_000);
console.log(limiter.tryAcquire()); // true
console.log(limiter.tryAcquire()); // true (count: 2)
⚠️ 致命缺陷:窗口边界突发问题。 如果限制是每分钟 100 次,在第一分钟的最后 1 秒用完 100 次配额,第二分钟的第 1 秒又立刻获得 100 次配额——这意味着在 2 秒的窗口交界处可以处理 200 次请求,远超预期。这在生产环境中可能导致短暂的过载。
2.2 滑动窗口计数器(Sliding Window Counter)
滑动窗口是对固定窗口的改进:用前一个窗口的加权计数 + 当前窗口的计数来平滑边界问题。
// 滑动窗口计数器实现
class SlidingWindowRateLimiter {
private prevCount = 0;
private currCount = 0;
private currWindowStart = Date.now();
constructor(
private readonly maxRequests: number,
private readonly windowMs: number
) {}
tryAcquire(): boolean {
const now = Date.now();
const elapsed = now - this.currWindowStart;
if (elapsed >= this.windowMs) {
// 当前窗口变为前一个窗口
this.prevCount = this.currCount;
this.currCount = 0;
this.currWindowStart = now;
}
// 计算前一个窗口的加权贡献
const positionInWindow = Math.min(elapsed / this.windowMs, 1);
const weightedPrevCount = this.prevCount * (1 - positionInWindow);
const effectiveCount = weightedPrevCount + this.currCount;
if (effectiveCount < this.maxRequests) {
this.currCount++;
return true;
}
return false;
}
}
// 使用示例:每分钟最多 100 次请求
const limiter = new SlidingWindowRateLimiter(100, 60_000);
滑动窗口解决了边界突发问题,计算复杂度为 O(1),空间复杂度也为 O(1),是生产环境中最常用的方案之一。
2.3 令牌桶算法(Token Bucket)
令牌桶是最经典的限流算法,也是 AWS API Gateway 和 Stripe 等主流 API 平台的默认限流方案。核心思想:桶以固定速率往里放令牌,请求需要取到令牌才能通过,桶满时多余令牌丢弃。
// 令牌桶算法实现
class TokenBucketRateLimiter {
private tokens: number;
private lastRefillTime: number;
constructor(
private readonly capacity: number, // 桶容量(最大令牌数)
private readonly refillRate: number // 每秒补充的令牌数
) {
this.tokens = capacity; // 初始满桶
this.lastRefillTime = Date.now();
}
tryAcquire(tokensNeeded: number = 1): boolean {
this.refill();
if (this.tokens >= tokensNeeded) {
this.tokens -= tokensNeeded;
return true;
}
return false;
}
private refill(): void {
const now = Date.now();
const elapsed = (now - this.lastRefillTime) / 1000; // 转换为秒
const tokensToAdd = elapsed * this.refillRate;
this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd);
this.lastRefillTime = now;
}
// 获取当前可用令牌数(用于返回 Retry-After 头)
getAvailableTokens(): number {
this.refill();
return Math.floor(this.tokens);
}
}
// 使用示例:桶容量 10,每秒补充 2 个令牌
const limiter = new TokenBucketRateLimiter(10, 2);
// 突发请求:连续取 10 个令牌
for (let i = 0; i < 12; i++) {
const allowed = limiter.tryAcquire();
console.log(`请求 ${i + 1}: ${allowed ? '✅ 允许' : '❌ 拒绝'}`);
}
// 前 10 个允许,后 2 个拒绝
令牌桶的核心优势:天然支持突发流量(Burst)。桶满时可以瞬间处理 capacity 个请求,之后以 refillRate 的速率持续处理,完美模拟真实场景中「偶尔突发、常态平稳」的流量模式。
2.4 漏桶算法(Leaky Bucket)
漏桶与令牌桶相反:请求进入桶排队,以固定速率从桶底「漏出」处理。桶满时新请求直接丢弃。
// 漏桶算法实现
class LeakyBucketRateLimiter {
private queue: number[] = [];
private lastLeakTime: number;
constructor(
private readonly capacity: number, // 桶容量
private readonly leakRate: number // 每秒漏出的请求数
) {
this.lastLeakTime = Date.now();
}
tryAcquire(): boolean {
this.leak();
if (this.queue.length < this.capacity) {
this.queue.push(Date.now());
return true;
}
return false;
}
private leak(): void {
const now = Date.now();
const elapsed = (now - this.lastLeakTime) / 1000;
const leaked = Math.floor(elapsed * this.leakRate);
if (leaked > 0) {
this.queue.splice(0, Math.min(leaked, this.queue.length));
this.lastLeakTime = now;
}
}
}
// 使用示例:桶容量 5,每秒漏出 1 个请求
const limiter = new LeakyBucketRateLimiter(5, 1);
漏桶的特点是输出速率恒定,适合需要平滑输出流量的场景(如消息队列的消费者限流)。但它的缺点也很明显:不允许突发流量,即使系统空闲也不能快速处理积压请求。
算法对比总览
| 算法 | 时间复杂度 | 空间复杂度 | 允许突发 | 平滑性 | 典型应用 |
|---|---|---|---|---|---|
| 固定窗口 | O(1) | O(1) | ⚠️ 窗口边界突发 | 差 | 简单场景、日志限流 |
| 滑动窗口 | O(1) | O(1) | ❌ | 好 | ✅ Web API 首选 |
| 令牌桶 | O(1) | O(1) | ✅ 可控突发 | 好 | ✅ API Gateway(AWS/Stripe) |
| 漏桶 | O(1) | O(n) | ❌ | 最好 | 消息队列、流量整形 |
⚠️ **警告:**不要在生产环境使用固定窗口计数器。窗口边界突发问题在高并发场景下会导致 2 倍于预期的流量穿透,后果严重。如果追求简单,至少使用滑动窗口计数器。
🚀 三、分布式限流:基于 Redis 的生产级方案
单机限流在微服务架构下完全不够用——多个服务实例各自维护计数器,限流形同虚设。分布式限流的核心思路是将计数器存储在共享的 Redis 中,利用 Lua 脚本保证原子性。
3.1 Redis + Lua 实现令牌桶
// 基于 Redis 的分布式令牌桶限流器
import Redis from 'ioredis';
const redis = new Redis();
// Lua 脚本:原子性地执行令牌桶逻辑
const TOKEN_BUCKET_SCRIPT = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refillRate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
-- 获取当前桶状态
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local lastRefill = tonumber(bucket[2]) or now
-- 计算补充的令牌数
local elapsed = math.max(0, now - lastRefill)
local refill = elapsed * refillRate
tokens = math.min(capacity, tokens + refill)
-- 尝试获取令牌
local allowed = 0
if tokens >= requested then
tokens = tokens - requested
allowed = 1
end
-- 更新桶状态,设置过期时间为窗口的 2 倍(防止内存泄漏)
redis.call('HMSET', key, 'tokens', tostring(tokens), 'last_refill', tostring(now))
redis.call('EXPIRE', key, math.ceil(capacity / refillRate) * 2)
return allowed
`;
class DistributedTokenBucket {
constructor(
private readonly redis: Redis,
private readonly key: string,
private readonly capacity: number,
private readonly refillRate: number // 每秒补充的令牌数
) {}
async tryAcquire(tokens: number = 1): Promise<boolean> {
const now = Date.now() / 1000;
const result = await this.redis.eval(
TOKEN_BUCKET_SCRIPT,
1,
this.key,
this.capacity.toString(),
this.refillRate.toString(),
now.toString(),
tokens.toString()
);
return result === 1;
}
}
// 使用示例:每个 API Key 每秒 10 个请求,突发最多 20 个
const limiter = new DistributedTokenBucket(
redis,
'rate_limit:user_123',
20, // capacity
10 // refillRate: 每秒 10 个令牌
);
async function handleRequest() {
const allowed = await limiter.tryAcquire();
if (!allowed) {
throw new Error('Rate limit exceeded');
}
// 处理请求...
}
📌 **记住:**Redis Lua 脚本的原子性是分布式限流的关键。如果用「先 GET 再 SET」的方式,在高并发下会出现竞态条件,导致限流失效。必须使用 Lua 脚本或 Redis 事务保证读写原子性。
3.2 Redis 滑动窗口实现
对于需要更精确限流的场景,可以使用 Redis Sorted Set 实现真正的滑动窗口:
// 基于 Redis Sorted Set 的滑动窗口限流器
const SLIDING_WINDOW_SCRIPT = `
local key = KEYS[1]
local windowMs = tonumber(ARGV[1])
local maxRequests = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requestId = ARGV[4]
-- 清除窗口外的旧记录
local windowStart = now - windowMs
redis.call('ZREMRANGEBYSCORE', key, '-inf', tostring(windowStart))
-- 统计当前窗口内的请求数
local count = redis.call('ZCARD', key)
if count < maxRequests then
-- 添加当前请求
redis.call('ZADD', key, tostring(now), requestId)
redis.call('PEXPIRE', key, windowMs)
return 1
else
-- 计算需要等待的时间
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
local retryAfter = tonumber(oldest[2]) + windowMs - now
return -math.ceil(retryAfter)
end
`;
class DistributedSlidingWindow {
constructor(
private readonly redis: Redis,
private readonly key: string,
private readonly maxRequests: number,
private readonly windowMs: number
) {}
async tryAcquire(): Promise<{ allowed: boolean; retryAfterMs?: number }> {
const now = Date.now();
const requestId = `${now}:${Math.random().toString(36).slice(2)}`;
const result = await this.redis.eval(
SLIDING_WINDOW_SCRIPT,
1,
this.key,
this.windowMs.toString(),
this.maxRequests.toString(),
now.toString(),
requestId
);
if (result === 1) {
return { allowed: true };
}
return { allowed: false, retryAfterMs: Math.abs(result as number) };
}
}
// 使用示例:每分钟最多 60 次请求
const limiter = new DistributedSlidingWindow(
redis,
'rate_limit:api:user_123',
60,
60_000
);
这个方案的巧妙之处在于:通过 ZREMRANGEBYSCORE 清除窗口外的记录,ZCARD 统计当前窗口请求数,ZADD 添加新请求——整个过程在 Lua 脚本中原子执行,既精确又高效。
🔧 四、实战集成:Hono/Express 中间件
限流算法写好了,下一步是集成到 Web 框架中。以下是 Hono 框架的限流中间件实现:
// Hono 限流中间件
import { Context, Next } from 'hono';
import { Redis } from 'ioredis';
interface RateLimitOptions {
windowMs: number; // 时间窗口(毫秒)
maxRequests: number; // 最大请求数
keyGenerator: (c: Context) => string; // 限流 Key 生成函数
message?: string; // 超限返回消息
skipSuccessfulRequests?: boolean;
}
function rateLimit(options: RateLimitOptions) {
const {
windowMs,
maxRequests,
keyGenerator,
message = 'Too many requests, please try again later.',
skipSuccessfulRequests = false,
} = options;
const redis = new Redis(process.env.REDIS_URL!);
return async (c: Context, next: Next) => {
const key = `rl:${keyGenerator(c)}`;
const now = Date.now();
// 使用滑动窗口 Lua 脚本
const result = await redis.eval(
SLIDING_WINDOW_SCRIPT, // 复用上文的脚本
1,
key,
windowMs.toString(),
maxRequests.toString(),
now.toString(),
`${now}:${Math.random().toString(36).slice(2)}`
);
if (result !== 1) {
const retryAfterMs = Math.abs(result as number);
c.header('Retry-After', Math.ceil(retryAfterMs / 1000).toString());
c.header('X-RateLimit-Limit', maxRequests.toString());
c.header('X-RateLimit-Remaining', '0');
return c.json({ error: message }, 429);
}
await next();
// 如果配置了跳过成功请求且请求成功,则不计数
if (skipSuccessfulRequests && c.res.status < 400) {
await redis.zrem(key, `${now}:*`);
}
};
}
// 使用示例
import { Hono } from 'hono';
const app = new Hono();
// 全局限流:每个 IP 每分钟 100 次
app.use('*', rateLimit({
windowMs: 60_000,
maxRequests: 100,
keyGenerator: (c) => {
return c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || 'unknown';
},
}));
// 登录端点更严格限流:每个 IP 每 15 分钟最多 5 次
app.post('/api/auth/login', rateLimit({
windowMs: 15 * 60_000,
maxRequests: 5,
keyGenerator: (c) => {
const ip = c.req.header('x-forwarded-for') || 'unknown';
return `login:${ip}`;
},
message: '登录尝试过于频繁,请 15 分钟后重试',
}), async (c) => {
// 处理登录逻辑
return c.json({ success: true });
});
⚠️ **警告:**不要用
x-forwarded-for的第一个值作为限流 Key——这个头部可以被客户端伪造。生产环境中应该取最后一个可信代理添加的 IP,或者使用框架提供的c.env.remoteAddress等可信来源。
💡 五、限流策略设计与避坑指南
5.1 响应头规范
一个符合规范的限流实现必须返回标准的 HTTP 头部,让客户端知道当前的限流状态:
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100 # 窗口内允许的最大请求数
X-RateLimit-Remaining: 0 # 窗口内剩余请求数
X-RateLimit-Reset: 1717056000 # 窗口重置的 Unix 时间戳
Retry-After: 30 # 建议等待的秒数
5.2 常见陷阱与解决方案
❌ 陷阱一:只限流入,不限流出
很多团队只对外部 API 做限流,却忽略了内部服务之间的调用。当服务 A 以每秒 1000 次的速率调用服务 B,而服务 B 只能处理 500 次/秒时,服务 B 必然崩溃。
✅ 正确做法:内外部都要限流,内部服务间使用熔断器(Circuit Breaker)配合限流。
❌ 陷阱二:限流 Key 设计不当
用 IP 限流对使用 NAT 的企业用户极不公平——整个办公室可能共享一个出口 IP,一个人用多了就把所有人限了。
✅ 正确做法:优先使用 API Key / 用户 ID 作为限流 Key,IP 限流作为兜底防护。
❌ 陷阱三:返回 429 但不设 Retry-After
客户端收到 429 后不知道什么时候可以重试,只能盲目重试(thundering herd),反而加剧服务压力。
✅ 正确做法:总是返回 Retry-After 头,客户端使用指数退避 + Retry-After 的组合策略。
5.3 多层限流架构
生产环境推荐采用多层限流:
| 层级 | 位置 | 限流方式 | 延迟 |
|---|---|---|---|
| L1 | CDN/WAF | IP 级别 DDoS 防护 | < 1ms |
| L2 | API Gateway | API Key 级别速率限制 | 1-5ms |
| L3 | 应用层 | 用户级别精细限流 | 5-20ms |
| L4 | 数据库层 | 连接池限制 | N/A |
💡 **提示:**CDN/WAF 层(L1)的限流不需要精确,它的职责是挡住洪泛攻击;精确限流交给应用层(L2/L3)。分层越早拦截,系统越安全。
📝 总结与工具推荐
限流方案的选择没有银弹,核心是根据业务场景做取舍:
- ✅ 简单场景(单体应用、低并发)→ 滑动窗口计数器,代码量少、效果好
- ✅ API Gateway(需要突发支持)→ 令牌桶算法,AWS/Stripe 都在用
- ✅ 微服务架构(多实例部署)→ Redis + Lua 分布式方案,必须原子操作
- ✅ 严格流量整形(消息队列、支付网关)→ 漏桶算法,输出绝对平滑
推荐工具链:
| 工具 | 类型 | 适用场景 |
|---|---|---|
bottleneck |
npm 包 | Node.js 客户端限流(防止调用外部 API 超限) |
rate-limiter-flexible |
npm 包 | 功能最全的 Node.js 限流库,支持 Redis/Cluster |
| Cloudflare Rate Limiting | SaaS | CDN 层限零配置限流 |
| Kong Rate Limiting | 插件 | API Gateway 层限流 |
express-rate-limit |
npm 包 | Express 快速集成(单机场景) |
⚡ **关键结论:**限流不是一个单独的功能,而是贯穿整个请求链路的安全基础设施。从 CDN 到应用层,每一层都应该有自己的限流策略。记住:宁可限流拒绝一些请求,也不要让整个系统因为过载而全面崩溃。前者只是部分用户暂时不可用,后者是所有用户全部不可用。