Language:Chinese VersionEnglish Version

上个月,我们的一个支付处理服务宕机了47分钟。根本原因不是业务逻辑中的错误或数据库故障。而是一个集成合作伙伴开始以正常流量50倍的频率疯狂调用我们的API,耗尽了连接池,导致其他所有客户端都无法获得服务。我们确实实施了速率限制——某种意义上的。一个每60秒重置的简单计数器。但这远远不够。

如果你构建API有一段时间了,你可能经历过类似的情况。速率限制听起来很简单,直到你实际上需要为处理每秒数千个请求且跨越多个区域的系统实现它时才会发现其中的复杂性。问题出在算法、分布式状态,以及——或许最容易被忽视的——你的客户端如何处理”请慢一点”的指令。

本文将分解三种最常见的速率限制算法,展示生产就绪的实现方案,并介绍使整个系统正常工作的客户端模式。

为什么简单的速率限制会失败

速率限制最简单的方法是固定计数器:在每个时间窗口内允许N个请求,每个请求时增加计数器,当计数器超过N时拒绝请求。大多数教程都停留在这个层面。问题在于边界条件,这被称为”边缘突发”问题。

想象一下每分钟100个请求的限制。客户端在前59秒发送0个请求,然后在第59秒发送100个请求。计数器在第60秒重置,客户端立即又发送了100个请求。在2秒内就是200个请求——是预期速率的两倍——而你的计数器从未触发限制。

这不是理论上的问题。在生产环境中,流量模式本质上就是突发的。定时作业在每分钟整点触发。移动应用在屏幕唤醒时同步。Webhook重试请求聚集在一起。一个无法处理突发模式的速率限制器根本算不上真正的速率限制器。

算法1:固定窗口计数器

尽管有缺陷,固定窗口计数器仍有其用武之地。它实现起来非常简单且易于理解,对于许多内部服务来说,其突发漏洞是可以接受的。

实现使用包含当前时间窗口的键:

-- Redis中的固定窗口(Lua脚本)
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])

local current = redis.call('INCR', key)
if current == 1 then
    redis.call('EXPIRE', key, window)
end

if current > limit then
    return 0
end
return 1

键通常编码客户端标识符和窗口时间戳:ratelimit:client_abc:1711929600,其中时间戳被向下取整到窗口边界。当新窗口开始时,键尚不存在,因此INCR将其初始化为1,我们设置过期时间。

这个 Lua 脚本是原子性的—Redis 执行 Lua 脚本时不会交错其他命令—因此 INCR 和 EXPIRE 之间不会出现竞态条件。这一点比人们想象的更重要。我曾见过使用独立的 GET 和 SET 命令的实现,在高并发情况下,键可能会没有过期时间,导致内存泄漏,直到 Redis 达到其 maxmemory 限制。

使用场景: 内部服务间通信,在这种情况下你信任客户端,只需要一个安全阀。后台任务队列,其中近似限制是可以接受的。

算法 2:滑动窗口日志

滑动窗口日志通过跟踪单个请求的时间戳而不是聚合计数来解决边缘突发问题。对于每个请求,你记录时间戳,删除超出窗口的条目,然后检查剩余数量是否超过限制。

-- Redis 中的滑动窗口日志(Lua 脚本)
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = 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
    return 0
end

-- 添加新请求
redis.call('ZADD', key, now, member)
redis.call('EXPIRE', key, window)
return 1

这使用了一个 Redis 有序集合,其中分数是时间戳。ZREMRANGEBYSCORE 修剪旧条目,ZCARD 计算剩余数量。member 值需要每个请求都是唯一的—UUID 或时间戳和随机后缀的组合都可以。

精度是完美的:在任何时刻,你都在精确计算尾部窗口内的请求数。但内存成本很高。如果你允许每小时 10,000 个请求,每个客户端的有序集合最多可以保存 10,000 个条目。乘以数千个客户端,你将面临严重的 Redis 内存消耗。

使用场景: 当精度比内存更重要时,通常用于像支付处理或短信发送这样的高成本操作,每个请求都有实际成本。

算法 3:令牌桶

令牌桶是我在大多数生产场景中首先选择的算法。它允许受控的突发流量(这通常是您真正想要的),同时保持稳定的速率限制。其思维模型很简单:想象一个存放令牌的桶。令牌以固定速率添加。每个请求消耗一个令牌。如果桶为空,请求将被拒绝。桶有最大容量,这决定了突发流量的大小。

-- Redis中的令牌桶(Lua脚本)
local key = KEYS[1]
local capacity = tonumber(ARGV[1])      -- 最大令牌数(突发大小)
local rate = tonumber(ARGV[2])           -- 每秒令牌数
local now = tonumber(ARGV[3])            -- 当前时间戳(毫秒)
local requested = tonumber(ARGV[4])      -- 要消耗的令牌数(通常为1)

local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1])
local last_refill = tonumber(bucket[2])

if tokens == nil then
    -- 首次请求:初始化满桶
    tokens = capacity
    last_refill = now
end

-- 计算自上次补充以来的令牌数
local elapsed = (now - last_refill) / 1000
local new_tokens = elapsed * rate
tokens = math.min(capacity, tokens + new_tokens)

local allowed = 0
local remaining = tokens

if tokens >= requested then
    tokens = tokens - requested
    allowed = 1
    remaining = tokens
end

-- 存储更新后的状态
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, math.ceil(capacity / rate) * 2)

return {allowed, math.floor(remaining)}

此实现为每个客户端存储两个值:当前令牌数和上次补充时间戳。每次请求时,它会计算自上次检查以来积累了多少令牌,添加这些令牌(不超过容量),然后尝试消耗请求的数量。

令牌桶的美妙之处在于它的两个参数直接映射到业务需求。”我们希望客户端能够每分钟发出100个请求,突发流量最多20个”可以转换为rate=1.67(100/60),capacity=20。突发流量处理已内置在算法中,而不是您需要解决的边缘情况。

应用代码中的令牌桶

这是一个 Go 实现,适用于不需要 Redis 依赖的场景,比如 CLI 工具或单实例服务:

package ratelimit

import (
    "sync"
    "time"
)

type TokenBucket struct {
    mu         sync.Mutex
    tokens     float64
    capacity   float64
    rate       float64 // 每秒令牌数
    lastRefill time.Time
}

func NewTokenBucket(capacity float64, ratePerSecond float64) *TokenBucket {
    return &TokenBucket{
        tokens:     capacity,
        capacity:   capacity,
        rate:       ratePerSecond,
        lastRefill: time.Now(),
    }
}

func (tb *TokenBucket) Allow() bool {
    return tb.AllowN(1)
}

func (tb *TokenBucket) AllowN(n float64) bool {
    tb.mu.Lock()
    defer tb.mu.Unlock()

    now := time.Now()
    elapsed := now.Sub(tb.lastRefill).Seconds()
    tb.tokens += elapsed * tb.rate
    if tb.tokens > tb.capacity {
        tb.tokens = tb.capacity
    }
    tb.lastRefill = now

    if tb.tokens < n {
        return false
    }
    tb.tokens -= n
    return true
}

注意互斥锁的使用。即使在单进程应用中,如果你要处理并发的 HTTP 请求,桶的状态是共享的。我见过生产环境中的 bug,开发者基于 goroutine 的服务器假设是单线程执行,结果导致令牌计数变为负数。

服务器端实现模式

Redis + Lua:生产环境标准

对于分布式系统,使用 Lua 脚本的 Redis 是最经过实战检验的方法。上面显示的 Lua 脚本是原子的,Redis 的单线程执行模型意味着你不需要分布式锁。以下是一些在生产环境中运行此方法的操作注意事项:

谨慎使用 Redis Cluster。 单个速率限制检查的所有键必须位于同一个分片上。使用哈希标签来确保这一点:ratelimit:{client_abc}:tokens,其中 {client_abc} 部分决定了分片。

设置适当的超时时间。 如果 Redis 无法访问,你需要一个策略。大多数服务默认允许请求(故障开放),因为短暂的无速率限制时期比完全中断要好。但对于安全敏感的限制(登录尝试、OTP 验证),你可能希望故障关闭。

async function checkRateLimit(clientId, limit, window) {
    try {
        const result = await redis.eval(luaScript, 1,
            `ratelimit:${clientId}`,
            limit, window, Date.now(), uuidv4()
        );
        return result === 1;
    } catch (err) {
        logger.warn('Rate limit check failed, failing open', {
            clientId, error: err.message
        });
        // 对一般 API 限制故障开放
        return true;
    }
}

Nginx 速率限制

对于边缘级别的防护,nginx 内置的限流模块作为第一道防线效果很好。它在内部使用了漏桶算法:

http {
    # 定义一个区域:10MB 共享内存,基于客户端 IP 键,50 请求/秒
    limit_req_zone $binary_remote_addr zone=api_limit:10m rate=50r/s;

    # 对于认证端点,基于请求头中的 API 键
    map $http_x_api_key $api_key_limit {
        default $http_x_api_key;
        ""      $binary_remote_addr;
    }
    limit_req_zone $api_key_limit zone=auth_api_limit:20m rate=100r/s;

    server {
        location /api/ {
            limit_req zone=api_limit burst=20 nodelay;
            limit_req_status 429;

            proxy_pass http://backend;
        }

        location /api/v2/ {
            limit_req zone=auth_api_limit burst=50 nodelay;
            limit_req_status 429;

            proxy_pass http://backend;
        }
    }
}

burst 参数允许排队处理超额请求而不是立即拒绝它们。使用 nodelay,突发请求会立即处理但会占用突发预算。不使用 nodelay,请求会被延迟以匹配配置的速率。在实践中,对于 API 端点你几乎总是希望使用 nodelay—客户端宁愿快速收到 429 响应,而不是等待 10 秒钟获得延迟的 200 响应。

API 网关限流

如果你正在运行 Kong、AWS API Gateway 或 Envoy,限流是一个配置问题而不是代码问题。例如 Kong 的限流插件:

plugins:
  - name: rate-limiting
    config:
      second: 10
      minute: 500
      hour: 10000
      policy: redis
      redis_host: rate-limit-redis.internal
      redis_port: 6379
      redis_database: 0
      fault_tolerant: true
      hide_client_headers: false

fault_tolerant: true 设置意味着如果 Redis 不可用,Kong 将允许请求通过—这与前面讨论的相同故障开放模式。hide_client_headers: false 确保限流头部被传递给客户端,这把我们带到了客户端方面。

响应头部:通信层

限流是服务器和客户端之间的一场对话。这场对话的服务器端通过 HTTP 头部实现。IETF 草案 RateLimit Header Fields(draft-ietf-httpapi-ratelimit-headers)正在形成一个标准,但实践中你会看到几种约定:

HTTP/1.1 200 OK
RateLimit-Limit: 100
RateLimit-Remaining: 67
RateLimit-Reset: 1711929660
Retry-After: 30

RateLimit-Limit 告诉客户端他们的配额。RateLimit-Remaining 告诉他们还剩多少。RateLimit-Reset 是窗口重置时的 Unix 时间戳。Retry-After 出现在 429 响应中,告诉客户端需要等待多少秒。

以下是在 Express.js 中设置这些头的中间件:

function rateLimitMiddleware(options) {
    const { limit, windowMs } = options;

    return async (req, res, next) => {
        const clientId = req.headers['x-api-key'] || req.ip;
        const result = await checkRateLimit(clientId, limit, windowMs);

        res.set('RateLimit-Limit', String(limit));
        res.set('RateLimit-Remaining', String(result.remaining));
        res.set('RateLimit-Reset', String(result.resetAt));

        if (!result.allowed) {
            const retryAfter = Math.ceil((result.resetAt - Date.now()) / 1000);
            res.set('Retry-After', String(retryAfter));
            return res.status(429).json({
                error: 'rate_limit_exceeded',
                message: `Rate limit of ${limit} requests per ${windowMs/1000}s exceeded`,
                retry_after: retryAfter
            });
        }

        next();
    };
}

客户端:合理使用速率限制

带抖动的指数退避

当你收到 429 响应时,最糟糕的做法是立即重试。第二糟糕的做法是在固定延迟后重试——因为所有在同一时间被限制速率的其他客户端也会在相同的延迟后重试,从而造成惊群效应。

带抖动的指数退避是标准解决方案。下面是一个 Python 实现,当存在 Retry-After 头时会遵循它:

import time
import random
import requests
from requests.adapters import HTTPAdapter

class RateLimitedClient:
    def __init__(self, base_url, max_retries=5):
        self.base_url = base_url
        self.max_retries = max_retries
        self.session = requests.Session()
        self.session.mount('https://', HTTPAdapter(max_retries=0))

    def request(self, method, path, **kwargs):
        url = f"{self.base_url}{path}"
        last_exception = None

        for attempt in range(self.max_retries + 1):
            try:
                response = self.session.request(method, url, **kwargs)

                if response.status_code != 429:
                    return response

                # 如果存在 Retry-After 头,则使用它
                retry_after = response.headers.get('Retry-After')
                if retry_after:
                    delay = float(retry_after)
                else:
                    # 指数退避:1s, 2s, 4s, 8s, 16s
                    base_delay = min(2 ** attempt, 32)
                    # 完全抖动:在 0 和 base_delay 之间随机
                    delay = random.uniform(0, base_delay)

                time.sleep(delay)

            except requests.ConnectionError as e:
                last_exception = e
                base_delay = min(2 ** attempt, 32)
                time.sleep(random.uniform(0, base_delay))

        raise Exception(
            f"在 {self.max_retries} 次重试后请求失败: {last_exception}"
        )

根据 AWS 在其架构博客上发表的分析,”完全抖动”策略(random.uniform(0, base_delay))在减少争用情况下的总完成时间方面,优于”相等抖动”和”解相关抖动”。关键见解是,将重试分散在整个延迟窗口中,可以最大化至少有一些客户端能在每次重试轮次中通过的概率。

断路器模式

指数退避处理瞬时速率限制。但如果下游服务持续拒绝你的请求呢?继续重试会浪费双方资源。断路器模式通过跟踪故障率并在故障超过阈值时”断开电路”来解决这一问题:

class CircuitBreaker:
    CLOSED = 'closed'        # 正常运行
    OPEN = 'open'            # 故障状态,立即拒绝
    HALF_OPEN = 'half_open'  # 测试服务是否已恢复

    def __init__(self, failure_threshold=5, recovery_timeout=30,
                 success_threshold=3):
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.success_threshold = success_threshold
        self.state = self.CLOSED
        self.failure_count = 0
        self.success_count = 0
        self.last_failure_time = None

    def can_execute(self):
        if self.state == self.CLOSED:
            return True
        if self.state == self.OPEN:
            if time.time() - self.last_failure_time > self.recovery_timeout:
                self.state = self.HALF_OPEN
                self.success_count = 0
                return True
            return False
        # HALF_OPEN: 允许请求通过进行测试
        return True

    def record_success(self):
        if self.state == self.HALF_OPEN:
            self.success_count += 1
            if self.success_count >= self.success_threshold:
                self.state = self.CLOSED
                self.failure_count = 0
        else:
            self.failure_count = 0

    def record_failure(self):
        self.failure_count += 1
        self.last_failure_time = time.time()
        if self.failure_count >= self.failure_threshold:
            self.state = self.OPEN

在实践中,你会将断路器包装在受速率限制的客户端周围,这样当服务持续返回429状态码时,你的应用程序会在恢复期内完全停止发送请求。Netflix的Hystrix库推广了这种模式,虽然Hystrix本身处于维护模式,但这种模式仍然存在于resilience4j(Java)、Polly(.NET)和gobreaker(Go)等库中。

SaaS的多租户速率限制

如果你正在构建SaaS产品,速率限制会变得复杂得多。你不仅是在保护系统免受过载,还在执行业务规则,确保租户间的公平性,并且通常将限制与定价层级挂钩。

分层限制

大多数 SaaS API 使用分层限制结构:

const TIER_LIMITS = {
    free: {
        requests_per_minute: 60,
        requests_per_day: 1000,
        burst: 10,
        concurrent_connections: 2
    },
    pro: {
        requests_per_minute: 600,
        requests_per_day: 50000,
        burst: 50,
        concurrent_connections: 10
    },
    enterprise: {
        requests_per_minute: 6000,
        requests_per_day: 500000,
        burst: 200,
        concurrent_connections: 50
    }
};

async function multiTierRateLimit(req, res, next) {
    const apiKey = req.headers['x-api-key'];
    const tenant = await getTenantByApiKey(apiKey);
    const limits = TIER_LIMITS[tenant.tier];

    // 并行检查多个限制
    const [minuteOk, dailyOk, concurrentOk] = await Promise.all([
        checkTokenBucket(
            `minute:${tenant.id}`,
            limits.burst,
            limits.requests_per_minute / 60
        ),
        checkFixedWindow(
            `daily:${tenant.id}`,
            limits.requests_per_day,
            86400
        ),
        checkConcurrent(
            `concurrent:${tenant.id}`,
            limits.concurrent_connections
        )
    ]);

    if (!minuteOk || !dailyOk || !concurrentOk) {
        // 返回有关超出哪个限制的具体信息
        return res.status(429).json({
            error: 'rate_limit_exceeded',
            limits: {
                per_minute: { limit: limits.requests_per_minute, exceeded: !minuteOk },
                per_day: { limit: limits.requests_per_day, exceeded: !dailyOk },
                concurrent: { limit: limits.concurrent_connections, exceeded: !concurrentOk }
            }
        });
    }

    next();
}

注意,我们同时检查多个限制。租户可能在其每分钟限制范围内,但已经用尽了每日配额。响应体告诉客户端他们具体触发了哪个限制,这对于调试至关重要——没有任何比一个没有任何上下文的裸 429 更令人沮丧的事情了。

负载下的公平性

这是一个让我们吃过亏的场景:在流量高峰期间,一个企业租户消耗了我们 80% 的 API 容量。他们在速率限制范围内——他们为高限制付费。但他们的流量正在影响数百个小租户的体验。单独的速率限制是不够的;我们需要全局公平性。

解决方案是一种加权公平排队方法。每个租户根据其层级获得相应的权重,在系统过载时,请求按比例被接受:

async function fairnessAwareRateLimit(req, tenant) {
    // 第一步检查:单个租户限制(快速路径)
    const withinTenantLimit = await checkTenantLimit(tenant);
    if (!withinTenantLimit) return false;

    // 第二步检查:全局系统负载
    const systemLoad = await getSystemLoad(); // 0.0 到 1.0
    if (systemLoad < 0.8) return true; // 不需要公平性限流

    // 高负载情况下的加权接受
    const weight = TIER_WEIGHTS[tenant.tier]; // 免费版=1,专业版=5,企业版=20
    const totalWeight = await getTotalActiveWeight();
    const fairShare = weight / totalWeight;

    const tenantRecentRequests = await getRecentRequestCount(tenant.id, 10);
    const totalRecentRequests = await getTotalRecentRequests(10);
    const actualShare = tenantRecentRequests / totalRecentRequests;

    // 如果租户使用量低于其公平份额,则允许通过
    return actualShare <= fairShare * 1.2; // 20% 宽容度
}

监控与可观测性

没有监控的速率限制就像没有警报的烟雾探测器。你需要知道限制何时被触发、被谁触发,以及你的限制设置是否正确。

需要跟踪的关键指标:

  • 按层级划分的速率限制命中率 — 如果40%的免费层级用户每天都会触发限制,那么你的免费层级限制过于严格(或者你的文档没有正确设置用户期望)。
  • 429响应百分比 — 将此作为总响应的百分比进行跟踪。突然的激增意味着流量模式发生了变化或者有客户端行为异常。
  • 重试风暴检测 — 监控429响应后立即重试的模式。这表明客户端没有正确实现退避算法。
  • 各层级的剩余空间 — 租户通常使用其限制的百分比是多少?如果大多数企业客户通常达到90%,你可能需要提高限制或者提供更高级别的服务层级。
# 速率限制的 Prometheus 指标
from prometheus_client import Counter, Histogram, Gauge

rate_limit_decisions = Counter(
    'rate_limit_decisions_total',
    'Rate limit decisions',
    ['tenant_tier', 'decision', 'limit_type']
)

rate_limit_remaining = Gauge(
    'rate_limit_remaining_ratio',
    'Ratio of remaining rate limit quota',
    ['tenant_id', 'limit_type']
)

rate_limit_check_duration = Histogram(
    'rate_limit_check_duration_seconds',
    'Time to evaluate rate limit',
    buckets=[0.001, 0.005, 0.01, 0.025, 0.05, 0.1]
)

常见陷阱

分布式系统中的时钟偏差。如果你的速率限制检查在多个应用服务器之间进行,而这些服务器的时钟略有不同,那么你的固定窗口和滑动窗口实现将表现不一致。在 Lua 脚本中使用 Redis 服务器时间(redis.call('TIME')),而不是从应用服务器传递时间戳。

在错误的层级实现速率限制。我曾见过团队在应用代码中实现速率限制,但却没有限制数据库连接池。一个发送 1,000 个请求且每个请求触发 10 个数据库查询的客户端实际上是在执行 10,000 次操作。考虑在多个层级实现速率限制:边缘层(nginx)、应用层(每个端点)和资源层(数据库连接、外部 API 调用)。

忽略 WebSocket 和流式传输。传统的基于请求的速率限制不适用于持久连接。对于 WebSocket API,你需要在连接内实现基于消息的速率限制,同时限制每个客户端的并发连接数。

不对内部服务进行速率限制。“但这是内部服务,我们信任调用方。”直到服务 A 中的部署错误导致对服务 B 的无限重试循环。每个服务到服务的调用都应该有限速限制,即使这些限制很宽松。

整合所有内容

生产环境中的速率限制系统通常采用多层策略:

  1. 边缘层(nginx/CDN):基于 IP 的速率限制,用于阻止 DDoS 和明显的滥用行为。设置宽松的限制,采用失败关闭策略。
  2. API 网关:基于 API 密钥的速率限制,与订阅级别绑定。使用令牌桶实现每秒限制,使用固定窗口实现每日配额。
  3. 应用层:对昂贵操作(搜索、导出、批量处理)的每个端点限制。使用滑动窗口以保证准确性。
  4. 资源层:连接池、队列深度和并发限制,以保护数据库和下游服务。

每一层都有不同的目的,并处理不同的故障模式。边缘层阻止大规模攻击。网关执行业务规则。应用层保护昂贵操作。资源层防止级联故障。

速率限制是那些很容易做到 80% 正确,但要做到 100% 正确却极其困难的问题之一。但要达到 95% 的正确性——使用令牌桶或滑动窗口,在 Redis 中使用 Lua 脚本运行检查,返回适当的头部,并监控结果——是直接的工程实践。那剩下的 5% 就需要你根据实际流量模式调整限制,处理分布式部署中的边缘情况,并与那些坚信自己的用例应该获得例外对待的客户进行协商。

从令牌桶开始。用本文中的 Lua 脚本在 Redis 中实现它。添加响应头。构建一个遵循这些头的客户端。你的 API 将会超越市面上 90% 的其他 API。

By Michael Sun

Founder and Editor-in-Chief of NovVista. Software engineer with hands-on experience in cloud infrastructure, full-stack development, and DevOps. Writes about AI tools, developer workflows, server architecture, and the practical side of technology. Based in China.

Leave a Reply

Your email address will not be published. Required fields are marked *

You missed