舱壁模式在 Node.js 里怎么落地:四道隔离与四组实测
舱壁模式原理极简,属于 “知道了就觉得理所当然” 的知识;但它属于典型的「简单但极易被忽略」的稳定性手段。 就像保险丝,原理一点不复杂,但是不装,电路一过载就烧设备。
很多稳定性模式都是这个味道:思想朴素,价值巨大,难在主动落地,而不是理解。
舱壁(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 / 0 | 24 / 176 |
| 整体墙钟 | 2540 ms | 308 ms |
| 端到端延迟(含排队) | p50 1323.8 / p95 2438.5 ms | p50 205.3 / p95 306.7 ms |
| 其中「在途 HTTP 耗时」 | p50 101.4 ms | p50 101.2 ms |
| 队列深度峰值 | 192 | 16 |
| 堆增长 | 约 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 进程里。忙等阻塞的是整个进程,所以:
- 客户端
await sleep(20)的定时器回调也一起被冻结; - 那些探针请求直到忙等结束后才被发出去(日志显示:
t=311ms 忙等结束,t=319ms 服务端才第一次收到 /ok); - 于是探针测到的只是「发出→响应」的 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),修正后的数据已重跑。
关联
- 并发与流式传输的边界:Agent 流式传输选型(长连接同样是一种要划边界的资源)
- 检索侧的资源隔离与限流:Milvus 企业级开发运维方案(Resource Group 就是数据库层的舱壁)
- 用「以落盘为准」替代「相信模型」的工程约束:InkOS 为什么不用 LangGraph
自测
- 舱壁与限流、熔断、超时的分工分别是什么?为什么「全局并发 100」不等于有隔离?
- 为什么 Node 里除了连接池,还必须隔离事件循环?Java 那边为什么没这个问题?
- 实测里「共享池」与「每依赖独立池」差了多少倍?慢依赖的延迟为什么几乎等于健康依赖的等待时间?
- 为什么用「在途耗时」测量舱壁会得出错误结论?该怎么测?
- 为什么测事件循环阻塞时,压测客户端不能和被测服务同进程?
- 无界排队的主要代价是什么?实测里内存涨了多少(这说明了什么)?
- 舱壁的代价是什么?什么情况下「加了舱壁」反而更糟?
参考
- 实测脚本(随仓库留档,零第三方依赖):
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
