API接口暴露在公网后,如果不做任何防护,单个客户端可能在短时间内发起大量请求,耗尽数据库连接、CPU或内存资源。常见的固定窗口计数法虽然实现简单,却存在窗口边界瞬间放行双倍流量的缺陷。令牌桶算法通过持续匀速补充令牌的方式,在限制平均速率的同时允许一定程度的突发流量,非常适合需要兼顾稳定性和用户体验的场景。下面从算法原理到Express中间件实现,逐步构建可用的请求频率限制方案。

一、令牌桶算法的工作机制
令牌桶可以理解为一个容量有限的容器,系统以固定速率向桶中放入令牌,例如每秒放入10个。当桶已经装满时,新产生的令牌会被直接丢弃。每个到达的请求必须先从桶中取走一个令牌,只有成功取到令牌的请求才被放行,否则返回限流响应。桶的容量决定了系统能够承受的最大突发流量。如果桶容量设为20,那么在令牌积满的情况下,即使瞬时涌入20个请求也能全部通过,但之后就必须等待令牌按速率重新生成。
与漏桶算法不同,漏桶强制请求以恒定速率流出,超出部分要么排队要么丢弃,对突发流量非常不友好。令牌桶则把突发控制交给桶容量:只要桶里有存货,请求就能立即通过。因此令牌桶更适合API网关这类需要响应式放行业务突刺的场景。对比固定窗口算法,令牌桶的状态随时间连续变化,不存在人为划分的时间窗口,从根本上消除了窗口切换导致的边界突刺问题。
二、用Express中间件实现内存版令牌桶
在单实例Node.js服务中,可以直接把桶状态保存在内存里。利用闭包保存令牌数量和上次补充时间,每次请求到来时先根据经过的时间计算应当补充的令牌数,再判断是否足够消耗一个令牌。由于Node.js事件循环单线程执行JavaScript代码,只要不在中间件中使用异步等待,就不会出现多个请求同时修改令牌数量的竞态问题。
下面给出一个完整的Express中间件工厂函数。参数capacity表示桶的最大容量,refillRate表示每秒补充的令牌数量。当请求到达时,先补充令牌,再扣减;如果令牌不足则返回429状态码。
const tokenBucket = (capacity, refillRate) => {
let tokens = capacity;
let lastRefill = Date.now();
return (req, res, next) => {
const now = Date.now();
const elapsedSeconds = (now - lastRefill) / 1000;
tokens = Math.min(capacity, tokens + elapsedSeconds * refillRate);
lastRefill = now;
if (tokens >= 1) {
tokens -= 1;
next();
} else {
res.status(429).json({ message: 'Too Many Requests' });
}
};
};
注意这里的扣减操作并不是严格原子化的,因为在单线程环境下,从判断tokens >= 1到执行tokens -= 1之间没有其他代码能够插入运行,因此不会出现超发。一旦服务部署到多个进程或容器实例,各实例内存相互隔离,同一个客户端可能被不同实例分别放行,限流效果就会失效。此时必须引入集中式存储来统一维护令牌桶状态。
使用方法也很简单,可以把中间件挂载到需要保护的路由前缀上。例如只想限制/api下的接口,可以这样写:
const express = require('express');
const app = express();
const apiLimiter = tokenBucket(20, 10);
app.use('/api', apiLimiter);
app.get('/api/data', (req, res) => {
res.json({ ok: true });
});
app.listen(3000);
这段代码表示/api路径下的请求共享一个容量为20、每秒补充10个令牌的桶。正常情况下每秒钟最多平均通过10个请求,但短时间内可以突发通过最多20个请求,之后需要等待令牌慢慢恢复。
三、基于Redis实现分布式限流
当Node.js服务以多进程或多容器方式部署时,内存版令牌桶无法共享状态。Redis作为高性能键值存储,天然适合存放限流计数。但简单的读取、计算、写回操作在Redis上并不是原子的,多个请求可能读到相同的旧令牌数,造成超发。解决方案是把整个补充和扣减逻辑写进一段Lua脚本,由Redis单线程执行脚本,保证操作原子性。
下面是一段Lua脚本,接收桶的键名、容量、补充速率、当前时间和请求令牌数作为参数。它先从Redis的哈希结构中读取上次令牌数和刷新时间,没有则初始化。接着计算应补充的令牌数,再判断是否足够。如果足够则扣减并写回,同时设置键的过期时间,防止长期不活跃的桶占用内存。
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = 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])
local last_refill = tonumber(bucket[2])
if tokens == nil then
tokens = capacity
last_refill = now
end
local elapsed = (now - last_refill) / 1000
tokens = math.min(capacity, tokens + elapsed * refill_rate)
if tokens >= requested then
tokens = tokens - requested
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, 60)
return 1
else
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, 60)
return 0
end
在Node.js中通过Redis客户端执行这段脚本。推荐使用redis官方库的eval方法,传入脚本内容、键数量、键名以及各个参数。中间件需要根据脚本返回值决定是否放行。由于eval是异步操作,需要把中间件写成async函数,并在内部使用await等待Redis响应。
const redis = require('redis');
const client = redis.createClient();
client.on('error', (err) => console.error('Redis Client Error', err));
client.connect();
const luaScript = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = 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])
local last_refill = tonumber(bucket[2])
if tokens == nil then
tokens = capacity
last_refill = now
end
local elapsed = (now - last_refill) / 1000
tokens = math.min(capacity, tokens + elapsed * refill_rate)
if tokens >= requested then
tokens = tokens - requested
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, 60)
return 1
else
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, 60)
return 0
end
`;
const redisTokenBucket = (capacity, refillRate) => {
return async (req, res, next) => {
const now = Date.now();
const key = `rate_limit:${req.ip}`;
try {
const result = await client.eval(luaScript, {
keys: [key],
arguments: [String(capacity), String(refillRate), String(now), '1']
});
if (result === 1) {
next();
} else {
res.status(429).json({ message: 'Too Many Requests' });
}
} catch (err) {
next(err);
}
};
};
Redis的eval方法在不同版本客户端中参数形式略有差异。上面示例基于redis库4.x版本,使用keys和arguments对象传递参数。如果使用的是3.x版本,则直接按顺序传入即可。无论哪个版本,都要确保Lua脚本中return的值与JavaScript中的判断逻辑一致。通常用1表示放行,0表示拒绝。
分布式限流会给每个请求增加一次网络往返,因此对延迟敏感的服务需要评估Redis的性能和可用性。如果Redis出现故障,中间件可以选择降级放行或者直接拒绝,具体取决于业务对可用性和安全性的权衡。建议在Redis连接异常时记录日志,并根据服务等级决定是否启用快速失败。
四、参数调优与常见误区
桶容量capacity和补充速率refillRate是两个需要根据业务特点设定的核心参数。补充速率应当略高于接口正常情况下的平均每秒请求数,避免误伤正常用户。桶容量则决定允许的突发规模,如果客户端存在批量拉取数据的场景,容量可以适当调大;如果对突发流量非常敏感,容量可以设得接近速率值,让限流行为更接近漏桶。
一个常见的误区是认为桶容量越大越好,但其实容量过大意味着限流几乎失效,瞬时突发可能压垮下游服务。另一个需要注意的问题是时间精度。如果使用秒作为时间单位,令牌补充的粒度会比较粗,短时间内的请求可能出现偶发拒绝,但对于常规API限流已经足够。需要更精细控制时,可以把速率换算成毫秒级别,但Lua脚本中要注意浮点运算的精度。
令牌桶算法并不能解决所有限流需求,例如需要严格平滑输出时漏桶更合适,需要精确统计周期内调用次数时固定窗口计数更直观。但对于大多数Web API的速率限制,令牌桶在实现复杂度、突发容忍和限制效果之间取得了很好的平衡。通过Express中间件封装后,业务代码无需关心限流细节,只需在路由上挂载对应策略即可。
最后要注意,限流标识通常使用客户端IP、用户ID或API Key。在生产环境中,如果服务前面存在反向代理,直接读取req.ip可能拿到的是代理地址,需要正确设置trust proxy选项,否则所有请求会共享同一个桶,造成严重误限。