Develop

API 限流实战:四种限流算法与 Redis + Nginx + Sentinel + Node.js 四种落地方案的完整指南

✎ -- 字 🕐 -- 分钟
字号

一、为什么需要限流:场景与防护目标

去年双十一零点,我们的电商网关被打挂了——一个爬虫在 5 秒内从 1 个 IP 发出 12 万次请求,直接把认证服务拖到不可用。事后复盘,问题不是出在认证服务的性能,而是它在最前面没有任何"水龙头"挡住这些洪水。

限流(Rate Limiting)解决的就是这类问题:在资源(CPU、连接数、第三方 API 配额)被耗尽之前,主动丢弃超额请求,保证核心业务可用。它的典型场景包括:

  • 防爬虫/防刷:登录、注册、短信验证码、抽奖等接口的恶意调用
  • 防雪崩:当下游服务出现慢响应时,限流避免上游请求堆积把整个链路压垮
  • 业务削峰:秒杀、抢购、抢券场景,把瞬时峰值削平成平台可承受的曲线
  • 保护第三方:调用微信支付、Stripe、短信网关等有 QPS 配额的接口,避免被封号
  • 成本控制:调用 OpenAI、Stable Diffusion 等按量计费的 AI 服务,限流能直接省银子

一个完整的限流设计要回答四个问题:在哪一层做、用什么算法、按什么维度限、超额了怎么处理。下面逐个拆开讲。

二、四种经典限流算法详解

限流算法是地基,方案选错,后面再优化也是事倍功半。下面四种算法从简单到精准依次展开。

2.1 固定窗口(Fixed Window)

最朴素的实现:把时间切成等长的窗口(比如每分钟一个),每个窗口维护一个计数器。计数器超过阈值就拒绝。代码大概长这样:

// Node.js 单机版固定窗口
const buckets = new Map();

function fixedWindow(key, limit, windowMs) {
  const now = Date.now();
  const windowStart = Math.floor(now / windowMs) * windowMs;
  const bucketKey = `${key}:${windowStart}`;

  const count = (buckets.get(bucketKey) || 0) + 1;
  buckets.set(bucketKey, count);

  // 清理过期
  if (Math.random() < 0.01) {
    for (const k of buckets.keys()) {
      if (parseInt(k.split(':')[1]) < now - windowMs) buckets.delete(k);
    }
  }
  return count <= limit;
}

// 使用:每个 IP 每分钟 100 次
app.use((req, res, next) => {
  if (!fixedWindow(req.ip, 100, 60_000)) {
    return res.status(429).json({ error: 'Too Many Requests' });
  }
  next();
});

它的致命问题是窗口边界的 2 倍突刺:假设阈值是 100/分钟,攻击者可以在 0:59 发送 100 个请求,再在 1:01 发送 100 个请求,两秒内打出 200 个,绕过限制。Redis 的 INCR + EXPIRE 是分布式版本最常见的实现。

2.2 滑动窗口(Sliding Window)

为了解决突刺问题,把"窗口"从一个固定时段变成"最近 N 秒"。常见做法是维护一个有序队列,记录每次请求的时间戳,超过窗口最旧的就出队:

import time
from collections import deque

class SlidingWindow:
    def __init__(self, limit: int, window_seconds: int):
        self.limit = limit
        self.window = window_seconds
        self.timestamps = deque()

    def allow(self) -> bool:
        now = time.time()
        # 弹出窗口外的旧时间戳
        while self.timestamps and now - self.timestamps[0] > self.window:
            self.timestamps.popleft()
        if len(self.timestamps) >= self.limit:
            return False
        self.timestamps.append(now)
        return True

精度比固定窗口好很多,但代价是 O(N) 内存(N 是窗口内的请求数),且不能完全消除突刺——只是把突刺窗口缩小到 N 倍率。在 Redis 分布式场景下,可以用 ZADD + ZREMRANGEBYSCORE + ZCARD 的三步操作,Lua 脚本原子化即可。

2.3 令牌桶(Token Bucket)——最常用 ★

想象一个桶,令牌以固定速率往里灌(refill rate),桶满就溢出。请求来了先取令牌,取到就通过、取不到就拒绝。桶有最大容量 burst,允许短时间内的流量暴增。

这是绝大多数生产系统的默认选择,Guava 的 RateLimiter、Nginx 的 limit_req、AWS API Gateway 都是令牌桶。它的两个核心参数:

  • refill_rate:平均速率(个/秒),决定长期流量
  • burst:桶容量,决定能扛多大突刺

伪代码实现非常简洁:

class TokenBucket {
  constructor(capacity, refillPerSec) {
    this.capacity = capacity;       // 桶大小(最大突刺)
    this.tokens = capacity;         // 初始满
    this.refillRate = refillPerSec;  // 每秒补充
    this.lastRefill = Date.now();
  }

  allow(cost = 1) {
    const now = Date.now();
    const elapsed = (now - this.lastRefill) / 1000;
    this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate);
    this.lastRefill = now;

    if (this.tokens >= cost) {
      this.tokens -= cost;
      return true;
    }
    return false;
  }
}

// 每秒 10 个,桶 20 个 = 允许 2 秒的满负载
const limiter = new TokenBucket(20, 10);

2.4 漏桶(Leaky Bucket)

和令牌桶"反着来":请求先进桶(队列),然后以固定速率流出处理。桶满就拒绝。

完全消除了突刺,输出永远是匀速的——这在需要平滑下游保护(比如数据库连接池、消息生产速率)时很有用。但代价是无法应对合理突刺,双十一秒杀前用户集中点击会被它无情丢弃。Guava 没有原生的漏桶,resilience4jRateLimiter 同时支持令牌桶和漏桶两种模式。

2.5 四种算法核心对比

算法精度抗突刺内存适用场景
固定窗口差(2x 突刺)O(1)粗粒度防护、近似统计
滑动窗口较好O(N)API 配额、统计面板
令牌桶极好(可配置 burst)O(1)绝大多数场景
漏桶不允许突刺O(1)+队列平滑输出、保护下游

四种算法的关系图如下,可以一眼看出演进:

四种限流算法对比图

三、Redis + Lua 令牌桶:分布式限流方案

单进程限流够用的情况很少——Node.js 8 个实例 + Java 20 个 Pod 一起来,进程内计数器立刻就废了。必须把计数放到底层共享存储,Redis 是最常见的选择。

Redis 单条命令是原子的,但"取令牌、判断、扣减"三步必须原子执行,否则并发下会超发。Lua 脚本是唯一干净的方案

-- token_bucket.lua
-- KEYS[1]: 桶 key
-- ARGV[1]: 容量
-- ARGV[2]: 每秒补充速率
-- ARGV[3]: 当前时间(毫秒)
-- ARGV[4]: 申请令牌数
-- ARGV[5]: TTL(秒)

local key       = KEYS[1]
local capacity  = tonumber(ARGV[1])
local rate      = tonumber(ARGV[2])   -- tokens per second
local now       = tonumber(ARGV[3])   -- current ms
local cost      = tonumber(ARGV[4])
local ttl       = tonumber(ARGV[5])

local data    = redis.call('HMGET', key, 'tokens', 'ts')
local tokens  = tonumber(data[1]) or capacity
local last_ts = tonumber(data[2]) or now

-- 补充令牌
local elapsed = math.max(0, now - last_ts) / 1000.0
tokens = math.min(capacity, tokens + elapsed * rate)

local allowed = 0
if tokens >= cost then
  tokens = tokens - cost
  allowed = 1
end

redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', key, ttl)

-- 返回:allowed, remaining, retry_after_ms
local retry_after = (cost - tokens) / rate * 1000
return { allowed, tokens, math.ceil(retry_after) }

Node.js 调用层(使用 ioredis):

const Redis = require('ioredis');
const fs = require('fs');
const redis = new Redis({ host: '127.0.0.1', port: 6379 });

const SCRIPT = fs.readFileSync('./token_bucket.lua', 'utf8');
const SHA = await redis.script('LOAD', SCRIPT);

async function takeToken(userId, capacity = 20, rate = 5) {
  const key = `rl:tb:${userId}`;
  const [allowed, remaining, retryAfter] = await redis.evalsha(
    SHA, 1, key, capacity, rate, Date.now(), 1, 60
  );
  return { allowed: allowed === 1, remaining, retryAfter };
}

// Express 中间件
app.use(async (req, res, next) => {
  const r = await takeToken(req.user?.id || req.ip, 20, 5);
  res.setHeader('X-RateLimit-Remaining', r.remaining);
  if (!r.allowed) {
    res.setHeader('Retry-After', Math.ceil(r.retryAfter / 1000));
    return res.status(429).json({ error: 'rate_limited' });
  }
  next();
});

几个生产踩坑:

  • SHA 缓存:重启 Redis 后 EVALSHA 会失败,要捕获 NOSCRIPT 错误回退到 EVAL,或订阅 __keyevent@0__:expired 重载
  • 时钟漂移:Lua 里用 Redis 服务器时间(redis.call('TIME'))比用应用服务器时间更安全,多实例不会出现时间差
  • 热 key:大 V 用户被单独限流时,一个 key 会被高频打。可以加一层进程内 LRU cache,把 allowed 结果缓存 100ms,分散压力

四、Node.js 应用层:express-rate-limit 实战

如果你的限流需求不复杂(单机、不要求分布式一致),直接用现成库最快。express-rate-limit 是事实标准:

npm i express-rate-limit rate-limit-redis ioredis
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);

// 全局限流:每 IP 每分钟 100 次
export const globalLimiter = rateLimit({
  windowMs: 60_000,
  limit: 100,
  standardHeaders: 'draft-7',     // 返回 RateLimit-* 头
  legacyHeaders: false,
  store: new RedisStore({
    sendCommand: (...args) => redis.call(...args),
    prefix: 'rl:',
  }),
  keyGenerator: (req) => req.user?.id || req.ip,
  handler: (req, res) => {
    res.status(429).json({
      error: 'rate_limited',
      message: '请求过于频繁,请稍后再试',
      retryAfter: res.getHeader('Retry-After'),
    });
  },
});

// 重点接口单独限流
export const loginLimiter = rateLimit({
  windowMs: 15 * 60_000,
  limit: 5,             // 15 分钟最多 5 次登录
  skipSuccessfulRequests: true,  // 成功登录不计数
  store: new RedisStore({ sendCommand: (...args) => redis.call(...args), prefix: 'rl:login:' }),
});

app.use('/api/', globalLimiter);
app.post('/login', loginLimiter, loginHandler);

进阶技巧:

  • 用户优先于 IP:登录后用 req.user.id,未登录用 req.ip,避免同一公司 NAT 出口被误伤
  • 差异化阈值:VIP 用户的 limit 可以从 Redis 动态读取,limit: async (req) => getUserQuota(req.user.id)
  • 白名单:监控/对账脚本用 skip: (req) => req.ip === '10.0.0.5' 跳过

五、Java 微服务:Sentinel 完整实战

阿里开源的 Sentinel 是 Java 生态里最成熟的限流/熔断框架,比 Resilience4j 多了可视化 Dashboard 和规则动态推送两大杀器。

Maven 依赖:

<dependency>
  <groupId>com.alibaba.csp</groupId>
  <artifactId>sentinel-core</artifactId>
  <version>1.8.6</version>
</dependency>
<dependency>
  <groupId>com.alibaba.csp</groupId>
  <artifactId;sentinel-transport-simple-http</artifactId>
  <version>1.8.6</version>
</dependency>

代码层:

// 1. 初始化规则(应用启动时执行一次)
List<FlowRule> rules = new ArrayList<>();
FlowRule rule = new FlowRule("orderService")
    .setGrade(RuleConstant.FLOW_GRADE_QPS)        // QPS 维度
    .setCount(50)                                  // 阈值 50 QPS
    .setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_WARM_UP)  // 冷启动
    .setWarmUpPeriodSec(10);                       // 10 秒预热
rules.add(rule);
FlowRuleManager.loadRules(rules);

// 2. 包裹业务代码
try (Entry entry = SphU.entry("orderService")) {
    // 业务逻辑
    return orderService.createOrder(req);
} catch (BlockException e) {
    // 被限流了
    return ResponseEntity.status(429).body("系统繁忙");
}

// 3. 注解式更优雅
@SentinelResource(value = "payCallback",
                  blockHandler = "handleBlock",
                  fallback = "handleFallback")
@PostMapping("/pay/callback")
public Result handlePayCallback(@RequestBody PayNotify req) {
    return payService.handle(req);
}

public Result handleBlock(@RequestBody PayNotify req, BlockException e) {
    log.warn("支付回调被限流: {}", req.getOrderId());
    return Result.failure("RATE_LIMITED");
}

Sentinel 的流控效果有四种,对应不同业务诉求:

效果行为适用场景
快速失败(默认)直接抛 BlockException普通 API
Warm Up(冷启动)阈值从小逐渐放大系统刚启动、缓存冷
排队等待匀速通过、超时拒绝消息消费、批量任务
预热+排队组合兼有二者的平滑曲线高 QPS 大流量

生产部署建议跑一个 Sentinel Dashboard,规则从 Dashboard 推送,不用重启应用就能调整阈值。这是它相比手写 AOP 限流最大的优势。

六、Nginx 网关层:limit_req 实战

网关层限流是性价比最高的位置——所有请求都从这里过,一次配置顶住所有上游服务。

# /etc/nginx/nginx.conf

# 1. 定义限流区(10M 大约能存 16 万 IP,需根据 QPS 调整)
limit_req_zone $binary_remote_addr zone=per_ip:10m rate=10r/s;
limit_req_zone $http_x_forwarded_for zone=per_user:10m rate=20r/s;

# 2. 定义日志格式
log_format limit_log '$remote_addr [$time_iso8601] '
                    '$status $limit_req_status $http_referer';

server {
    listen 80;
    server_name api.example.com;
    access_log /var/log/nginx/limit.log limit_log;

    # 3. 全局限流:每 IP 每秒 10 个,桶 20(允许 2 秒突刺)
    location /api/ {
        limit_req zone=per_ip burst=20 nodelay;
        limit_req_status 429;
        proxy_pass http://backend;
    }

    # 4. 登录接口单独更严:每秒 1 个,桶 5
    location /api/login {
        limit_req zone=per_ip burst=5 nodelay;
        proxy_pass http://backend;
    }

    # 5. 白名单不限流
    location /api/internal/ {
        limit_req zone=per_ip burst=100 nodelay;
        # 仅允许内网
        allow 10.0.0.0/8;
        deny all;
    }
}

三个关键参数必须理解清楚:

  • rate=10r/s:平均速率是每秒 10 个
  • burst=20:桶容量,瞬时可超 20 个
  • nodelay:桶里有令牌就立刻放行(不延迟),删掉这个就是排队模式(匀速)

压测验证配置:

# 用 ab 压测 1000 个请求 50 并发
ab -n 1000 -c 50 https://api.example.com/api/users

# 预期看到 429 状态码分布
# Non-2xx responses:    250  (25% 被限流)

七、四种方案压测对比与选型

用 wrk 压测同一接口(返回 hello world 即可)做横向对比,环境是 4 核 8G 单机,Redis 6.0:

方案延迟 P99CPU 占用分布式一致复杂度
Nginx limit_req2ms5%不依赖配置即生效
Node.js express-rate-limit(内存)3ms15%多实例失效10 行代码
Redis Lua 令牌桶8ms20%✅ 强一致30 行 Lua + SDK
Sentinel(应用内)1ms10%需集群版Java 专属

选型决策建议:

  • 单体小项目 → 进程内 RateLimiter(Guava/令牌桶实现)
  • Node.js 集群 → express-rate-limit + Redis Store
  • Java 微服务 → Sentinel(自带 Dashboard 是杀手锏)
  • 网关层全局防护 → Nginx limit_req 永远是第一道
  • 需要精确扣减第三方配额 → Redis Lua 自写

八、常见陷阱与最佳实践

8.1 八大常见陷阱

陷阱症状解决方案
限流键选错同一公司 NAT 出口共用 IP,正常用户被误伤登录后改用 user_id
没有 429 状态码客户端无法识别重试必须返回 429 + Retry-After 头
客户端不重试偶发抖动直接失败客户端做指数退避(1s, 2s, 4s)
没有降级页前端白屏限流触发时返回缓存的旧数据
冷启动被打重启后瞬间被洪流打挂用 Warm Up 模式慢慢放开
单一维度被绕过攻击者换 IP/换账号IP + user_id + device 三维组合
不监控命中率限流形同虚设必接 Prometheus alert(allowed_ratio<0.7)
规则未走灰度新阈值直接全量先 10% 流量跑 5 分钟

8.2 上线前 Checklist

  • □ 限流键是否包含用户身份(不止 IP)
  • □ 是否返回 429 + 标准 RateLimit-* 头(RFC 6585 / draft-7)
  • □ 客户端是否有指数退避重试
  • □ 限流降级是否返回旧数据/友好页面
  • □ 是否接入监控:QPS、限流命中率、P99 延迟
  • □ 告警规则:限流命中率突增、Redis 内存水位
  • □ 规则变更是否走配置中心,灰度发布
  • □ 压测报告:2x 峰值流量下系统稳定

8.3 监控指标最小集

无论选哪种方案,这五个指标必接:

# Prometheus 告警规则示例
groups:
  - name: rate_limit_alerts
    rules:
      - alert: RateLimitHitHigh
        expr: sum(rate(rate_limit_blocked_total[5m])) > 100
        for: 2m
        labels: { severity: warning }
        annotations:
          summary: "限流触发率过高: {{ $value }} req/s"

      - alert: RedisMemoryHigh
        expr: redis_memory_used_bytes / redis_memory_max_bytes > 0.8
        for: 5m
        labels: { severity: warning }

九、真实案例:限流救了我两次事故

讲两个亲身经历的故事,比纯理论更能帮你理解为什么"宁可多配一层"。

案例一:短信网关被打爆。某次营销活动,短信验证码接口被一个羊毛党工具扫了 2 小时,单接口累计发了 14 万条短信,直接被运营商黄牌警告。事后第一件事就是在网关层加了 limit_req zone=per_ip burst=3 nodelay——每 IP 只能瞬时打 3 个请求,桶一空就必须等。之后 3 个月,类似扫描请求全部被挡在网关之外,短信成本直降 60%。

案例二:缓存击穿引发雪崩。Redis 大 key 失效那一秒,几千个并发请求同时打到数据库,连接池瞬间耗尽,所有依赖该缓存的接口超时。我们上了 Sentinel + 排队等待模式,把瞬时打 DB 的请求匀速到 100 QPS,P99 延迟从 8 秒降到 200 毫秒。事故复盘时 CTO 说了句让我印象很深的话:"限流不是性能优化,是 SLA 的底线。"

这两次之后我们形成了一个固定动作:新接口上线 Checklist 里必带"限流策略"一栏,不写不允许 merge。看似形式主义,几次之后真的救了几次场。

写在最后

限流是个"平时感觉不到,出事才知道重要"的东西。生产环境里至少要做两层:网关层兜底(Nginx)+ 应用层精细化(Redis Lua / Sentinel),单点都不够稳。

算法选型记住一句话——不确定时选令牌桶,它既允许合理的业务突刺,又能严格控制平均速率,是工业界最通用的答案。

下一篇会聊限流的"高级形态":动态自适应限流、热点参数限流,以及在 K8s + Service Mesh 环境下的全局限流方案,敬请期待。