API 接口如果不做频率限制,迟早会被滥用——可能是爬虫、可能是恶意攻击、也可能是前端 bug 导致的重复请求。我们在实际项目中实现了一个从内存级方案起步、后续可平滑升级到 Redis 的 Rate Limiter,这里分享设计思路。
最简方案:内存 Map
核心思路是用一个 Map 记录每个 IP 的请求次数和时间窗口:
interface RateLimitEntry {
count: number;
resetTime: number;
}
const store = new Map<string, RateLimitEntry>();
const WINDOW_MS = 60 * 1000; // 1 分钟窗口
const MAX_REQUESTS = 60; // 每窗口最多 60 次
function isRateLimited(identifier: string): boolean {
const now = Date.now();
const entry = store.get(identifier);
if (!entry || now > entry.resetTime) {
store.set(identifier, { count: 1, resetTime: now + WINDOW_MS });
return false;
}
entry.count++;
return entry.count > MAX_REQUESTS;
}
在 API 路由中使用:
export async function POST(request: Request) {
const ip = request.headers.get('x-forwarded-for') || 'unknown';
if (isRateLimited(ip)) {
return Response.json(
{ error: 'Too many requests' },
{ status: 429 }
);
}
// 正常业务逻辑...
}
IP 识别的细节
获取真实客户端 IP 并不像想象中简单。在反向代理(Nginx、CDN)后面,request.ip 可能是代理服务器的地址。正确的做法是按优先级读取:
function getClientIP(request: Request): string {
const forwarded = request.headers.get('x-forwarded-for');
if (forwarded) {
// 取第一个 IP(最接近客户端的)
return forwarded.split(',')[0].trim();
}
const realIP = request.headers.get('x-real-ip');
if (realIP) return realIP;
return 'unknown';
}
注意:x-forwarded-for 可以被伪造,生产环境中要确保你的反向代理会覆盖这个头而不是追加。
内存泄漏防护
内存方案最大的隐患是 Map 无限增长。每个不同的 IP 都会添加一条记录,长时间运行后内存持续上升。必须定期清理过期条目:
// 每 5 分钟清理一次
setInterval(() => {
const now = Date.now();
for (const [key, entry] of store) {
if (now > entry.resetTime) {
store.delete(key);
}
}
}, 5 * 60 * 1000);
这个清理逻辑看似简单,但很容易被遗忘。我们在项目中就因为忘了加清理,导致一个月后服务内存从 200MB 涨到了 1.2GB。
分层限流策略
实际业务中,不同接口的限流策略往往不同:
const RATE_LIMITS = {
login: { window: 5 * 60 * 1000, max: 5 }, // 登录:5分钟5次
api: { window: 60 * 1000, max: 100 }, // 普通接口:1分钟100次
upload: { window: 60 * 1000, max: 10 }, // 上传:1分钟10次
};
function isRateLimited(ip: string, type: keyof typeof RATE_LIMITS): boolean {
const config = RATE_LIMITS[type];
const key = `${type}:${ip}`;
// ...同样的逻辑,使用 config.window 和 config.max
}
登录接口要严格限制(防暴力破解),文件上传要限制(防资源滥用),普通查询可以宽松一些。
什么时候该升级到 Redis
内存方案在以下场景不够用:
- 多实例部署:每个进程有自己的 Map,限流形同虚设。用户轮询到不同实例就绕过了限制。
- 重启丢失:服务重启后所有计数器清零,等于限流短暂失效。
- 内存压力:高并发场景下 IP 数量巨大,内存方案不经济。
升级到 Redis 的改动很小,核心逻辑不变,只是把存储从 Map 换成 Redis:
import Redis from 'ioredis';
const redis = new Redis();
async function isRateLimited(ip: string): Promise<boolean> {
const key = `rate:${ip}`;
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, 60); // 60 秒过期
}
return count > MAX_REQUESTS;
}
Redis 的 INCR + EXPIRE 组合天然支持计数和自动过期,不需要手动清理。而且多实例共享同一个 Redis,限流策略全局生效。
响应头的规范
好的 Rate Limiter 应该在响应头中告知客户端限流状态:
const headers = {
'X-RateLimit-Limit': String(MAX_REQUESTS),
'X-RateLimit-Remaining': String(Math.max(0, MAX_REQUESTS - count)),
'X-RateLimit-Reset': String(Math.ceil(resetTime / 1000)),
};
前端拿到这些信息后可以做自适应请求(比如接近限额时降低请求频率),而不是等到被 429 了才知道。
总结
Rate Limiter 的实现不复杂,但选对方案很重要。单实例应用用内存 Map 就够了,记得加过期清理;多实例部署必须上 Redis。不管哪种方案,核心都是:识别客户端 → 计数 → 判断阈值 → 返回合适的状态码和头信息。