分布式限流与熔断的核心问题,并不在于单个接口的QPS阈值怎么算,而在于多实例部署时每个Node.js进程都要对同一个资源执行同样的规则。如果把限流计数放在进程内存里,20个实例各允许100 QPS,对外就变成了2000 QPS;如果把熔断状态也放在内存里,A实例已经打开熔断器,B实例仍然会继续向上游发请求。要解决这类一致性问题,Sentinel提供了一套资源与规则分离的模型,而Node.js项目完全可以沿用这套思路,用Redis和Lua脚本在服务端实现分布式限流和熔断降级。

这篇文章围绕资源定义、限流算法、熔断状态机和降级兜底四个层面,拆解落地细节。
一、单机限流为什么在多实例下失效?
在一个Express或Koa服务中,最简单的限流实现是保存一个Map,记录每个资源在当前时间窗口内的请求次数。时间窗口一旦变化,就把计数清零。这种做法的优点是读取和写入都在进程内存中完成,单机性能几乎没有损耗。但问题也很直接:计数器只属于当前进程。负载均衡把请求打散到多个Pod或容器后,每个实例都只看到自己那部分流量,全局限流阈值会被实例数量放大。
更隐蔽的问题在熔断降级。熔断器通常依赖连续失败次数或失败比例来判断是否打开。假如一个部署了10个副本的Node.js服务中,只有3个副本因为网络抖动持续失败,这3个副本会打开熔断并返回兜底数据,而其余7个副本仍然正常请求上游。客户端看到的现象就是时好时坏,降级策略无法稳定生效。因此分布式场景下,限流计数和熔断状态必须下沉到所有实例都能访问的共享存储中。
Redis是这类状态存储的常见选择,但直接用GET、INCR、EXPIRE组合命令存在原子性问题。判断是否超过阈值和累加计数如果拆成两条命令,两个并发请求可能同时通过判断,再各自累加,导致实际通过量大于阈值。借助Redis的Lua脚本可以把判断、删除过期记录、写入新记录一次完成,这正是后面限流实现的基础。
二、Sentinel的规则模型如何映射到Node.js?
Sentinel的模型可以拆成三个概念:资源、规则和统计。资源是字符串,通常表示一个接口或一个方法调用,例如GET:/api/orders;规则描述对这个资源的限制条件,例如QPS阈值、异常比例阈值、慢调用比例阈值;统计则收集每秒请求量、响应时间、异常数等运行时数据。Java版Sentinel通过Slot Chain把这三个概念串起来,请求进入资源时依次经过流量控制、熔断降级、系统保护等逻辑。
Node.js服务虽然不能直接使用Java版Sentinel客户端,但完全可以在应用层复刻这套模型。资源名可以用method:path这样的约定,规则存放在Redis或配置中心,每个Node.js实例启动时拉取一次规则,并通过定时任务或发布订阅机制监听变更。统计部分可以结合Redis的有序集合和时间窗口完成,不必每个请求都访问数据库。
如果企业内部已经有Sentinel控制台,也可以把Node.js服务的规则继续维护在控制台中,然后通过HTTP接口读取规则JSON。不要试图让Node.js进程加入Java版Sentinel集群,除非使用官方提供的多语言支持或Sidecar方案,否则协议和序列化格式并不通用。对大多数项目来说,借鉴Sentinel的规则语义,自己实现轻量客户端会更加可控。
三、基于Redis和Lua的滑动窗口限流
窗口限流最简单的是固定窗口,例如从0毫秒到999毫秒算一个窗口,窗口内最多允许20个请求。固定窗口的问题在于边界突刺:第999毫秒和第1000毫秒可以分别放行20个请求,实际在2毫秒内放行了40个请求。滑动窗口通过保留每个请求的时间戳,计算当前时间往前推一个窗口内的请求数,可以平滑地限制瞬时流量。
在Redis中,滑动窗口可以用ZSET实现。成员是请求的唯一标识,分数是请求时间戳。每次请求到来时,先删除窗口外的成员,然后统计当前窗口内成员数量,如果达到阈值就拒绝,否则加入当前请求。Lua脚本可以保证这些操作在Redis单线程中原子执行,不会因为并发而出现判断与写入之间的竞态。
-- 滑动窗口限流脚本
local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local unique = 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, unique)
redis.call('PEXPIRE', key, window)
return 1
脚本中的ZREMRANGEBYSCORE用于移除过期成员,ZCARD取剩余数量。只有数量小于阈值时才写入当前请求。每个请求需要传入唯一标识,一般可以用时间戳加随机数,也可以使用请求ID。设置过期时间是为了避免某个资源长时间没有请求时Key仍然占用内存。
Node.js侧可以用ioredis加载这段脚本。每次请求调用slidingWindowLimit函数,传入资源Key、窗口长度和阈值。阈值取多少需要根据实际业务压测确定,不要只按接口总QPS拍脑袋,还要考虑Redis自身的网络延迟和脚本执行时间。
const Redis = require('ioredis');
const redis = new Redis({ host: '127.0.0.1', port: 6379 });
async function slidingWindowLimit(key, windowMs, limit) {
const now = Date.now();
const unique = `${now}-${Math.random()}`;
const result = await redis.eval(
`local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local unique = 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, unique)
redis.call('PEXPIRE', key, window)
return 1`,
1,
key,
windowMs,
limit,
now,
unique
);
return result === 1;
}
async function rateLimitMiddleware(req, res, next) {
const allowed = await slidingWindowLimit(
`rate:${req.method}:${req.path}`,
1000,
20
);
if (!allowed) {
res.status(429).json({ code: 429, message: '请求过于频繁' });
return;
}
next();
}
这种限流可以精确到资源级别,也可以按用户ID、IP或租户隔离。例如将Key拼成rate:${apiKey}:${req.path},就能对不同调用方分别限流。但要注意Redis的Key数量会上升,需要合理设置过期时间。
四、熔断状态机与降级兜底
Sentinel的降级规则支持三种策略:平均响应时间、异常比例和异常数。Node.js实现时可以把平均响应时间转化为慢调用比例,把异常比例作为熔断判断条件。核心是维护一个滑动窗口内的调用样本,记录每次成功或失败以及响应时间,当失败率或慢调用率达到阈值时打开熔断器。
熔断器状态机包含三个状态:关闭、打开和半开。关闭状态下请求正常通过,同时记录调用结果;打开状态下直接拒绝请求并执行降级逻辑;打开一段时间后进入半开状态,允许少量探测请求通过。如果探测请求成功率达到预期,就恢复关闭,否则重新打开。
class CircuitBreaker {
constructor(options) {
this.failureThreshold = options.failureThreshold || 0.5;
this.slowCallThreshold = options.slowCallThreshold || 0.5;
this.maxRT = options.maxRT || 800;
this.windowSize = options.windowSize || 10;
this.halfOpenMaxCalls = options.halfOpenMaxCalls || 3;
this.state = 'CLOSED';
this.stats = [];
this.halfOpenCalls = 0;
}
canPass() {
if (this.state === 'OPEN') {
return false;
}
if (this.state === 'HALF_OPEN') {
return this.halfOpenCalls < this.halfOpenMaxCalls;
}
return true;
}
recordSuccess(rt) {
this.record(rt, true);
}
recordFailure(rt) {
this.record(rt, false);
}
record(rt, success) {
if (this.state === 'HALF_OPEN') {
this.halfOpenCalls += 1;
}
this.stats.push({ rt, success, timestamp: Date.now() });
if (this.stats.length > this.windowSize) {
this.stats.shift();
}
this.refreshState();
}
refreshState() {
if (this.stats.length < this.windowSize) {
return;
}
const failures = this.stats.filter(item => !item.success).length;
const slowCalls = this.stats.filter(item => item.rt > this.maxRT).length;
const failureRatio = failures / this.stats.length;
const slowRatio = slowCalls / this.stats.length;
if (this.state === 'CLOSED' && (failureRatio >= this.failureThreshold || slowRatio >= this.slowCallThreshold)) {
this.state = 'OPEN';
this.openedAt = Date.now();
}
}
attemptReset() {
if (this.state === 'OPEN' && Date.now() - this.openedAt > 5000) {
this.state = 'HALF_OPEN';
this.halfOpenCalls = 0;
this.stats = [];
}
}
}
这个简化版熔断器没有把状态写入Redis,单机使用没有问题。分布式场景下,可以在状态变化时向Redis写入带有过期时间的Key,例如breaker:open:${resource},其他实例在canPass阶段先检查Redis中的状态。这样虽然存在秒级延迟,但足以避免多数无效请求打到上游。
降级逻辑要尽量轻量且无副作用。常见兜底包括返回空列表、读取本地缓存、返回默认配置或重试提示。不要把降级处理写成再次调用另一个强依赖服务,否则熔断就没有意义。下面是一个处理请求的示例。
async function handleRequest(req, res) {
const breaker = getBreaker(`${req.method}:${req.path}`);
breaker.attemptReset();
if (!breaker.canPass()) {
return fallback(res, '服务暂不可用,请稍后重试');
}
const start = Date.now();
try {
const data = await upstreamService.fetch(req.query);
const rt = Date.now() - start;
breaker.recordSuccess(rt);
res.json({ code: 200, data });
} catch (err) {
const rt = Date.now() - start;
breaker.recordFailure(rt);
return fallback(res, '上游服务异常,已返回兜底数据');
}
}
function fallback(res, message) {
return res.json({
code: 503,
message,
data: { list: [], total: 0 }
});
}
五、规则热加载与工程化建议
规则写死在代码里只适合小项目。为了达到类似Sentinel控制台实时下发规则的效果,可以把规则维护在Redis Hash或配置中心中。Node.js服务启动时读取一次规则,之后通过setInterval定时拉取,或者订阅Redis的pub/sub通道,在规则变更时主动推送新规则。
如果需要持久化展示监控指标,可以把限流通过量、拒绝量、熔断打开次数、平均响应时间等指标定期写入Redis,再由独立的采集服务汇总。不要在每个请求中直接写监控系统,那样会放大Redis和网络压力。统计指标可以本地聚合,每1秒或5秒批量上报一次。
工程化落地时还应注意几点。第一,资源命名要统一,避免大小写和路径参数差异导致规则失效,例如/api/orders/123应归一为/api/orders/:id。第二,限流阈值不要只设置接口级,还要设置用户级和IP级,防止单个调用方占满配额。第三,熔断超时和半开探测数量需要结合上游服务恢复能力调整,过短会导致请求持续被拒绝,过长会让上游长时间承受压力。第四,Redis本身的可用性也要纳入考虑,如果Redis不可用,限流策略应当选择快速失败还是放行,需要在业务侧明确。
把Sentinel的规则语义和Node.js的轻量生态结合起来,不一定需要引入庞大中间件。用Redis保存共享状态,用Lua脚本保证原子性,用状态机实现熔断降级,已经能够覆盖多数分布式服务的流量治理需求。
Node.js限流熔断Sentinel降级策略修改时间:2026-09-25 17:23:42