API 限流算法完全指南:令牌桶、漏桶、滑动窗口与分布式限流实战

深入解析四大经典限流算法的原理与 TypeScript 实现,对比各算法性能与适用场景,并给出基于 Redis 的分布式限流方案和 Express/Hono 中间件实战代码,帮助开发者构建高可用 API 服务。

API 设计 2026-05-29 20 分钟

根据 Cloudflare 2025 年度报告,全球 DDoS 攻击同比增长 117%,其中针对 API 端点的应用层攻击占比首次超过 60%。一个没有限流保护的 API,就像一扇没有锁的门——不仅暴露在恶意攻击下,还可能被合法用户的突发流量冲垮。限流(Rate Limiting)是 API 网关的第一道防线,也是每一位后端开发者必须掌握的核心基础设施技能。本文将从算法原理出发,用 TypeScript 实现四种经典限流方案,并给出基于 Redis 的分布式生产级解决方案。

📌 **记住:**限流不是「可选功能」,而是 API 的生存必需品。每一个面向外部的 API 端点都应该有明确的流量控制策略。

📊 一、为什么 API 必须有限流?

1.1 真实案例:无限流的代价

2024 年,某知名 SaaS 平台因一个客户端 Bug 导致每秒发送超过 50 万次重复请求,数据库连接池在 30 秒内耗尽,整个平台宕机 4 小时,直接损失超过 200 万美元。事后复盘,根本原因就是缺少 API 层限流

无限流的 API 面临三重风险:

  • 资源耗尽:CPU、内存、数据库连接被打满
  • 级联故障:一个服务的过载波及整个微服务链路
  • 账单暴增:Serverless 场景下,一个失控脚本可能产生天价账单

1.2 限流的核心指标

在设计限流方案前,需要明确三个核心维度:

维度 说明 常见取值
请求速率(Rate) 单位时间允许的最大请求数 100 req/s, 1000 req/min
突发容量(Burst) 短时间内允许超过均值的请求数 通常为 Rate 的 1.5-3 倍
粒度(Scope) 限流的范围:全局、用户、IP、API Key 按 API Key 最常见

💡 **提示:**限流粒度的选择直接影响用户体验。全局限流简单但粗糙,按用户/API Key 限流更公平但实现复杂。生产环境推荐按 API Key + 端点的组合粒度进行限流。

🔐 二、四大限流算法原理与实现

2.1 固定窗口计数器(Fixed Window Counter)

最简单的限流方案:将时间划分为固定大小的窗口(如每分钟),每个窗口维护一个计数器,超过阈值就拒绝请求。

// 固定窗口计数器实现
class FixedWindowRateLimiter {
  private count = 0;
  private windowStart = Date.now();

  constructor(
    private readonly maxRequests: number,
    private readonly windowMs: number
  ) {}

  tryAcquire(): boolean {
    const now = Date.now();
    // 窗口过期,重置计数器
    if (now - this.windowStart >= this.windowMs) {
      this.count = 0;
      this.windowStart = now;
    }
    if (this.count < this.maxRequests) {
      this.count++;
      return true; // 允许请求
    }
    return false; // 拒绝请求
  }
}

// 使用示例:每分钟最多 100 次请求
const limiter = new FixedWindowRateLimiter(100, 60_000);
console.log(limiter.tryAcquire()); // true
console.log(limiter.tryAcquire()); // true (count: 2)

⚠️ 致命缺陷:窗口边界突发问题。 如果限制是每分钟 100 次,在第一分钟的最后 1 秒用完 100 次配额,第二分钟的第 1 秒又立刻获得 100 次配额——这意味着在 2 秒的窗口交界处可以处理 200 次请求,远超预期。这在生产环境中可能导致短暂的过载。

2.2 滑动窗口计数器(Sliding Window Counter)

滑动窗口是对固定窗口的改进:用前一个窗口的加权计数 + 当前窗口的计数来平滑边界问题。

// 滑动窗口计数器实现
class SlidingWindowRateLimiter {
  private prevCount = 0;
  private currCount = 0;
  private currWindowStart = Date.now();

  constructor(
    private readonly maxRequests: number,
    private readonly windowMs: number
  ) {}

  tryAcquire(): boolean {
    const now = Date.now();
    const elapsed = now - this.currWindowStart;

    if (elapsed >= this.windowMs) {
      // 当前窗口变为前一个窗口
      this.prevCount = this.currCount;
      this.currCount = 0;
      this.currWindowStart = now;
    }

    // 计算前一个窗口的加权贡献
    const positionInWindow = Math.min(elapsed / this.windowMs, 1);
    const weightedPrevCount = this.prevCount * (1 - positionInWindow);
    const effectiveCount = weightedPrevCount + this.currCount;

    if (effectiveCount < this.maxRequests) {
      this.currCount++;
      return true;
    }
    return false;
  }
}

// 使用示例:每分钟最多 100 次请求
const limiter = new SlidingWindowRateLimiter(100, 60_000);

滑动窗口解决了边界突发问题,计算复杂度为 O(1),空间复杂度也为 O(1),是生产环境中最常用的方案之一

2.3 令牌桶算法(Token Bucket)

令牌桶是最经典的限流算法,也是 AWS API Gateway 和 Stripe 等主流 API 平台的默认限流方案。核心思想:桶以固定速率往里放令牌,请求需要取到令牌才能通过,桶满时多余令牌丢弃。

// 令牌桶算法实现
class TokenBucketRateLimiter {
  private tokens: number;
  private lastRefillTime: number;

  constructor(
    private readonly capacity: number,       // 桶容量(最大令牌数)
    private readonly refillRate: number      // 每秒补充的令牌数
  ) {
    this.tokens = capacity;                  // 初始满桶
    this.lastRefillTime = Date.now();
  }

  tryAcquire(tokensNeeded: number = 1): boolean {
    this.refill();

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

  private refill(): void {
    const now = Date.now();
    const elapsed = (now - this.lastRefillTime) / 1000; // 转换为秒
    const tokensToAdd = elapsed * this.refillRate;
    this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd);
    this.lastRefillTime = now;
  }

  // 获取当前可用令牌数(用于返回 Retry-After 头)
  getAvailableTokens(): number {
    this.refill();
    return Math.floor(this.tokens);
  }
}

// 使用示例:桶容量 10,每秒补充 2 个令牌
const limiter = new TokenBucketRateLimiter(10, 2);

// 突发请求:连续取 10 个令牌
for (let i = 0; i < 12; i++) {
  const allowed = limiter.tryAcquire();
  console.log(`请求 ${i + 1}: ${allowed ? '✅ 允许' : '❌ 拒绝'}`);
}
// 前 10 个允许,后 2 个拒绝

令牌桶的核心优势:天然支持突发流量(Burst)。桶满时可以瞬间处理 capacity 个请求,之后以 refillRate 的速率持续处理,完美模拟真实场景中「偶尔突发、常态平稳」的流量模式。

2.4 漏桶算法(Leaky Bucket)

漏桶与令牌桶相反:请求进入桶排队,以固定速率从桶底「漏出」处理。桶满时新请求直接丢弃。

// 漏桶算法实现
class LeakyBucketRateLimiter {
  private queue: number[] = [];
  private lastLeakTime: number;

  constructor(
    private readonly capacity: number,       // 桶容量
    private readonly leakRate: number        // 每秒漏出的请求数
  ) {
    this.lastLeakTime = Date.now();
  }

  tryAcquire(): boolean {
    this.leak();

    if (this.queue.length < this.capacity) {
      this.queue.push(Date.now());
      return true;
    }
    return false;
  }

  private leak(): void {
    const now = Date.now();
    const elapsed = (now - this.lastLeakTime) / 1000;
    const leaked = Math.floor(elapsed * this.leakRate);
    if (leaked > 0) {
      this.queue.splice(0, Math.min(leaked, this.queue.length));
      this.lastLeakTime = now;
    }
  }
}

// 使用示例:桶容量 5,每秒漏出 1 个请求
const limiter = new LeakyBucketRateLimiter(5, 1);

漏桶的特点是输出速率恒定,适合需要平滑输出流量的场景(如消息队列的消费者限流)。但它的缺点也很明显:不允许突发流量,即使系统空闲也不能快速处理积压请求。

算法对比总览

算法 时间复杂度 空间复杂度 允许突发 平滑性 典型应用
固定窗口 O(1) O(1) ⚠️ 窗口边界突发 简单场景、日志限流
滑动窗口 O(1) O(1) ✅ Web API 首选
令牌桶 O(1) O(1) ✅ 可控突发 ✅ API Gateway(AWS/Stripe)
漏桶 O(1) O(n) 最好 消息队列、流量整形

⚠️ **警告:**不要在生产环境使用固定窗口计数器。窗口边界突发问题在高并发场景下会导致 2 倍于预期的流量穿透,后果严重。如果追求简单,至少使用滑动窗口计数器。

🚀 三、分布式限流:基于 Redis 的生产级方案

单机限流在微服务架构下完全不够用——多个服务实例各自维护计数器,限流形同虚设。分布式限流的核心思路是将计数器存储在共享的 Redis 中,利用 Lua 脚本保证原子性。

3.1 Redis + Lua 实现令牌桶

// 基于 Redis 的分布式令牌桶限流器
import Redis from 'ioredis';

const redis = new Redis();

// Lua 脚本:原子性地执行令牌桶逻辑
const TOKEN_BUCKET_SCRIPT = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refillRate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

-- 获取当前桶状态
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local lastRefill = tonumber(bucket[2]) or now

-- 计算补充的令牌数
local elapsed = math.max(0, now - lastRefill)
local refill = elapsed * refillRate
tokens = math.min(capacity, tokens + refill)

-- 尝试获取令牌
local allowed = 0
if tokens >= requested then
  tokens = tokens - requested
  allowed = 1
end

-- 更新桶状态,设置过期时间为窗口的 2 倍(防止内存泄漏)
redis.call('HMSET', key, 'tokens', tostring(tokens), 'last_refill', tostring(now))
redis.call('EXPIRE', key, math.ceil(capacity / refillRate) * 2)

return allowed
`;

class DistributedTokenBucket {
  constructor(
    private readonly redis: Redis,
    private readonly key: string,
    private readonly capacity: number,
    private readonly refillRate: number // 每秒补充的令牌数
  ) {}

  async tryAcquire(tokens: number = 1): Promise<boolean> {
    const now = Date.now() / 1000;
    const result = await this.redis.eval(
      TOKEN_BUCKET_SCRIPT,
      1,
      this.key,
      this.capacity.toString(),
      this.refillRate.toString(),
      now.toString(),
      tokens.toString()
    );
    return result === 1;
  }
}

// 使用示例:每个 API Key 每秒 10 个请求,突发最多 20 个
const limiter = new DistributedTokenBucket(
  redis,
  'rate_limit:user_123',
  20,   // capacity
  10    // refillRate: 每秒 10 个令牌
);

async function handleRequest() {
  const allowed = await limiter.tryAcquire();
  if (!allowed) {
    throw new Error('Rate limit exceeded');
  }
  // 处理请求...
}

📌 **记住:**Redis Lua 脚本的原子性是分布式限流的关键。如果用「先 GET 再 SET」的方式,在高并发下会出现竞态条件,导致限流失效。必须使用 Lua 脚本或 Redis 事务保证读写原子性。

3.2 Redis 滑动窗口实现

对于需要更精确限流的场景,可以使用 Redis Sorted Set 实现真正的滑动窗口:

// 基于 Redis Sorted Set 的滑动窗口限流器
const SLIDING_WINDOW_SCRIPT = `
local key = KEYS[1]
local windowMs = tonumber(ARGV[1])
local maxRequests = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requestId = ARGV[4]

-- 清除窗口外的旧记录
local windowStart = now - windowMs
redis.call('ZREMRANGEBYSCORE', key, '-inf', tostring(windowStart))

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

if count < maxRequests then
  -- 添加当前请求
  redis.call('ZADD', key, tostring(now), requestId)
  redis.call('PEXPIRE', key, windowMs)
  return 1
else
  -- 计算需要等待的时间
  local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
  local retryAfter = tonumber(oldest[2]) + windowMs - now
  return -math.ceil(retryAfter)
end
`;

class DistributedSlidingWindow {
  constructor(
    private readonly redis: Redis,
    private readonly key: string,
    private readonly maxRequests: number,
    private readonly windowMs: number
  ) {}

  async tryAcquire(): Promise<{ allowed: boolean; retryAfterMs?: number }> {
    const now = Date.now();
    const requestId = `${now}:${Math.random().toString(36).slice(2)}`;

    const result = await this.redis.eval(
      SLIDING_WINDOW_SCRIPT,
      1,
      this.key,
      this.windowMs.toString(),
      this.maxRequests.toString(),
      now.toString(),
      requestId
    );

    if (result === 1) {
      return { allowed: true };
    }
    return { allowed: false, retryAfterMs: Math.abs(result as number) };
  }
}

// 使用示例:每分钟最多 60 次请求
const limiter = new DistributedSlidingWindow(
  redis,
  'rate_limit:api:user_123',
  60,
  60_000
);

这个方案的巧妙之处在于:通过 ZREMRANGEBYSCORE 清除窗口外的记录,ZCARD 统计当前窗口请求数,ZADD 添加新请求——整个过程在 Lua 脚本中原子执行,既精确又高效。

🔧 四、实战集成:Hono/Express 中间件

限流算法写好了,下一步是集成到 Web 框架中。以下是 Hono 框架的限流中间件实现:

// Hono 限流中间件
import { Context, Next } from 'hono';
import { Redis } from 'ioredis';

interface RateLimitOptions {
  windowMs: number;       // 时间窗口(毫秒)
  maxRequests: number;    // 最大请求数
  keyGenerator: (c: Context) => string; // 限流 Key 生成函数
  message?: string;       // 超限返回消息
  skipSuccessfulRequests?: boolean;
}

function rateLimit(options: RateLimitOptions) {
  const {
    windowMs,
    maxRequests,
    keyGenerator,
    message = 'Too many requests, please try again later.',
    skipSuccessfulRequests = false,
  } = options;

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

  return async (c: Context, next: Next) => {
    const key = `rl:${keyGenerator(c)}`;
    const now = Date.now();

    // 使用滑动窗口 Lua 脚本
    const result = await redis.eval(
      SLIDING_WINDOW_SCRIPT, // 复用上文的脚本
      1,
      key,
      windowMs.toString(),
      maxRequests.toString(),
      now.toString(),
      `${now}:${Math.random().toString(36).slice(2)}`
    );

    if (result !== 1) {
      const retryAfterMs = Math.abs(result as number);
      c.header('Retry-After', Math.ceil(retryAfterMs / 1000).toString());
      c.header('X-RateLimit-Limit', maxRequests.toString());
      c.header('X-RateLimit-Remaining', '0');
      return c.json({ error: message }, 429);
    }

    await next();

    // 如果配置了跳过成功请求且请求成功,则不计数
    if (skipSuccessfulRequests && c.res.status < 400) {
      await redis.zrem(key, `${now}:*`);
    }
  };
}

// 使用示例
import { Hono } from 'hono';

const app = new Hono();

// 全局限流:每个 IP 每分钟 100 次
app.use('*', rateLimit({
  windowMs: 60_000,
  maxRequests: 100,
  keyGenerator: (c) => {
    return c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || 'unknown';
  },
}));

// 登录端点更严格限流:每个 IP 每 15 分钟最多 5 次
app.post('/api/auth/login', rateLimit({
  windowMs: 15 * 60_000,
  maxRequests: 5,
  keyGenerator: (c) => {
    const ip = c.req.header('x-forwarded-for') || 'unknown';
    return `login:${ip}`;
  },
  message: '登录尝试过于频繁,请 15 分钟后重试',
}), async (c) => {
  // 处理登录逻辑
  return c.json({ success: true });
});

⚠️ **警告:**不要用 x-forwarded-for 的第一个值作为限流 Key——这个头部可以被客户端伪造。生产环境中应该取最后一个可信代理添加的 IP,或者使用框架提供的 c.env.remoteAddress 等可信来源。

💡 五、限流策略设计与避坑指南

5.1 响应头规范

一个符合规范的限流实现必须返回标准的 HTTP 头部,让客户端知道当前的限流状态:

HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100          # 窗口内允许的最大请求数
X-RateLimit-Remaining: 0        # 窗口内剩余请求数
X-RateLimit-Reset: 1717056000   # 窗口重置的 Unix 时间戳
Retry-After: 30                 # 建议等待的秒数

5.2 常见陷阱与解决方案

❌ 陷阱一:只限流入,不限流出

很多团队只对外部 API 做限流,却忽略了内部服务之间的调用。当服务 A 以每秒 1000 次的速率调用服务 B,而服务 B 只能处理 500 次/秒时,服务 B 必然崩溃。

正确做法:内外部都要限流,内部服务间使用熔断器(Circuit Breaker)配合限流。

❌ 陷阱二:限流 Key 设计不当

用 IP 限流对使用 NAT 的企业用户极不公平——整个办公室可能共享一个出口 IP,一个人用多了就把所有人限了。

正确做法:优先使用 API Key / 用户 ID 作为限流 Key,IP 限流作为兜底防护。

❌ 陷阱三:返回 429 但不设 Retry-After

客户端收到 429 后不知道什么时候可以重试,只能盲目重试(thundering herd),反而加剧服务压力。

正确做法:总是返回 Retry-After 头,客户端使用指数退避 + Retry-After 的组合策略。

5.3 多层限流架构

生产环境推荐采用多层限流:

层级 位置 限流方式 延迟
L1 CDN/WAF IP 级别 DDoS 防护 < 1ms
L2 API Gateway API Key 级别速率限制 1-5ms
L3 应用层 用户级别精细限流 5-20ms
L4 数据库层 连接池限制 N/A

💡 **提示:**CDN/WAF 层(L1)的限流不需要精确,它的职责是挡住洪泛攻击;精确限流交给应用层(L2/L3)。分层越早拦截,系统越安全。

📝 总结与工具推荐

限流方案的选择没有银弹,核心是根据业务场景做取舍:

  • 简单场景(单体应用、低并发)→ 滑动窗口计数器,代码量少、效果好
  • API Gateway(需要突发支持)→ 令牌桶算法,AWS/Stripe 都在用
  • 微服务架构(多实例部署)→ Redis + Lua 分布式方案,必须原子操作
  • 严格流量整形(消息队列、支付网关)→ 漏桶算法,输出绝对平滑

推荐工具链:

工具 类型 适用场景
bottleneck npm 包 Node.js 客户端限流(防止调用外部 API 超限)
rate-limiter-flexible npm 包 功能最全的 Node.js 限流库,支持 Redis/Cluster
Cloudflare Rate Limiting SaaS CDN 层限零配置限流
Kong Rate Limiting 插件 API Gateway 层限流
express-rate-limit npm 包 Express 快速集成(单机场景)

⚡ **关键结论:**限流不是一个单独的功能,而是贯穿整个请求链路的安全基础设施。从 CDN 到应用层,每一层都应该有自己的限流策略。记住:宁可限流拒绝一些请求,也不要让整个系统因为过载而全面崩溃。前者只是部分用户暂时不可用,后者是所有用户全部不可用。

📚 相关文章