去年,我们的 API 被打爆了3次。第一次是爬虫,第二次是DDOS,第三次是自己人搞的压测。
后来加了限流,再也没出过问题。
第一次被打爆:爬虫
某天凌晨,监控报警:API 响应时间从50ms飙到5秒。
查日志,发现有个IP在疯狂请求我们的接口,每秒1000+请求。
数据库CPU 100%,Redis也挂了,整个系统瘫痪。
临时方案:在nginx里把这个IP ban掉。
但下次怎么办?总不能每次都手动ban吧。
限流的几种策略
1. 固定窗口
最简单的限流方式:每分钟最多100个请求。
const requests = {}; function rateLimit(userId) { const now = Math.floor(Date.now() / 60000); // 当前分钟 const key = `${userId}:${now}`; requests[key] = (requests[key] || 0) + 1; return requests[key] <= 100; }问题:临界点突刺。
如果用户在 00:59 发了100个请求,01:00 又发了100个请求,相当于1秒内200个请求。
2. 滑动窗口
用一个队列记录每个请求的时间,检查最近1分钟内有多少请求。
const requests = new Map(); function rateLimit(userId) { const now = Date.now(); const userRequests = requests.get(userId) || []; // 删除1分钟前的请求 const validRequests = userRequests.filter(time => now - time < 60000); if (validRequests.length >= 100) { return false; } validRequests.push(now); requests.set(userId, validRequests); return true; }问题:内存占用大,要记录每个请求的时间。
3. 令牌桶(Token Bucket)
我们最后用的方案。
原理:
- 桶里有一定数量的令牌(比如100个)
- 每次请求消耗1个令牌
- 令牌以固定速率补充(比如每秒10个)
- 桶满了就不再补充
优点:
- 能应对突发流量
- 实现简单
- 内存占用小
用 Redis 实现令牌桶
单机限流不行,多台服务器要共享限流状态,用Redis。
const redis = require('redis').createClient(); async function rateLimit(userId) { const key = `rate_limit:${userId}`; const now = Date.now(); const capacity = 100; // 桶容量 const refillRate = 10; // 每秒补充10个令牌 // 获取当前状态 const data = await redis.get(key); let tokens, lastRefill; if (data) { ({ tokens, lastRefill } = JSON.parse(data)); // 计算应该补充多少令牌 const elapsed = (now - lastRefill) / 1000; tokens = Math.min(capacity, tokens + elapsed * refillRate); } else { tokens = capacity; } // 消耗1个令牌 if (tokens >= 1) { tokens -= 1; await redis.setex(key, 60, JSON.stringify({ tokens, lastRefill: now })); return true; } return false; }这样,每个用户都有自己的令牌桶。
更好的方案:Redis Lua脚本
上面的代码有问题:get 和 setex 不是原子操作,高并发下会出问题。
用 Lua 脚本保证原子性:
const script = ` local key = KEYS[1] local capacity = tonumber(ARGV[1]) local refillRate = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local data = redis.call('GET', key) local tokens, lastRefill if data then local decoded = cjson.decode(data) tokens = decoded.tokens lastRefill = decoded.lastRefill local elapsed = (now - lastRefill) / 1000 tokens = math.min(capacity, tokens + elapsed * refillRate) else tokens = capacity end if tokens >= 1 then tokens = tokens - 1 redis.call('SETEX', key, 60, cjson.encode({ tokens = tokens, lastRefill = now })) return 1 else return 0 end `; async function rateLimit(userId) { const result = await redis.eval( script, 1, // keys数量 `rate_limit:${userId}`, 100, // capacity 10, // refillRate Date.now() ); return result === 1; }完美。
不同级别的限流
我们设置了3个级别:
1. 全局限流
整个系统每秒最多10000个请求。
防止服务器被打爆。
2. 用户限流
每个用户每分钟最多100个请求。
防止单个用户滥用。
3. 接口限流
某些昂贵的接口单独限流,比如:
- 导出数据:每小时最多5次
- 发送邮件:每天最多100封
- AI生成:每分钟最多10次
限流后的友好提示
被限流了要告诉用户,别让他们一脸懵逼。
if (!await rateLimit(userId)) { return res.status(429).json({ error: 'Too Many Requests', message: '请求过于频繁,请稍后再试', retryAfter: 60 // 60秒后重试 }); }HTTP状态码用 429 Too Many Requests,前端可以根据这个做友好提示。
白名单机制
有些用户需要更高的限额,比如付费用户、内部系统。
async function getRateLimit(userId) { // VIP用户限额更高 if (await isVIP(userId)) { return { capacity: 1000, refillRate: 100 }; } // 内部系统不限流 if (await isInternal(userId)) { return null; // 跳过限流 } // 普通用户 return { capacity: 100, refillRate: 10 }; }监控和告警
限流后要监控:
- 每分钟有多少请求被限流了
- 哪些用户触发限流最多
- 哪些接口触发限流最多
如果某个用户频繁触发限流,可能是:
- 爬虫
- 客户端有 bug,一直重试
- 恶意攻击
要及时处理。
现在的效果
限流上线后:
- API 再也没被打爆过
- 数据库 CPU 稳定在30%以下
- 99%的用户感知不到限流(因为正常用不会触发)
每天拦截约 5万 个异常请求。
其他方案
1. 用现成的工具
不想自己实现,可以用:
- Nginx限流:
ngx_http_limit_req_module - Kong:API网关,内置限流插件
- Cloudflare:CDN层限流
2. 用云服务
- AWS API Gateway:支持限流
- 阿里云 API Gateway:支持限流
总结
限流是保护系统的必备手段:
- 选对策略:令牌桶最适合API限流
- 用Redis实现:支持分布式
- 用Lua脚本:保证原子性
- 分级限流:全局、用户、接口
- 友好提示:告诉用户为什么被限流
- 监控告警:及时发现异常
别等被打爆了才加限流。
评论区