跳到主要内容

舱壁模式在 Node.js 里怎么落地:四道隔离与四组实测

· 阅读需 13 分钟

舱壁模式原理极简,属于 “知道了就觉得理所当然” 的知识;但它属于典型的「简单但极易被忽略」的稳定性手段。 就像保险丝,原理一点不复杂,但是不装,电路一过载就烧设备。

很多稳定性模式都是这个味道:思想朴素,价值巨大,难在主动落地,而不是理解。

舱壁(Bulkhead)来自造船:把船体隔成若干水密舱,一个舱进水,船不沉。搬到软件里,它只有一句话——**给资源划边界:让 A 的故障、拥堵、突发,物理上占不到 B 的资源。**但这句话在 Node.js 里的落点和 Java 生态很不一样。Java 谈舱壁,默认是「线程池隔离」(Hystrix 那套);Node 是单线程的,所以除了连接池,你还得隔离「事件循环」本身——一个 JSON.parse 大字符串或一次同步循环,就能把整个进程的所有接口一起拖下水。

这篇把 Node 里的四道舱壁逐个落成代码 + 本机实测。四组实验的效果都很干净(最夸张的一组是 1416 ms → 2.3 ms)

一、先清楚:舱壁不是熔断、不是限流、不是超时​

这四个经常被混为一谈,但职责不同:

模式解决的问题一句话
舱壁 Bulkhead故障/拥堵不扩散划分资源边界(空间维度)
熔断 Circuit Breaker别继续打已经坏掉的下游快速失败,定期探测恢复(时间维度)
限流 Rate Limit进来的量超出处理能力按配额拒掉多余请求(入口维度)
超时/重试 Timeout/Retry单次调用质量别等太久、失败再来一次(单次调用维度)

关键区别:限流是「总量控制」,舱壁是「分组控制」。一个共享的「全局并发 100」,在舱壁视角下是没有隔离的——租户 A 用满 100,租户 B 就是 0。这就是第 4 组实验要证明的事。

二、Node.js 的特殊性:要隔离的不只是连接池​

资源不隔离会怎样隔离手段
socket / 连接池慢依赖占满连接,健康依赖排队每个依赖一个 http.Agent
事件循环(CPU)一个 CPU 密集任务卡住所有接口worker_threads / 子进程
并发位(in-flight 数)排队无限增长,延迟线性爆炸有界队列 + 快速失败
租户/优先级带宽大客户刷爆,小客户饿死每租户独立限流器
内存大对象常驻、缓存无上限独立缓存 + LRU + 上限
数据库连接慢查询占满池,其它业务全挂每业务独立 pool

Java 那边第二行不存在(有线程池天然分流);Node 里它是最要命的一条,也是本文最想讲的一条。

三、四道舱壁与实测​

环境:Node v24.14.1、12 核;下游服务跑在独立子进程里(这一点在第 6 节会解释为什么是必须的);每组数字为本机回环实测,脚本留档在 source/bulkhead-lab/。

3.1 第一道:连接池舱壁(每依赖一个 Agent)​

场景:5 条连接的池子被「慢依赖」占满 1.5 秒,此时打健康依赖。

配置健康依赖的延迟(10 次请求)
A 共享同一个池(无舱壁)p50 1415.6 ms / p95 1416.6 ms
B 每依赖独立池(有舱壁)p50 2.3 ms / p95 2.7 ms

615 倍。 慢依赖把连接占完,健康依赖连发都发不出去——注意它的延迟几乎正好等于慢依赖的 1.5 s,说明它一直在排队,而不是自己慢。

import http from 'node:http';

// ❌ 无舱壁:所有下游共用一个池
const shared = new http.Agent({ keepAlive: true, maxSockets: 5 });

// ✅ 有舱壁:每个依赖自己的池
const agents = {
search: new http.Agent({ keepAlive: true, maxSockets: 5 }),
recommend: new http.Agent({ keepAlive: true, maxSockets: 5 }),
payment: new http.Agent({ keepAlive: true, maxSockets: 3 }),
};
// fetch 之外的裸 http 请求直接传 agent;undici/fetch 用 new undici.Agent({ connections: 5 }) 或独立 dispatcher

要点:maxSockets 就是这条船上的「水密门」。每个下游一个值,别用全局默认。

3.2 第二道:事件循环舱壁(worker_threads)★ Node 独有​

场景:一个 300 ms 的同步 CPU 任务(模拟大 JSON 解析、模板渲染、加解密、同步压缩),同时打「立刻返回」的 /ok。

配置/ok 的延迟(10 次请求)
CPU 在主线程(无舱壁)p50 283.1 ms / p95 283.3 ms
CPU 在 worker 线程(有舱壁)p50 1.3 ms / p95 2.0 ms

一个 300 ms 的同步任务,让「本来 1 ms 就返回」的接口等了 283 ms。 因为 Node 的事件循环是单线程的:忙等期间它既不能收新连接,也不能回已有响应——所有接口一起停摆,跟慢依赖没关系。

import { Worker } from 'node:worker_threads';

// ❌ 主线程里同步跑:整个进程停摆
function bad(heavyJson) {
return JSON.parse(heavyJson); // 大对象解析动辄几十上百毫秒
}

// ✅ 丢给 worker:事件循环空出来继续服务
const pool = new WorkerPool('./cpu-worker.mjs', { size: 4 }); // 池也要有上限
const result = await pool.run({ task: 'parse', payload: heavyJson });

判据:任何超过约 10 ms 的同步计算都该问一句「能不能挪出主线程」。Node 里没有「线程池帮你兜底」这回事。

3.3 第三道:队列舱壁(有界队列 + 快速失败)​

场景:并发位 8,下游每次 100 ms,一次性灌 200 个请求。对比「无界排队」与「队列上限 16」。

无界队列有界队列(maxQueue=16)
完成 / 当场拒绝200 / 024 / 176
整体墙钟2540 ms308 ms
端到端延迟(含排队)p50 1323.8 / p95 2438.5 msp50 205.3 / p95 306.7 ms
其中「在途 HTTP 耗时」p50 101.4 msp50 101.2 ms
队列深度峰值19216
堆增长约 0.1 MB约 0.0 MB

读法:无界情况下,请求本身的 HTTP 耗时只有 101 ms(下游没变慢),但端到端 p95 到了 2.4 秒——多出来的 2.3 秒全是在你自己的队列里等。有界方案把它压到 306 ms,代价是 176 个请求立刻失败。

权衡的实质:把「2.4 秒后失败」换成「立刻失败」。对调用方来说后者好得多——他能马上决定重试或降级,而不是握着一个已经注定超时的请求耗到客户端超时。

一个诚实的更正:我原以为无界排队的主要代价是内存。实测 192 个待处理请求只涨了 0.1 MB——内存根本不是瓶颈(至少在这个载荷下)。真正的代价是延迟和对下游的冲击。

3.4 第四道:租户舱壁(每租户独立限流器)​

场景:限流器并发 4。租户 A 先灌 60 个请求,30 ms 后租户 B 发 5 个,看 B 的待遇。

配置租户 B 的端到端延迟
A 共享限流器(无舱壁)p50 1594.5 ms
B 每租户独立限流器(有舱壁)p50 103.3 ms

15 倍。 共享时 B 排在 A 的 60 个后面;隔离后 B 走自己的 4 条通道,A 再吵也与它无关。

// 每个租户(或每个优先级)一套限流器,而不是全局一个计数
const limiters = new Map();
const limiterFor = (tenant) => {
if (!limiters.has(tenant)) limiters.set(tenant, new Limiter({ concurrency: 4, maxQueue: 16 }));
return limiters.get(tenant);
};
await limiterFor(tenant).run(() => callDownstream());

四、陷阱一:Node 默认不限 socket 数,这是个两难​

实测(Node v24.14.1):

http.globalAgent.maxSockets 默认 = Infinity

所以「连接池被打满」这个经典故障,在 Node 默认配置下不会以你预期的方式出现——默认是完全不限。于是你有两个反向的坑:

  • 不限:可以瞬间开出成千上万条连接 → 打爆下游(或把对端连接数吃满)、自己句柄与内存上涨。这是「没有背压」。
  • 限了但共享:就是 §3.1 的灾难——跨依赖互相拖累。

结论:maxSockets 必须显式设、且按依赖分开设。默认值两头都不对。

五、陷阱二:用「在途耗时」测舱壁,永远测不出排队 ★​

这条是我自己踩的,值得单独讲,因为它会让你得出完全错误的结论。

第一版的实验 4,我这样统计延迟:

limiter.run(() => call(...)).then((r) => r.ms) // ❌ r.ms 是「已开始执行到结束」的耗时

call() 内部从发出请求开始计时,所以排队等待的时间完全没被计入。结果第一版数据是这样的:

A 共享限流器(无舱壁) 租户 B 的 5 次请求:p50 101.1 ms
B 每租户独立限流器(有舱壁) 租户 B 的 5 次请求:p50 100.5 ms ← 一模一样!

数字告诉我「隔离没有收益」——而真实情况是 B 等了 1.6 秒。修正成「提交 → 完成的端到端计时」后:

A 共享限流器(无舱壁) 租户 B 的 5 次请求端到端:p50 1594.5 ms
B 每租户独立限流器(有舱壁) 租户 B 的 5 次请求端到端:p50 103.3 ms

教训:舱壁的效果全部体现在排队上,而排队恰恰是最容易被计时器漏掉的部分。 §3.3 那张表里我特意把「在途 101 ms」和「端到端 2438 ms」并排放——两列差出来的部分,就是舱壁在管的东西。

工程含义:把「排队时长」和「队列深度」当成一等指标。只监控「下游响应时间」的团队,看不到自己在队列里烂掉的那 2.3 秒。

六、陷阱三:服务端和测量端放在同一个进程 ★​

第二版数据我又错了一次,错法更隐蔽。

实验 2 第一版里,CPU 忙等 300 ms,而探针 /ok 的延迟只有 7.6 ms——看起来「CPU 根本没影响」,结论会和标题完全相反。

原因:下游服务和测量客户端跑在同一个 Node 进程里。忙等阻塞的是整个进程,所以:

  1. 客户端 await sleep(20) 的定时器回调也一起被冻结;
  2. 那些探针请求直到忙等结束后才被发出去(日志显示:t=311ms 忙等结束,t=319ms 服务端才第一次收到 /ok);
  3. 于是探针测到的只是「发出→响应」的 5~7 ms,而真正的阻塞被测量代码自己吞掉了。

修正方式:把下游服务 fork 成独立子进程,本进程只当客户端。改完后:

CPU 在主线程(无舱壁) /ok ×10:p50 283.1 ms ← 这才对,约等于忙等时长
CPU 在 worker 线程(有舱壁) /ok ×10:p50 1.3 ms

教训:测量者不能和被测量者共享同一个会被阻塞的资源。 想在单进程里测事件循环阻塞,你至少得把探针放到 worker_threads 里。

七、落地清单​

□ 每个下游一个 Agent:maxSockets 显式设定,不用 globalAgent 默认(Infinity)
□ 每个下游一个并发限流器:concurrency + ★maxQueue 都要设,满了立刻失败(503/429)
□ 超过 ~10ms 的同步计算挪出主线程:worker_threads(worker 池也要有上限)
□ 每个租户/优先级一套限流器,不要一个全局计数器
□ 数据库:每业务独立连接池,别共用一个 pool
□ 观测三类指标:队列深度峰值、排队时长(端到端 − 在途)、快速失败率
□ 计时一律用「提交 → 完成」的端到端口径,否则排队不可见
□ 压测时把被测服务与压测客户端分到不同进程/机器
□ 舱壁要配超时与熔断兜底:舱壁防扩散,熔断防持续打坏下游,两者互补

舱壁的代价(别只讲好处):资源利用率会下降——每个舱都要留余量,总容量必然大于实际峰值需求;配置项变多(每个依赖一组数字);舱壁太小等于假隔离(并发位只有 1 的「舱壁」就是个串行队列)。所以要按依赖的关键性和容量分配,而不是无脑一人一份。

八、本文局限​

  • 全部为本机回环、单机、Node 24.14.1 实测;数字只说明机制与量级关系,不代表生产绝对值;
  • 下游是合成服务(固定 100 ms / 1000 ms 延迟、同步忙等),不是真实业务;
  • 限流器是我写的教学实现(约 20 行),生产建议用成熟库(p-limit、bottleneck、undici 的 connections 等);
  • 未涉及多进程/多实例下的全局配额问题:本文的舱壁都是单进程内的;跨实例限流需要 Redis 之类的共享计数器,那是另一个话题;
  • 文中两次测量失误都是真实的(见 §5、§6),修正后的数据已重跑。

关联​

自测​

  1. 舱壁与限流、熔断、超时的分工分别是什么?为什么「全局并发 100」不等于有隔离?
  2. 为什么 Node 里除了连接池,还必须隔离事件循环?Java 那边为什么没这个问题?
  3. 实测里「共享池」与「每依赖独立池」差了多少倍?慢依赖的延迟为什么几乎等于健康依赖的等待时间?
  4. 为什么用「在途耗时」测量舱壁会得出错误结论?该怎么测?
  5. 为什么测事件循环阻塞时,压测客户端不能和被测服务同进程?
  6. 无界排队的主要代价是什么?实测里内存涨了多少(这说明了什么)?
  7. 舱壁的代价是什么?什么情况下「加了舱壁」反而更糟?

参考​

  • 实测脚本(随仓库留档,零第三方依赖):source/bulkhead-lab/——harness.mjs(四组实验)、origin-server.mjs(独立进程的下游)、cpu-worker.mjs、harness.log(真实输出);跑法与测量陷阱见其中 README.md
  • 环境:Node v24.14.1(12 核)/ macOS(Darwin 24.6.0)
  • Node 文档 http.Agent(maxSockets 语义与默认值):nodejs.org/api/http.html#new-agentoptions
  • Node 文档 worker_threads:nodejs.org/api/worker_threads.html