本指南使用 Redis、Lua 脚本,以及 Node.js 的 async/await,实现一个分布式令牌桶限流器。Node.js 部分采用 node-redis 客户端库。
概述
限流用于控制操作执行的速率,常见用途包括:
- 限制每名用户或每个 IP 地址的 API 请求量。
- 防止滥用,抵御拒绝服务攻击。
- 在多个客户端之间公平分配资源。
- 限制后台任务或批量操作的执行速率。
令牌桶是一种常见限流算法:它允许一定程度的突发流量,同时约束长期平均速率。
令牌桶如何工作
可以把算法理解为一个存放令牌的桶:
- 初始化:桶内令牌数量从最大容量开始。
- 补充:按恒定速率补充令牌,例如每秒一个。
- 消耗:每个请求消耗一个令牌。
- 决策:有令牌则放行,否则拒绝。
- 容量上限:桶中令牌始终不超过最大容量。
积累的令牌用于吸收突发流量,补充速率则控制长期平均请求速率。
为什么使用 Redis
- 原子操作:Lua 脚本原子执行,避免竞态条件。
- 共享状态:多台应用服务器可以共享同一组限流计数。
- 高性能:内存操作可提供微秒级延迟。
- 自动过期:键可以自动过期,不过本节令牌桶实现未使用这一功能。
核心 Lua 脚本
核心逻辑是在 Redis 服务端原子运行的 Lua 脚本。令牌桶状态的检查与更新被合并为一个不可分割的操作,防止分布式环境中的竞态。
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local refill_interval = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
-- Get current state or initialize
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1])
local last_refill = tonumber(bucket[2])
-- Initialize if this is the first request
if tokens == nil then
tokens = capacity
last_refill = now
end
-- Calculate token refill
local time_passed = now - last_refill
local refills = math.floor(time_passed / refill_interval)
if refills > 0 then
tokens = math.min(capacity, tokens + (refills * refill_rate))
last_refill = last_refill + (refills * refill_interval)
end
-- Try to consume a token
local allowed = 0
if tokens >= 1 then
tokens = tokens - 1
allowed = 1
end
-- Update state
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', last_refill)
-- Return result: allowed (1 or 0) and remaining tokens
return {allowed, tokens}
脚本分解
- 获取状态:通过
HMGET从哈希中读取当前令牌数和上次补充时间。 - 初始化:首次使用时将令牌数设为最大容量。
- 计算补充量:根据经过的时间计算应该补充的令牌数。
- 限制容量:使用
math.min(),确保令牌数不超过容量。 - 消耗令牌:有可用令牌时将数量减一。
- 保存状态:使用
HMSET保存新状态。 - 返回结果:同时返回是否放行和剩余令牌数。
原子性为什么重要
如果这些步骤不是原子执行,就可能出现:
- 重复消耗:两个请求读到相同的令牌数,本应只放行一个,却都获得通过。
- 更新丢失:并发更新相互覆盖。
- 状态不一致:令牌数与补充时间不同步。
使用 EVAL 或 EVALSHA 可保证整个操作原子执行,使其适用于分布式系统。
安装
从 npm 安装 redis 包:
npm install redis
使用 Node.js 模块
TokenBucket 类提供异步限流接口,源码为文末演示下载目录中的 tokenBucket.js:
const { createClient } = require('redis');
const { TokenBucket } = require('./tokenBucket');
// Create a Redis connection
const client = createClient({ url: 'redis://localhost:6379' });
await client.connect();
// Create a rate limiter: 10 requests per second
const limiter = new TokenBucket({
redisClient: client,
capacity: 10, // Maximum burst size
refillRate: 1, // Add 1 token per interval
refillInterval: 1.0 // Every 1 second
});
// Check if a request should be allowed
const { allowed, remaining } = await limiter.allow('user:123');
if (allowed) {
console.log(`Request allowed. ${remaining} tokens remaining.`);
// Process the request
} else {
console.log('Request denied. Rate limit exceeded.');
// Return 429 Too Many Requests
}
// Disconnect when done
await client.disconnect();
由于 node-redis 操作是异步的,allow() 返回 Promise,应使用 await 或 .then() 处理结果。
配置参数
capacity:桶内最大令牌数,决定突发请求容量。refillRate:每个补充间隔加入的令牌数量。refillInterval:补充间隔,单位为秒。
例如:
capacity: 10, refillRate: 1, refillInterval: 1.0:平均每秒一个请求,最多允许十个请求的突发。capacity: 100, refillRate: 10, refillInterval: 1.0:平均每秒十个请求,最多允许一百个请求的突发。capacity: 60, refillRate: 1, refillInterval: 60.0:平均每分钟一个请求,最多允许六十个请求的突发。
限流键
key 参数标识被限流的对象,常见模式包括:
- 按用户:
user:{userId},独立限制每名用户。 - 按 IP 地址:
ip:{ipAddress},按客户端 IP 限制。 - 按 API 端点:
api:{endpoint}:{userId},为不同端点设置不同限制。 - 全局:
global:api,全部请求共享一个限制。
通过 EVALSHA 缓存脚本
Node.js 实现使用 EVALSHA 优化性能。首次使用时,Lua 脚本通过 SCRIPT LOAD 加载到 Redis;后续调用使用已缓存的 SHA1 哈希。如果脚本从缓存中被移除,模块会自动回退到 EVAL 并重新加载脚本。
// The module handles script caching automatically.
// First call loads the script, subsequent calls use EVALSHA.
const result1 = await limiter.allow('user:123'); // Uses EVAL + caches
const result2 = await limiter.allow('user:123'); // Uses EVALSHA (faster)
运行演示
获取源文件
演示由两个 JavaScript 文件组成。可从 GitHub 的 nodejs 源码目录下载,也可以使用 curl:
mkdir rate-limiter-demo && cd rate-limiter-demo
BASE=https://raw.githubusercontent.com/redis/docs/main/content/develop/use-cases/rate-limiter/nodejs
curl -O $BASE/tokenBucket.js
curl -O $BASE/demoServer.js
启动演示服务器
demoServer.js 提供一个 HTTP 演示服务器,展示限流器的运行方式:
# Install dependencies
npm install redis
# Run the demo server
node demoServer.js
演示中的交互式网页界面支持:
- 提交请求,实时查看请求被放行还是拒绝。
- 查看当前令牌数量。
- 动态调整限流参数。
- 尝试不同限流场景。
演示默认 Redis 位于 localhost:6379,也可通过命令行参数 --redis-host HOST 和 --redis-port PORT 指定其他主机及端口。浏览器访问 http://localhost:8080 即可打开界面。
响应头
通常会把限流信息放入 HTTP 响应头:
const { allowed, remaining } = await limiter.allow(`user:${userId}`);
// Add standard rate limit headers
res.set('X-RateLimit-Limit', String(limiter.capacity));
res.set('X-RateLimit-Remaining', String(Math.floor(remaining)));
res.set('X-RateLimit-Reset', String(Math.floor(Date.now() / 1000 + limiter.refillInterval)));
if (!allowed) {
res.set('Retry-After', String(Math.ceil(limiter.refillInterval)));
res.status(429).json({ error: 'Too Many Requests' });
return;
}
自定义集成
封装为 Express 中间件
将限流器封装为 Express 中间件,便于接入应用:
function rateLimitMiddleware(limiter, keyFn) {
return async (req, res, next) => {
const key = keyFn(req);
const { allowed, remaining } = await limiter.allow(key);
res.set('X-RateLimit-Remaining', String(Math.floor(remaining)));
if (!allowed) {
res.status(429).json({ error: 'Rate limit exceeded' });
return;
}
next();
};
}
// Apply per-IP rate limiting
app.use(rateLimitMiddleware(limiter, (req) => `ip:${req.ip}`));
错误处理
Redis 连接断开时,allow() 可能抛出异常。在生产应用中,应使用 try/catch 包裹调用,并按自己的策略决定故障时放行还是拒绝请求:
try {
const { allowed, remaining } = await limiter.allow('user:123');
// Handle result
} catch (err) {
console.error('Rate limiter error:', err);
// Fail open or closed depending on your policy
}
其他限流算法
前面的令牌桶适合大多数使用场景,但 Redis 也可以实现其他更符合特定需求的限流模式。原文对五种算法的特征概括如下:
| 算法 | 内存占用 | 准确性 | 突发行为 | 适用场景 |
|---|---|---|---|---|
| 令牌桶 | 1 个哈希键 | 精确 | 允许受控突发 | 具有突发流量的 API |
| 固定窗口计数器 | 1 个字符串键 | 近似 | 窗口边界可能出现两倍突发 | 简单的 API 限制 |
| 滑动窗口日志 | O(n) 个条目 | 精确 | 无边界突发 | 高价值 API、审计轨迹 |
| 滑动窗口计数器 | 2 个字符串键 | 接近精确 | 平滑窗口边界 | 通用 API |
| 漏桶,监管型 | 1 个哈希键 | 精确 | 原文归类为无突发 | 严格控制突发的场景 |
下面给出其他算法的实现示例。三个依赖当前时间的算法都在 Lua 脚本内部调用 redis.call('TIME'),以 Redis 服务器时钟生成时间戳,避免多个应用服务器之间的时钟偏差。固定窗口计数器不读取时钟,而是利用键的 TTL 定义窗口。
固定窗口计数器
在离散、不重叠的时间区间内统计请求数量。这是最简单的算法:每个窗口一个键,一次 EVAL 往返。
const FIXED_WINDOW_SCRIPT = `
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local count = redis.call('INCR', key)
if count == 1 then
redis.call('EXPIRE', key, window)
end
local ttl = redis.call('PTTL', key)
if count > limit then
return {0, ttl}
end
return {1, ttl}
`;
// Returns { allowed, retryAfterMs }. retryAfterMs is 0 when the request is allowed.
async function fixedWindowAllow(client, key, limit, windowSeconds) {
const [allowed, ttl] = await client.eval(FIXED_WINDOW_SCRIPT, {
keys: [key],
arguments: [String(limit), String(windowSeconds)],
});
return {
allowed: allowed === 1,
retryAfterMs: allowed === 1 ? 0 : Number(ttl),
};
}
取舍:客户端可以在一个窗口末尾发送 limit 个请求,再在下一个窗口开头发送 limit 个请求,从而在边界附近形成两倍请求量。
滑动窗口日志
使用有序集合记录每个请求的精确时间戳,提供真正的滚动窗口,避免窗口边界突发。
const { randomUUID } = require("crypto");
const SLIDING_WINDOW_LOG_SCRIPT = `
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local member = ARGV[3]
local t = redis.call('TIME')
local now = tonumber(t[1]) + tonumber(t[2]) / 1e6
local cutoff = now - window
redis.call('ZREMRANGEBYSCORE', key, '-inf', cutoff)
local count = redis.call('ZCARD', key)
if count < limit then
redis.call('ZADD', key, now, member)
redis.call('EXPIRE', key, window * 2)
return {1, 0}
end
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
local retry_after_ms = 0
if oldest[2] then
retry_after_ms = math.floor((tonumber(oldest[2]) + window - now) * 1000)
end
return {0, retry_after_ms}
`;
async function slidingWindowLogAllow(client, key, limit, windowSeconds) {
const [allowed, retryAfterMs] = await client.eval(SLIDING_WINDOW_LOG_SCRIPT, {
keys: [key],
arguments: [String(limit), String(windowSeconds), randomUUID()],
});
return { allowed: allowed === 1, retryAfterMs: Number(retryAfterMs) };
}
取舍:内存会随请求数量以 O(n) 增长,不适合同时具有高流量和大量独立限流对象的场景。
滑动窗口计数器
对两个固定窗口计数器进行加权平均,近似真正的滑动窗口。它的准确性接近精确算法,同时保持与固定窗口同等级的低内存消耗。两个键使用哈希标签,确保映射到 Redis Cluster 的同一个槽位。
const SLIDING_WINDOW_COUNTER_SCRIPT = `
local base = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local t = redis.call('TIME')
local now = tonumber(t[1]) + tonumber(t[2]) / 1e6
local window_num = math.floor(now / window)
local elapsed = (now % window) / window
local curr_key = base .. ':' .. window_num
local prev_key = base .. ':' .. (window_num - 1)
local prev = tonumber(redis.call('GET', prev_key) or 0)
local curr = tonumber(redis.call('GET', curr_key) or 0)
local estimate = prev * (1 - elapsed) + curr
if estimate >= limit then
return {0, 0}
end
local new_count = redis.call('INCR', curr_key)
if new_count == 1 then
redis.call('EXPIRE', curr_key, window * 2)
end
return {1, 0}
`;
async function slidingWindowCounterAllow(client, key, limit, windowSeconds) {
const [allowed] = await client.eval(SLIDING_WINDOW_COUNTER_SCRIPT, {
keys: [`{${key}}`],
arguments: [String(limit), String(windowSeconds)],
});
return { allowed: allowed === 1 };
}
取舍:加权估计可能比精确限制略多或略少地放行请求,对大多数应用而言,这种误差可以忽略。
漏桶:监管型实现
请求到达时增加虚拟桶的水位,桶按固定速率排空。如果桶已满,立即拒绝请求。这是监管型变体:每个请求立即被允许或拒绝,不会被排队延迟。
const LEAKY_BUCKET_SCRIPT = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local leak_rate = tonumber(ARGV[2])
local t = redis.call('TIME')
local now = tonumber(t[1]) + tonumber(t[2]) / 1e6
local data = redis.call('HGETALL', key)
local level = 0
local last_leak = now
if #data > 0 then
for i = 1, #data, 2 do
if data[i] == 'level' then
level = tonumber(data[i+1])
elseif data[i] == 'last_leak' then
last_leak = tonumber(data[i+1])
end
end
end
local elapsed = now - last_leak
level = math.max(0, level - elapsed * leak_rate)
if level + 1 > capacity then
return {0, math.floor((level + 1 - capacity) / leak_rate * 1000)}
end
level = level + 1
local ttl = math.ceil(capacity / leak_rate) + 1
redis.call('HSET', key, 'level', level, 'last_leak', now)
redis.call('EXPIRE', key, ttl)
return {1, 0}
`;
async function leakyBucketAllow(client, key, capacity, leakRate) {
const [allowed, retryAfterMs] = await client.eval(LEAKY_BUCKET_SCRIPT, {
keys: [key],
arguments: [String(capacity), String(leakRate)],
});
return { allowed: allowed === 1, retryAfterMs: Number(retryAfterMs) };
}
取舍:超出容量的流量会立即被拒绝。客户端需要处理 429 Too Many Requests,并采用退避策略重试。
进一步阅读
原文:Token bucket rate limiter with Redis and Node.js。本文为该文档的中文译文,示例代码保留原文。











暂无评论内容