深入浅出 Rate Limiting:从固定窗口到令牌桶的实战指南

By | 2026年8月1日

为什么你需要限流?

在微服务架构和 API 设计中,限流(Rate Limiting)是保障系统稳定性的关键技术。想象一下,你的服务突然被恶意请求或异常流量冲击,如果没有限流,数据库连接池可能被耗尽,CPU 飙升,最终导致整个服务不可用。限流就像交通管制,它控制请求的速率,确保系统负载在可承受范围内。

常见的限流场景包括:

  • 保护后端服务免受突发流量冲击
  • 防止 API 被滥用(如爬虫、暴力破解)
  • 保障多租户系统中每个租户的公平使用
  • 平滑流量,避免高峰时段过载

本文将带你深入理解四种经典限流算法的原理与实现,并给出基于 Node.js 和 Redis 的实战代码,帮助你构建高可用的限流模块。

限流算法概览

限流算法本质上是对请求速率的控制,常见的有四种:

  1. 固定窗口(Fixed Window):简单,但存在临界问题。
  2. 滑动窗口(Sliding Window):解决固定窗口的临界问题,但实现稍复杂。
  3. 漏桶(Leaky Bucket):恒定速率处理请求,适合平滑流量。
  4. 令牌桶(Token Bucket):允许一定程度的突发流量,是最常用的算法。

下面我们逐一深入。

1. 固定窗口算法

原理

固定窗口将时间划分为固定大小的窗口(如 1 秒),每个窗口内允许一定数量的请求。窗口边界是固定的(如从 0 秒到 1 秒,1 秒到 2 秒)。

实现

以下是一个简单的 Node.js 实现:


class FixedWindowLimiter {
    constructor(windowSizeMs, maxRequests) {
        this.windowSizeMs = windowSizeMs;
        this.maxRequests = maxRequests;
        this.windowStart = Date.now();
        this.count = 0;
    }

    allow() {
        const now = Date.now();
        // 如果当前时间超过窗口边界,重置窗口
        if (now - this.windowStart >= this.windowSizeMs) {
            this.windowStart = now;
            this.count = 0;
        }
        if (this.count < this.maxRequests) {
            this.count++;
            return true;
        }
        return false;
    }
}

临界问题

固定窗口的缺陷在于临界问题。假设窗口大小为 1 秒,最大请求数为 5。在 0.9 秒时来了 5 个请求,窗口重置后,1.1 秒又来了 5 个请求,那么 0.9 到 1.1 秒之间实际上处理了 10 个请求,超出了限制。

2. 滑动窗口算法

原理

滑动窗口将时间划分为更小的粒度(如秒),并记录每个小窗口的请求数。窗口随时间滑动,计算当前窗口总和。

实现

使用一个数组记录每个小窗口的计数:


class SlidingWindowLimiter {
    constructor(windowSizeMs, maxRequests, precisionMs = 1000) {
        this.windowSizeMs = windowSizeMs;
        this.maxRequests = maxRequests;
        this.precisionMs = precisionMs;
        this.window = []; // 存储 [timestamp, count]
    }

    allow() {
        const now = Date.now();
        // 清理过期数据
        while (this.window.length > 0 && now - this.window[0][0] > this.windowSizeMs) {
            this.window.shift();
        }
        // 计算当前窗口总请求数
        let total = 0;
        for (const [ts, count] of this.window) {
            total += count;
        }
        if (total < this.maxRequests) {
            // 添加当前请求到窗口
            const lastIndex = this.window.length - 1;
            if (lastIndex >= 0 && now - this.window[lastIndex][0] < this.precisionMs) {
                this.window[lastIndex][1]++;
            } else {
                this.window.push([now, 1]);
            }
            return true;
        }
        return false;
    }
}

滑动窗口的精度取决于小窗口的粒度。粒度越小,精度越高,但内存占用也越大。

3. 漏桶算法

原理

漏桶算法将请求视为水滴,桶的容量有限,水滴以固定速率从桶底漏出(即处理请求)。如果桶满了,新的水滴会被丢弃。

实现


class LeakyBucketLimiter {
    constructor(capacity, leakRatePerSec) {
        this.capacity = capacity;
        this.leakRatePerSec = leakRatePerSec;
        this.water = 0;
        this.lastTime = Date.now();
    }

    allow() {
        const now = Date.now();
        // 计算漏掉的水量
        const leaked = ((now - this.lastTime) / 1000) * this.leakRatePerSec;
        this.water = Math.max(0, this.water - leaked);
        this.lastTime = now;
        if (this.water < this.capacity) {
            this.water++;
            return true;
        }
        return false;
    }
}

漏桶算法强制平滑流量,但无法应对突发流量,因为即使桶未满,处理速率也是恒定的。

4. 令牌桶算法

原理

令牌桶以固定速率向桶中添加令牌,每个请求需要消耗一个令牌。桶的容量有限,如果桶满则令牌被丢弃。与漏桶不同,令牌桶允许一定程度的突发流量(因为桶内可以累积令牌)。

实现


class TokenBucketLimiter {
    constructor(capacity, refillRatePerSec) {
        this.capacity = capacity;
        this.tokens = capacity; // 初始装满令牌
        this.refillRatePerSec = refillRatePerSec;
        this.lastRefill = Date.now();
    }

    allow() {
        this.refill();
        if (this.tokens >= 1) {
            this.tokens--;
            return true;
        }
        return false;
    }

    refill() {
        const now = Date.now();
        const elapsedSec = (now - this.lastRefill) / 1000;
        this.tokens = Math.min(this.capacity, this.tokens + elapsedSec * this.refillRatePerSec);
        this.lastRefill = now;
    }
}

令牌桶算法是业界最常用的算法,例如 Guava 的 RateLimiter 就是基于令牌桶的变体。

实战:基于 Redis 的分布式限流

单机限流无法应对多实例部署的场景,因此我们需要分布式限流。Redis 的 INCREXPIRE 命令可以轻松实现固定窗口限流。

固定窗口的 Redis 实现


const redis = require('redis');
const client = redis.createClient();

async function fixedWindowLimit(key, maxRequests, windowSec) {
    const current = await client.incr(key);
    if (current === 1) {
        await client.expire(key, windowSec);
    }
    return current <= maxRequests;
}

但这种方式仍有临界问题。更优雅的方案是使用 Redis 的 ZSET 实现滑动窗口。

滑动窗口的 Redis 实现


async function slidingWindowLimit(userId, maxRequests, windowSec) {
    const key = `rate:${userId}`;
    const now = Date.now();
    const oldest = now - windowSec * 1000;
    const pipeline = client.multi();
    pipeline.zremrangebyscore(key, 0, oldest); // 移除过期记录
    pipeline.zadd(key, now, now); // 添加当前请求
    pipeline.zcard(key); // 统计窗口内请求数
    pipeline.expire(key, windowSec); // 设置过期时间
    const results = await pipeline.exec();
    const count = results[2];
    return count <= maxRequests;
}

令牌桶的 Redis 实现(Lua 脚本)

为了原子性,我们使用 Lua 脚本:


-- KEYS[1]: 令牌桶 key
-- ARGV[1]: 容量
-- ARGV[2]: 每秒补充速率
-- ARGV[3]: 当前时间戳(秒)
-- ARGV[4]: 请求的令牌数(通常为1)

local bucket = KEYS[1]
local capacity = tonumber(ARGV[1])
local refillRate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

local lastRefill = tonumber(redis.call('hget', bucket, 'lastRefill') or now)
local tokens = tonumber(redis.call('hget', bucket, 'tokens') or capacity)

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

if tokens >= requested then
    tokens = tokens - requested
    redis.call('hset', bucket, 'tokens', tokens)
    redis.call('hset', bucket, 'lastRefill', now)
    return 1
else
    return 0
end

在 Node.js 中调用:


const script = `...`; // 上面的 Lua 脚本
const result = await client.eval(script, 1, 'user:123', capacity, refillRate, Math.floor(Date.now()/1000), 1);

最佳实践与避坑指南

选择合适的算法

  • 如果系统需要平滑流量,选择漏桶。
  • 如果希望允许突发流量,选择令牌桶。
  • 如果是简单的 API 限流,固定窗口或滑动窗口已足够。

注意时钟同步

在分布式环境中,时间戳依赖可能导致问题。如果使用 Redis,确保服务器时间同步(NTP)。

设置合理的超时和过期时间

Redis 的 key 一定要设置过期时间,防止内存泄漏。

使用原子操作

避免竞态条件,使用 Lua 脚本或事务。

降级策略

当限流触发时,可以返回 429 状态码,并提供 Retry-After 头。

监控与告警

记录限流事件,监控指标,及时发现异常。

总结

限流是系统防护的重要防线,本文介绍了四种算法的原理与实现,并给出了分布式限流的实战方案。在实际项目中,建议结合具体场景选择合适的算法,并注意原子性和性能。下一步,你可以尝试将限流集成到 API 网关中,或者使用现成的中间件(如 Express-rate-limit)。

希望本文能帮助你构建稳定可靠的系统!