#DevOps

API 限流策略深入:算法与实现

限流不只"每分钟 N 次"——用什么算法(固定窗/滑动窗/令牌桶/漏桶)、按什么维度(IP/用户/API key)、存哪(内存/Redis)、超限返回什么(429/Retry-After),都有讲究。本文讲清四种限流算法的对比与实现,给一份生产级限流方案。

本文要点

  • 主题聚焦:DevOps
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年3月26日·更新于 2026年7月23日

很多团队对限流的理解,停留在"加个中间件,每分钟 100 次"。等出事了才发现:边界突刺把数据库打爆了、NAT 后面整层楼的用户被误杀、并发下计数根本不准、客户端不知道什么时候能重试一遍遍撞墙。

限流不是"加个 N"这么简单。它要回答四个问题:用什么算法、按什么维度、存哪、超限返回什么。每个问题都有坑。这篇文章把四个问题讲透,最后给一份能直接抄的生产级方案。

一、限流的目的:不只是防 DDoS

限流常被等同于"防黑客"。错。它的核心目标有四个,重要性不分先后:

  1. 保护服务——下游数据库连接池、第三方 API 配额都有上限。突发流量打过来,级联故障、雪崩、整站 503,都是这么来的。
  2. 公平性——单个用户/租户不能独占资源,否则其他用户被饿死。多租户 SaaS 这条是底线。
  3. 防刷——爬虫、薅羊毛、撞库暴力破解,都得靠限流挡。
  4. 计费——按 API 调用次数计费的 SaaS,限流是计费的前置:你能限,才能数,才能收钱。

所以限流是 API 的"免疫系统",默认就要有,而不是出事后的补丁。

二、四种算法:各自的脾气

1. 固定窗口(Fixed Window)

把时间切成固定窗口(比如每分钟一个),窗口内计数,超过阈值拒绝,窗口结束归零。

text
00:00–00:59  计数到 N 就拒绝
01:00        清零,重新计数

致命缺陷:边界突刺。用户在 00:59 发了 100 次(满额),01:01 又发 100 次——2 秒内实际通过 200 次,窗口边界成了流量尖峰。下游如果扛不住这 2 秒的 2 倍峰值,固定窗口救不了你。

Redis 实现简单到离谱:

redis
INCR    ratelimit:user:123:202607171530
EXPIRE  ratelimit:user:123:202607171530 60

适合后台任务、精度要求不高的简单计数。生产 API 别只用它。

2. 滑动窗口(Sliding Window)

不再"按自然分钟切",而是"从现在往前看 60 秒"。用 Redis 的 sorted set 存每个请求的时间戳,每次进来先清理过期成员,再统计当前窗口内数量。

lua
-- 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 调用天然有突发性(页面加载时前端并发十几个请求),令牌桶能容纳这种正常突发,而固定/滑动窗口会误杀。

lua
-- 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 头:

text
HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/json

{"error": "rate_limit_exceeded", "retry_after": 30}

X-RateLimit-* 头(Twitter 早期推广,事实标准):

text
X-RateLimit-Limit:     100        窗口上限
X-RateLimit-Remaining: 73         剩余
X-RateLimit-Reset:     1698902400 重置时间(Unix 秒)

给客户端这些信息,它们能自适应退避、在 UI 上显示"还剩多少次"、避免反复撞墙。这是 API 礼仪,也是降低客服工单量的便宜买卖。

六、多层限流:一道防线不够

单层限流顾此失彼。生产架构通常是洋葱式的多层:

text
请求 → [全局 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 抖动时全站拒绝,最后还是默默换回库。先用库把限流跑起来,等业务复杂到库撑不住(比如要和计费系统原子联动),再下沉。

小结

  1. 限流目的不止防 DDoS——保护服务、公平、防刷、计费,四个目标都要覆盖。
  2. 算法选型看场景——API 入口用令牌桶(允许突发),下游保护用漏桶(强制平滑),关键计费用滑动窗口(精确),别让固定窗口的边界突刺半夜叫醒你。
  3. 生产级必须 Redis + Lua——原子操作是底线,否则并发下计数偏到离谱。
  4. 响应要规范——429 + Retry-After + X-RateLimit-*,让客户端能自适应,而不是盲撞。
  5. 多层限流——全局/用户/IP 各管一摊,算法可以不同,单层永远不够。

金句:限流不是拒绝服务,是契约——明确告诉客户端"你能用多少、什么时候能再来",双方都按契约行事,系统才稳。

Related

相关推荐