API 限流策略深入:算法与实现
限流不只"每分钟 N 次"——用什么算法(固定窗/滑动窗/令牌桶/漏桶)、按什么维度(IP/用户/API key)、存哪(内存/Redis)、超限返回什么(429/Retry-After),都有讲究。本文讲清四种限流算法的对比与实现,给一份生产级限流方案。
本文要点
- 主题聚焦:DevOps
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
很多团队对限流的理解,停留在"加个中间件,每分钟 100 次"。等出事了才发现:边界突刺把数据库打爆了、NAT 后面整层楼的用户被误杀、并发下计数根本不准、客户端不知道什么时候能重试一遍遍撞墙。
限流不是"加个 N"这么简单。它要回答四个问题:用什么算法、按什么维度、存哪、超限返回什么。每个问题都有坑。这篇文章把四个问题讲透,最后给一份能直接抄的生产级方案。
一、限流的目的:不只是防 DDoS
限流常被等同于"防黑客"。错。它的核心目标有四个,重要性不分先后:
- 保护服务——下游数据库连接池、第三方 API 配额都有上限。突发流量打过来,级联故障、雪崩、整站 503,都是这么来的。
- 公平性——单个用户/租户不能独占资源,否则其他用户被饿死。多租户 SaaS 这条是底线。
- 防刷——爬虫、薅羊毛、撞库暴力破解,都得靠限流挡。
- 计费——按 API 调用次数计费的 SaaS,限流是计费的前置:你能限,才能数,才能收钱。
所以限流是 API 的"免疫系统",默认就要有,而不是出事后的补丁。
二、四种算法:各自的脾气
1. 固定窗口(Fixed Window)
把时间切成固定窗口(比如每分钟一个),窗口内计数,超过阈值拒绝,窗口结束归零。
00:00–00:59 计数到 N 就拒绝
01:00 清零,重新计数
致命缺陷:边界突刺。用户在 00:59 发了 100 次(满额),01:01 又发 100 次——2 秒内实际通过 200 次,窗口边界成了流量尖峰。下游如果扛不住这 2 秒的 2 倍峰值,固定窗口救不了你。
Redis 实现简单到离谱:
INCR ratelimit:user:123:202607171530
EXPIRE ratelimit:user:123:202607171530 60
适合后台任务、精度要求不高的简单计数。生产 API 别只用它。
2. 滑动窗口(Sliding Window)
不再"按自然分钟切",而是"从现在往前看 60 秒"。用 Redis 的 sorted set 存每个请求的时间戳,每次进来先清理过期成员,再统计当前窗口内数量。
-- KEYS[1] 限流 key
-- ARGV[1] 当前时间戳(ms) ARGV[2] 窗口大小(ms) ARGV[3] 最大请求数
local now, window, limit = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window) -- 清理窗口外
local count = redis.call('ZCARD', KEYS[1])
if count >= tonumber(ARGV[3]) then return 0 end
redis.call('ZADD', KEYS[1], now, now)
redis.call('PEXPIRE', KEYS[1], window)
return 1
平滑、无突刺、精度高。代价是内存:每个请求占一个 zset 成员。适合关键 API 和计费场景。
3. 令牌桶(Token Bucket)
桶里匀速生成令牌,请求消耗令牌,桶空了拒绝。关键特性:允许短时突发。空闲一段时间后桶会填满,下次可以一次性消耗多个令牌;之后回到匀速。
令牌桶是 API 入口限流的标配。原因:API 调用天然有突发性(页面加载时前端并发十几个请求),令牌桶能容纳这种正常突发,而固定/滑动窗口会误杀。
-- KEYS[1] 桶 key
-- ARGV: [容量, 生成速率(令牌/秒), 当前时间(秒), 本次消耗]
local capacity, rate, now, cost = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]), tonumber(ARGV[4])
local data = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(data[1]) or capacity
local ts = tonumber(data[2]) or now
tokens = math.min(capacity, tokens + math.max(0, now - ts) * rate) -- 补令牌
local allowed = 0
if tokens >= cost then tokens = tokens - cost; allowed = 1 end
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / rate) * 2)
return allowed
4. 漏桶(Leaky Bucket)
请求像水倒进桶里,底部以恒定速率漏出。桶满即拒绝。输出速率严格恒定,不接受突发。
漏桶 vs 令牌桶:漏桶强制匀速输出,令牌桶允许突发。所以分工不同:
- 漏桶适合下游保护——比如调用第三方支付 API,强制平滑,避免被对方限流或被当成攻击。
- 令牌桶适合入口限流——允许正常突发,只挡异常流量。
算法对比表
| 算法 | 实现复杂度 | 内存 | 突发 | 精度 | 典型场景 |
|---|---|---|---|---|---|
| 固定窗口 | 极低 | O(1) | 边界突刺 | 差 | 后台任务、粗粒度计数 |
| 滑动窗口 | 中 | O(N) | 平滑 | 高 | 关键 API、计费 |
| 令牌桶 | 中 | O(1) | 支持 | 高 | API 入口(主流) |
| 漏桶 | 中 | O(1) | 不支持 | 高 | 下游保护、队列削峰 |
三、按什么维度限:IP、用户、Key,还是全局
选维度,等于回答"谁是流量主体":
- IP——匿名用户唯一可识别维度。坑在 NAT/公司出口共享:一栋写字楼一个 IP,阈值压太低会误杀整个公司。IP 限流要相应放宽。
- 用户 ID——登录用户最准。但需要先认证才能判断,且同一用户多设备要合并到同一计数。
- API Key——第三方集成最准,Key 直接绑定到客户和计费账户。
- 全局——不分来源,保护服务整体。最后一道防线。
实践是多维组合,不是二选一。下一节会看到为什么。
四、存哪:内存够不够,Redis 怎么用
- 单机内存——单实例够用,延迟最低。多实例不共享、重启丢失,开发或小规模可以。
- Redis——多实例共享,主流方案。每个请求一次 Redis 往返,延迟在几毫秒级。
- Redis + Lua——把"读计数 + 判断 + 写回"封装成原子操作。生产级必须走 Lua,否则并发下计数根本不准。
为什么必须 Lua?考虑 INCR → 判断 → EXPIRE 三步:两个请求并发读到 count=99(限额 100),都判断通过,实际放进 101 个。Redis 单线程执行 Lua 脚本,整段是原子的,杜绝这种竞态。前面三段 Lua 代码就是为这个写的。
五、超限响应:别默默丢,也别返 500
超限要给客户端明确信号,而不是默默丢请求或返 500(那是服务端的锅)。规范做法:
HTTP 429 Too Many Requests + Retry-After 头:
HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/json
{"error": "rate_limit_exceeded", "retry_after": 30}
X-RateLimit-* 头(Twitter 早期推广,事实标准):
X-RateLimit-Limit: 100 窗口上限
X-RateLimit-Remaining: 73 剩余
X-RateLimit-Reset: 1698902400 重置时间(Unix 秒)
给客户端这些信息,它们能自适应退避、在 UI 上显示"还剩多少次"、避免反复撞墙。这是 API 礼仪,也是降低客服工单量的便宜买卖。
六、多层限流:一道防线不够
单层限流顾此失彼。生产架构通常是洋葱式的多层:
请求 → [全局 100k/s] → [IP 100/s] → [用户 1000/h] → [API Key 100/min] → 服务
- 全局限:防整体打爆,保护数据库和下游。
- 用户限:保证公平,防止单租户独占。
- IP 限:挡匿名刷子和爬虫。
每层可以用不同算法:全局用漏桶(平滑输入到下游),用户用令牌桶(允许正常突发),IP 用滑动窗口(精确挡刷)。任一层触发都拒绝,各司其职。
七、实现:自研 Lua 还是上库
两条路:
自研 Redis + Lua——完全可控,适合有特殊需求(复杂分层、自定义响应头、和计费系统挂钩)。代价是要自己处理脚本分发、Redis 故障降级、监控告警。前面的 Lua 脚本可以当起手模板。
用现成库——
- Node.js:
express-rate-limit+rate-limit-redis(简单场景)、@upstash/ratelimit(Serverless 友好,内置多种算法) - Go:
golang.org/x/time/rate(单机令牌桶)、github.com/go-redis/redis_rate(分布式) - Java:
Bucket4j(令牌桶,支持 Redis)、Resilience4j RateLimiter
我的建议:起步用库,跑顺后再下沉到自研 Lua。很多团队反着来,上来就造轮子,踩一坑并发计数不准,踩二坑 Redis 抖动时全站拒绝,最后还是默默换回库。先用库把限流跑起来,等业务复杂到库撑不住(比如要和计费系统原子联动),再下沉。
小结
- 限流目的不止防 DDoS——保护服务、公平、防刷、计费,四个目标都要覆盖。
- 算法选型看场景——API 入口用令牌桶(允许突发),下游保护用漏桶(强制平滑),关键计费用滑动窗口(精确),别让固定窗口的边界突刺半夜叫醒你。
- 生产级必须 Redis + Lua——原子操作是底线,否则并发下计数偏到离谱。
- 响应要规范——429 + Retry-After + X-RateLimit-*,让客户端能自适应,而不是盲撞。
- 多层限流——全局/用户/IP 各管一摊,算法可以不同,单层永远不够。
金句:限流不是拒绝服务,是契约——明确告诉客户端"你能用多少、什么时候能再来",双方都按契约行事,系统才稳。
Related