Develop

API 限流算法与实战:从令牌桶到分布式限流的完整指南

✎ -- 字 🕐 -- 分钟
字号

API 限流算法与实战:从令牌桶到分布式限流的完整指南

凌晨三点,告警群炸了——某推广活动带来的瞬时流量把登录接口打挂了,DBA 疯狂在群里@你,CTO 已经打电话过来。这种事故的根因往往就一句话:接口没限流。今天这篇文章,咱们就把限流这件事从原理到生产一次性讲透。

限流不是新话题,但真正能在生产环境稳定落地的方案并不多。文章会先讲清四大算法的本质区别,再分别给出单机(Java Guava、Node.js bottleneck)、分布式(Redis + Lua)、网关层(Nginx、Sentinel、Kong)的实战方案,最后用两个真实场景的案例收尾。看完你就能搭建一套生产可用的多级限流体系。

一、为什么需要限流

限流解决的是"流量 > 容量"时的优雅降级问题。它不是消灭流量,而是让系统在过载时仍能稳定服务核心用户。常见的三大场景:

  • 防刷防爆破:登录、短信验证码、注册等接口被恶意脚本狂打,限流能把攻击流量挡在 90% 以上;
  • 资源保护:数据库连接池、CPU 密集型接口、下游第三方 API 都对瞬时并发敏感,需要在网关层先削峰;
  • 成本控制:按调用次数计费的第三方 API、CDN 流量包,限流能直接省下一大笔账单。

很多人把限流和熔断、降级混为一谈,实际上三者关系是这样的:

机制触发条件作用点典型实现
限流(Rate Limit)调用速率超阈值入口处Nginx limit_req、Sentinel、Redis + Lua
熔断(Circuit Breaker)下游错误率超阈值调用方Hystrix、Resilience4j、Istio
降级(Fallback)熔断触发或资源紧张调用方返回缓存、默认值、错误页

一句话总结:限流是入口的"门卫",熔断是调用链的"保险丝",降级是熔断后的"兜底服务"。三者协同才能形成完整的稳定性防线。

二、四大限流算法原理解析

算法决定限流的"性格"——是严格、宽松、还是带突发容忍。理解差异才能选对场景。

2.1 固定窗口(Fixed Window)

最简单粗暴:把时间切成 1 秒或 1 分钟的固定窗口,每个窗口内计数 +1,超过阈值就拒绝。Redis 的 INCR + EXPIRE 是典型实现。

致命缺陷:临界突刺。假设限流 100 req/min,前 1 分钟末尾 1 秒涌入 100 个,紧接着下 1 分钟开头 1 秒又涌入 100 个,2 秒内实际通过 200 个,远超阈值。

2.2 滑动窗口(Sliding Window)

为了解决临界突刺,把固定窗口再切细成 N 个小格(比如 60 个 1 秒格),按时间滚动计算最近窗口内的总和。精度和内存占用成正比。

它又分两种:滑动日志(精确记录每次请求时间戳,内存占用高)和滑动计数(每个小格只记计数,按权重合并,精度稍差但省内存)。生产中滑动计数用得最多。

2.3 令牌桶(Token Bucket)

系统以固定速率往桶里放令牌(比如 1 个/秒),桶容量有限。请求来时尝试拿 1 个令牌,有就放行,没有就拒绝。

最大优点:允许突发。平时攒令牌,突发来时一次性放行一波,兼顾"日常限速"和"突发放行"两个需求。Cloudflare、Stripe API 的限流都基于它。

2.4 漏桶(Leaky Bucket)

请求像水一样进桶,桶底以固定速率漏水(处理)。桶满则溢出(拒绝)。

它和令牌桶的区别是:令牌桶"攒"令牌来允许突发,漏桶"匀速"漏水来强制平滑。流量整形(Traffic Shaping)场景下漏桶更合适,但大多数 Web 限流场景都选令牌桶。

2.5 四大算法对比

算法精度允许突发内存占用实现复杂度典型场景
固定窗口低(临界突刺)极低极低统计型 QPS 监控
滑动日志严格审计场景
滑动计数中高通用 API 限流
令牌桶需要突发的 API
漏桶流量整形

三、单机限流实战

如果你的服务是单实例或限流只在进程内有效,用内存级实现就够了,零依赖性能拉满。

3.1 Java Guava RateLimiter

Guava 的 RateLimiter 就是经典的令牌桶实现,API 极简:

// 每秒 5 个许可(即 5 QPS)
RateLimiter limiter = RateLimiter.create(5.0);

// 平滑限流:sleep 到拿到令牌为止
if (!limiter.tryAcquire(100, TimeUnit.MILLISECONDS)) {
    return ResponseEntity.status(429).body("Too Many Requests");
}
return doBusinessLogic();

// 突发场景:预创建桶,允许短时爆发
RateLimiter burstLimiter = RateLimiter.create(5.0, 5, TimeUnit.SECONDS);

注意点:create(permitsPerSecond) 默认使用平滑突发策略(Guava 28+),无预热时间。如果需要"预热桶"(Warming Up),升级到 Resilience4j 或 Sentinel。

3.2 Node.js bottleneck / p-limit

Node.js 生态里 bottleneck 是最专业的限流库,p-limit 更轻量适合并发控制:

const Bottleneck = require("bottleneck");

// 全局限流:每秒 10 个
const limiter = new Bottleneck({
  maxConcurrent: 5,         // 最大并发
  minTime: 100,              // 每次至少间隔 100ms
  reservoir: 20,             // 桶容量
  reservoirRefreshAmount: 20,
  reservoirRefreshInterval: 1000 * 60,
});

limiter.schedule({ id: userId }, async () => {
  return await callExpensiveAPI(userId);
});

// 队列满则立即拒绝
limiter.on("failed", (err) => log.error(err));

单机实现的致命问题无法跨实例共享状态。3 个实例每个限流 100 QPS,集群实际能扛 300 QPS,黑客只要分布到不同实例就能绕过。生产中必须上分布式。

四、分布式限流:Redis + Lua 原子操作

分布式限流的核心挑战是原子性——计数、过期、判断三步必须在一次操作里完成。Redis + Lua 脚本是公认的最佳方案,因为 Lua 脚本在 Redis 单线程模型下是原子执行的。

4.1 滑动窗口 Lua 实现

用 Redis 的 有序集合(ZSET) 存每次请求的时间戳,count 计算当前窗口内成员数:

-- KEYS[1] = 限流 key
-- ARGV[1] = 窗口大小(毫秒)
-- ARGV[2] = 阈值
-- ARGV[3] = 当前时间戳(毫秒)
-- ARGV[4] = 唯一请求 ID(用于 ZADD 成员)

local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local member = ARGV[4]

-- 1. 清理窗口外的旧记录
redis.call("ZREMRANGEBYSCORE", key, 0, now - window)

-- 2. 统计当前窗口内请求数
local count = redis.call("ZCARD", key)

if count < limit then
    -- 3. 记录本次请求
    redis.call("ZADD", key, now, member)
    redis.call("PEXPIRE", key, window)
    return {1, limit - count - 1, 0}  -- 通过, 剩余配额, 0ms 等待
else
    -- 计算最早一条记录的过期时间,用于精确 Retry-After
    local oldest = redis.call("ZRANGE", key, 0, 0, "WITHSCORES")
    local retryAfter = window
    if #oldest >= 2 then
        retryAfter = (tonumber(oldest[2]) + window) - now
    end
    return {0, 0, retryAfter}  -- 拒绝, 剩余配额 0, 等待毫秒
end

返回值是个数组,能告诉调用方:是否放行、剩余配额、精确的 Retry-After 时间(毫秒)。这样客户端可以遵守 HTTP 429 规范礼貌退避。

4.2 令牌桶 Lua 实现

令牌桶用 Redis Hash 存桶状态:

-- KEYS[1] = 限流 key
-- ARGV[1] = 桶容量
-- ARGV[2] = 填充速率(每秒)
-- ARGV[3] = 当前时间戳(毫秒)
-- ARGV[4] = 申请的令牌数

local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])         -- tokens per second
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

local bucket = redis.call("HMGET", key, "tokens", "lastRefill")
local tokens = tonumber(bucket[1]) or capacity
local lastRefill = tonumber(bucket[2]) or now

-- 1. 补充令牌
local elapsed = (now - lastRefill) / 1000.0
local refilled = math.floor(elapsed * rate)
tokens = math.min(capacity, tokens + refilled)
local effectiveLastRefill = refilled > 0 and now or lastRefill

-- 2. 尝试拿令牌
if tokens >= requested then
    tokens = tokens - requested
    redis.call("HMSET", key, "tokens", tokens, "lastRefill", effectiveLastRefill)
    redis.call("PEXPIRE", key, 60000)
    return {1, tokens, 0}
else
    redis.call("HMSET", key, "tokens", tokens, "lastRefill", effectiveLastRefill)
    redis.call("PEXPIRE", key, 60000)
    local needed = requested - tokens
    local waitMs = math.ceil((needed / rate) * 1000)
    return {0, tokens, waitMs}
end

这里有几个细节值得说道:

  • lastRefill 更新时机:只有真正补充了令牌才更新到 now,避免后续请求把"过去的时间"算进未来;
  • PEXPIRE 设置:60 秒过期足够应对所有合理场景,又避免 key 永久残留;
  • 浮点精度math.floor 截断,宁可少给令牌也不多给,保证严格性。

4.3 Node.js 调用封装

把 Lua 脚本封装成可复用的中间件:

const Redis = require("ioredis");
const { v4: uuidv4 } = require("uuid");

const redis = new Redis({ host: "127.0.0.1", port: 6379 });

const slidingWindowLua = `
local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local member = ARGV[4]
redis.call("ZREMRANGEBYSCORE", key, 0, now - window)
local count = redis.call("ZCARD", key)
if count < limit then
    redis.call("ZADD", key, now, member)
    redis.call("PEXPIRE", key, window)
    return {1, limit - count - 1, 0}
else
    local oldest = redis.call("ZRANGE", key, 0, 0, "WITHSCORES")
    local retryAfter = window
    if #oldest >= 2 then
        retryAfter = (tonumber(oldest[2]) + window) - now
    end
    return {0, 0, retryAfter}
end
`;

redis.defineCommand("rateLimitSliding", {
  numberOfKeys: 1,
  lua: slidingWindowLua,
});

function rateLimit({ key, windowMs, limit }) {
  return async (req, res, next) => {
    const userKey = `rl:${key}:${req.userId || req.ip}`;
    try {
      const [allowed, remaining, retryAfter] = await redis.rateLimitSliding(
        userKey, windowMs, limit, Date.now(), uuidv4()
      );
      res.set("X-RateLimit-Limit", limit);
      res.set("X-RateLimit-Remaining", Math.max(0, remaining));
      if (!allowed) {
        res.set("Retry-After", Math.ceil(retryAfter / 1000));
        return res.status(429).json({ error: "Too Many Requests" });
      }
      next();
    } catch (err) {
      log.error("rate limit error", err);
      next();  // 限流失败时"放行+告警"优于直接拒绝
    }
  };
}

app.use("/api/", rateLimit({ key: "global", windowMs: 60_000, limit: 100 }));

关键的"限流失败时放行"是线上重要容灾策略——如果 Redis 抖动,限流不可用导致全站拒绝是更大的事故。要"fail open"配合告警,让运维第一时间介入。

五、网关层限流

当多个服务都需要限流时,把限流逻辑下沉到网关是最优雅的方案。

5.1 Nginx limit_req

Nginx 自带 limit_req 模块就是漏桶实现,零依赖性能最强:

# 定义限流区域:10MB 内存,每秒 10 个请求
limit_req_zone $binary_remote_addr zone=ip_limit:10m rate=10r/s;
limit_req_zone $http_x_api_key zone=key_limit:10m rate=100r/s;

server {
    listen 80;
    server_name api.example.com;

    location /api/login {
        # 允许突发 20 个,无延迟队列
        limit_req zone=ip_limit burst=20 nodelay;
        proxy_pass http://backend;
    }

    location /api/v1/ {
        # 排队模式:超出 burst 排队等待
        limit_req zone=key_limit burst=50;
        limit_req_status 429;
        proxy_pass http://backend;
    }
}

burst=20 nodelay 表示允许短时 20 个突发但立即处理;不带 nodelay 则排队等待。Nginx 的 limit_req 在 HTTP 层做限流,比 Lua 早,能把压力挡在 7 层之前。

5.2 Sentinel / Spring Cloud Gateway

阿里 Sentinel 是 Java 生态最流行的限流组件,支持按 QPS、并发线程数、调用关系等多种维度,配 Dashboard 实时调参:

# application.yml
spring:
  cloud:
    gateway:
      routes:
        - id: order_route
          uri: lb://order-service
          predicates:
            - Path=/api/order/**
          filters:
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 50    # 每秒 50
                redis-rate-limiter.burstCapacity: 100  # 桶容量
                key-resolver: "#{@userKeyResolver}"

Spring Cloud Gateway 底层也是 Redis + Lua,但封装了配置化的资源管理。

5.3 Kong

Kong 的 rate-limiting 插件支持 local、cluster、redis 三种策略,与服务注册、认证、监控天然集成:

# 为 service 启用限流,每分钟 100 次
curl -X POST http://kong:8001/services/my-service/plugins \
  -d "name=rate-limiting" \
  -d "config.minute=100" \
  -d "config.policy=redis" \
  -d "config.redis_host=redis"

5.4 网关层方案对比

网关语言生态算法配置化集群共享性能
Nginx limit_reqC / 通用漏桶配置需共享内存★★★★★
SentinelJava滑动/令牌/漏桶DashboardToken Server★★★★
Spring Cloud GatewayJava令牌桶YAMLRedis★★★★
KongLua / OpenResty固定窗口Admin APIRedis / Cluster★★★★

六、实战案例

案例 1:登录接口防爆破

场景:登录接口被撞库,每秒最高 5000 次尝试。需求:单 IP 每分钟最多 10 次尝试,单账号每分钟 5 次。

方案:网关层用 Nginx 按 IP 限流(10r/m,突发 5),应用层用 Redis + Lua 按账号二次限流(5r/m)。Nginx 把 99% 的攻击流量挡在门外,账号级限流兜底防分布式攻击。

关键细节:登录失败计数要按账号而非 IP,否则黑客用代理池可以绕开 IP 限制;限流响应要返回 Retry-After,让合规客户端礼貌退避;被限流的请求要写日志,作为后续风控的输入。

案例 2:开放 API 平台按用户配额

场景:API 平台按用户等级分发配额(免费 1000 次/天,付费 10 万次/天),需要全局精确计数。

方案:每天 0 点重置配额,用 Redis Hash 存每日用量。考虑到 free 用户量级大,用 Lua 脚本在 Redis 内做"读-判断-写" 避免并发竞态。付费用户单独走令牌桶路径,允许突发调用。

这里有个坑:INCR 不是原子的"读+判断",高并发下两个请求同时读到 999 都自增到 1000 后通过,超出 1 个。用 Lua 把"读、判断、+1"三步打包成原子操作才能解决。

七、选型决策

场景推荐方案理由
单实例内部限流Guava RateLimiter / bottleneck零依赖,纳秒级
多实例分布式Redis + Lua 令牌桶原子性、突发友好、运维成熟
网关层粗粒度Nginx limit_req7 层早期过滤,性能顶满
Java 微服务Sentinel + Dashboard规则动态推送,可视化
开放 API 平台Kong rate-limiting与网关功能深度集成
需要审计严格性滑动日志 + 持久化可回溯,不丢请求

多级限流是趋势:Nginx 挡大流量、Redis 分布式精确限流、单机库兜底关键接口,三层叠加既扛得住 DDoS 又能精确控制配额。

八、监控与告警

限流上线后必须监控这些指标,否则等于盲飞:

指标告警阈值含义
限流触发率> 5%真实流量超阈值,需要扩容或调参
Redis Lua 脚本延迟> 5msRedis 性能瓶颈或大 key
限流错误率> 0.1%Redis 不可用,需检查 fail open 策略
各 key 配额使用率> 80%识别热门用户/接口,提前扩容
HTTP 429 状态码比例> 10%上游或客户端行为异常

建议把 X-RateLimit-RemainingRetry-After 写到日志和 Prometheus,配合 Grafana 仪表盘可视化"剩余配额水位线"。当水位持续低于 20% 时,说明需要调高阈值或扩容。

九、常见陷阱

陷阱症状解决方案
限流 key 未隔离不同用户共享同一计数器key 拼接 userId / IP / 业务维度
忘记 fail openRedis 抖动全站 500catch 异常后放行 + 告警
固定窗口临界突刺边界 2 倍流量改用滑动窗口或令牌桶
令牌桶无容量上限长时间停服后所有请求瞬间通过设置合理 burstCapacity
时钟漂移分布式节点时间不一致导致误判统一用 Redis 时间,不用本地时钟
大 key 阻塞 RedisZSET 百万级成员增加窗口细分粒度或定期清理
客户端忽略 Retry-After重试风暴服务端主动 + 客户端重试退避
没区分读/写接口读接口被写接口拖垮按接口路径分别配置限流

十、总结

限流是稳定性体系的第一道防线,也是性价比最高的优化——几行代码就能挡掉 80% 的过载流量。核心要点回顾:

  • 选算法:日常限速用滑动计数,需要突发用令牌桶,流量整形用漏桶;
  • 多级组合:网关层粗粒度 + Redis 分布式精确 + 单机库兜底关键路径;
  • 原子性:分布式限流必须用 Lua 脚本,INCR + EXPIRE 不是原子操作;
  • 容灾:限流服务本身要 fail open,不能因为限流挂掉导致全站雪崩;
  • 可观测:必须监控触发率、延迟、配额使用率,配 Grafana 仪表盘;
  • 规范响应:返回 429 + Retry-After + X-RateLimit-Remaining,引导客户端礼貌退避。

凌晨三点的那通电话,归根结底不是流量太大,是没给系统装"门卫"。希望看完这篇,你的服务能睡个好觉。