流量控制是后端服务稳定性的生命线。一台配置再高的服务器,面对突发流量也扛不住无限制的请求冲击。限流的核心思想很简单:在单位时间内只允许一定数量的请求通过,多余的请求直接拒绝或排队。但传统的固定阈值限流有个明显的短板——阈值设高了保护不到位,设低了浪费资源。这篇文章就来聊聊在Node.js中如何把限流做得更智能、更动态,让系统能根据实时流量自动调整策略。

一、限流算法基础:从计数器到令牌桶
在动手写代码之前,先弄清楚几种主流限流算法的原理和差异,这对后续选择方案非常关键。
最简单的是固定窗口计数器:把时间切成固定区间,比如每分钟一个窗口,窗口内计数达到上限就拒绝。它实现起来最简单,但有个经典缺陷——临界问题。假设每分钟限流100次,在第59秒来了100个请求,第61秒又来了100个请求,两个窗口各自都没超限,但在短短两秒内实际通过了200个请求,这足以对下游造成冲击。
滑动窗口对此做了改进,它不再以固定时刻切分窗口,而是统计最近N秒内的请求数。可以用Redis的有序集合(ZSET)实现,每个请求记录一个时间戳,查询时只统计时间范围内的记录。滑动窗口解决了临界突刺问题,但内存开销会随请求量增长。
令牌桶算法是目前应用最广泛的方案。它的思路是系统以固定速率往桶里放令牌,桶有容量上限,满了就不再放。每个请求需要先从桶里取一个令牌,取不到就被拒绝。令牌桶的优势在于允许一定程度的突发流量——桶里积攒的令牌可以应对瞬时高峰,同时长期平均速率又是受控的。这个特性对API服务特别友好,下面我们的实现就以令牌桶为基础。
二、用Node.js实现一个动态令牌桶
先在单机环境下实现一个支持动态调整参数的令牌桶。与普通令牌桶不同,我们把生成速率和桶容量设计成可随时修改的属性,为后续的自动调节留好接口。
class DynamicTokenBucket {
constructor(options = {}) {
// 每秒生成的令牌数,默认100
this.rate = options.rate || 100;
// 桶的最大容量,默认200
this.capacity = options.capacity || 200;
this.tokens = this.capacity;
this.lastTime = Date.now();
}
// 动态调整限流参数
tune({ rate, capacity }) {
if (rate !== undefined) this.rate = rate;
if (capacity !== undefined) {
this.capacity = capacity;
if (this.tokens > capacity) this.tokens = capacity;
}
}
// 尝试获取令牌,返回true表示放行
tryAcquire(count = 1) {
const now = Date.now();
// 根据流逝的时间补充令牌
const elapsed = (now - this.lastTime) / 1000;
this.tokens = Math.min(
this.capacity,
this.tokens + elapsed * this.rate
);
this.lastTime = now;
if (this.tokens >= count) {
this.tokens -= count;
return true;
}
return false;
}
}
// Express中间件封装
const express = require('express');
const app = express();
const bucket = new DynamicTokenBucket({ rate: 500, capacity: 1000 });
app.use('/api', (req, res, next) => {
if (bucket.tryAcquire()) {
next();
} else {
res.status(429).json({ error: '请求过于频繁,请稍后重试' });
}
});
app.listen(3000, () => console.log('服务已启动'));这段代码的关键在于tryAcquire方法:它并不依赖定时器去补充令牌,而是在每次请求到达时根据时间差惰性计算应补充的令牌数。这种写法避免了定时器带来的额外开销,也让算法在低流量时依然精确。
注意tune方法,它可以在运行时改变速率和容量。这就是“动态”限流的核心:我们可以根据CPU使用率、事件循环延迟、下游响应时间等指标,定期调用tune来收紧或放宽限流。返回429状态码是HTTP语义上的标准做法,客户端收到后应执行退避重试策略。
三、基于Redis的分布式限流方案
单机限流在生产环境往往不够用。假设服务部署了8个实例,每个实例限流100 QPS,总流量上限是800 QPS,但如果负载均衡不均匀,某些实例可能提前触顶,另一些却闲置。更重要的是,单机桶无法感知全局流量。解决思路是把令牌桶的状态集中存到Redis,用Lua脚本保证操作的原子性。
const redis = require('redis');
const client = redis.createClient({ url: 'redis://127.0.0.1:6379' });
const LUA_SCRIPT = `
local key = KEYS[1]
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local data = redis.call('HMGET', key, 'tokens', 'last_time')
local tokens = tonumber(data[1]) or capacity
local last_time = tonumber(data[2]) or now
local elapsed = math.max(0, now - last_time)
tokens = math.min(capacity, tokens + elapsed * rate / 1000)
local allowed = 0
if tokens >= requested then
tokens = tokens - requested
allowed = 1
end
redis.call('HMSET', key, 'tokens', tokens, 'last_time', now)
redis.call('PEXPIRE', key, 60000)
return allowed
`;
async function acquireToken(key, rate, capacity) {
const allowed = await client.eval(
LUA_SCRIPT, 1, key,
rate, capacity, Date.now(), 1
);
return allowed === 1;
}Lua脚本在Redis中是原子执行的,读取令牌数、补充令牌、扣减令牌这一串操作不会被打断,因此不存在并发竞态问题。每次请求都要访问一次Redis,这会带来约1毫秒级的网络开销,如果QPS非常高,可以考虑本地缓存一部分令牌再定期与Redis同步的混合方案。
这套方案还有个好处:限流参数存在脚本调用参数里,修改参数不需要重启服务。配合一个配置中心或者Redis中的配置键,就能实现全集群统一的动态调整。此外建议给key设置过期时间,避免长期不用的API残留数据占用内存。
四、让限流阈值自动适应系统负载
有了可动态调参的限流器,最后一步是建立“感知—决策—调整”的闭环。思路是周期性采集系统指标,根据指标状态自动调用tune方法调整阈值。
Node.js有个天然的负载信号——事件循环延迟。当事件循环被阻塞时,说明服务器已经过载,此时应该收紧限流;反之则可以逐步放宽,试探系统的真实容量上限。这种方案本质上是一种反馈控制,类似TCP拥塞控制的AIMD思想(加性增、乘性减)。
const { monitorEventLoopDelay } = require('perf_hooks');
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
let currentRate = 500;
function autoTune(bucket) {
setInterval(() => {
// 取事件循环平均延迟,单位纳秒
const avgDelayMs = h.mean / 1e6;
h.reset();
if (avgDelayMs > 50) {
// 过载:速率减半,快速降压
currentRate = Math.max(50, Math.floor(currentRate / 2));
console.warn('过载,限流速率降至', currentRate);
} else if (avgDelayMs < 15) {
// 空闲:线性增速,温和试探
currentRate = Math.min(2000, currentRate + 50);
}
bucket.tune({ rate: currentRate, capacity: currentRate * 2 });
}, 5000);
}
autoTune(bucket);除了事件循环延迟,还可以把进程内存占用、下游接口的P99响应时间、错误率纳入判断条件。比如下游数据库响应突然从50毫秒恶化到500毫秒,即使本机很空闲,也应该主动收紧上游流量,这就是所谓的联动降级。多指标融合时建议采用加权评分或者简单的优先级判断:任何一个核心指标恶化就收紧,全部健康才逐步放宽。
调整的幅度和频率需要谨慎设计。收紧要快,避免系统被压垮;放宽要慢,防止流量振荡。上面代码里500毫秒的采样间隔和5秒的调整周期是比较稳妥的起点,实际项目中可以结合压测数据微调。
五、方案对比与落地建议
三种实现方式各有适用场景:单机令牌桶实现简单、零外部依赖,适合中小型单体服务;Redis分布式方案适合多实例部署的微服务,能实现全局限流和按用户、按API维度的精细化控制;自适应方案则在前两者基础上叠加了反馈控制,适合流量波动大、难以提前预估容量的业务。
落地时有几点经验值得参考。第一,限流维度要合理设计,常见的有全局、按API路径、按用户ID、按IP,不同维度用不同的key隔离。第二,被拒绝的请求要返回标准的429状态码并带上Retry-After响应头,给客户端明确的退避指引。第三,限流指标要接入监控,包括当前通过的QPS、拒绝数、令牌余量,一旦自动调整策略出现异常,运维能第一时间发现。第四,动态调整要有兜底下限,无论系统指标多差,都不要把速率降到零以下,同时保留人工干预开关,避免自动化策略在指标异常时误判。
限流不是孤立的手段,它通常与熔断、降级、排队配合使用。当限流持续触发时触发告警,当拒绝率超过阈值时主动降级非核心功能,才能构成完整的流量治理体系。把这些环节串起来,你的API服务就具备了应对流量洪峰的自我保护能力。