首页技术专题博客目录关于与联系

API 限流的正确姿势:从被打爆到稳如狗

API 被打爆了3次,后来做了限流,用令牌桶算法 + Redis,再也没出过问题。分享限流的正确姿势。

去年,我们的 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脚本

上面的代码有问题:getsetex 不是原子操作,高并发下会出问题。

用 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:支持限流

总结

限流是保护系统的必备手段:

  1. 选对策略:令牌桶最适合API限流
  2. 用Redis实现:支持分布式
  3. 用Lua脚本:保证原子性
  4. 分级限流:全局、用户、接口
  5. 友好提示:告诉用户为什么被限流
  6. 监控告警:及时发现异常

别等被打爆了才加限流。


参考资料:Token Bucket Algorithm

评论区