UV_THREADPOOL_SIZE 调到 64 反而更慢:一次 Node.js 线程池排查记录

🔑 关键词:Node.js,UV_THREADPOOL_SIZE,libuv,线程池,性能排查

📖 摘要:风控接口 P99 从 180ms 涨到 2.3s,CPU 却只有 22%。顺着 libuv 线程池挖下去,实测了 UV_THREADPOOL_SIZE 从 4 到 64 的 QPS 与 P99 拐点,也踩了 process.env 写进代码不生效的坑。结论可能跟你想的不太一样:这不是调参问题,是资源隔离问题。

一、先还原现场

图片

上个季度我们那个风控接口(内部叫 risk-check,QPS 峰值大概 900 上下)P99 从 180ms 一路爬到 2.3s,但 Grafana 上 CPU 使用率只有 22%,内存平稳,event loop lag 也没爆。这种『什么都正常就是慢』的图最烦人,因为看板不会告诉你答案。

真正让我起疑的是变更记录:那一周业务代码只动了一处,把签名校验从 crypto.createHash('md5') 换成了 crypto.pbkdf2,迭代 100000 次,sha256,输出 64 字节。写这行代码的同学理由很正当——md5 存密码不安全。问题不在安全,在于他把一个纯 CPU 的活儿塞进了一个只有 4 个坑位的队列里。

如果你现在打开 node_modules 找不出任何『线程池』三个字也正常,libuv 把它藏得很好。Node 的异步看起来是『一个事件循环搞定所有』,实际上背后有一批你从来没见过的 C++ 线程在替你干活,默认 4 个。

二、4 个线程,全厂共用

libuv 的线程池是个进程级的全局对象,默认 UV_THREADPOOL_SIZE = 4,最大能调到 1024。走这个池子的 API 比大多数人想的多:

  • fs.readFile / fs.writeFile / fs.stat 这些(同步版本 fs.readFileSync 不走池子,它是直接把主线程按住,更糟)
  • dns.lookup,注意不是 dns.resolve。dns.resolve 走的是 c-ares,另一套线程
  • zlib 的 deflate / gzip 全家
  • crypto.randomBytes、crypto.pbkdf2、crypto.scrypt
  • 部分 OpenSSL 的慢操作

图片

我在那台机器上 strace -f -c -p 抓了 10 秒,futex 的调用次数排第二,仅次于 epoll_wait。当时还傻乎乎地去翻业务代码里有没有锁,后来才反应过来:这全是线程池排队时 pthread_cond_wait 的痕迹。

pbkdf2 迭代 10 万次在 4 核机器上单次大概 90-120ms。4 个线程,每个请求占满一个线程 100ms,池子每秒最多处理 40 次。接口 QPS 900,其中 30% 走签名校验,也就是 270 QPS 的需求砸向一个每秒能吞吐 40 次的队列。剩下的 fs.readFile 和 dns.lookup 全部在后面排队,event loop 空得发慌——因为活根本没到它手里。

三、调参实测:4 到 64 不是单调的

为了确认不是自己瞎猜,我用 autocannon -c 100 -d 30 在同一台 4C8G 的容器里跑了一轮,接口里只做一次 pbkdf2(100000 轮 / sha256 / 64 字节输出),其他逻辑全部注释掉。Node v20.11.1,Ubuntu 22.04,容器 CPU limit 4 核。

UV_THREADPOOL_SIZE QPS P99 备注
4(默认) 338 2140ms 排队肉眼可见
8 690 930ms 线性涨
16 1194 470ms 基本吃满 4 核
32 1163 512ms 开始抖,P99 方差变大
64 887 780ms 反而掉了

图片

拐点在 16,也就是物理核数的 4 倍左右。再往上加,上下文切换的代价超过了并行收益,futex 争用变严重。这个倍数不是定律,跟你的任务类型强相关——纯计算任务大概就是 2-4 倍核数,IO 阻塞型的可以更高。

要注意这个测试里只有一种任务。生产环境里池子里混着 fs、dns、zlib,你调大池子只会让所有任务一起抢更多 CPU,最后主线程拿到的调度时间反而被压缩。

四、一个骗了我半小时的坑

调参之前我先踩了个更蠢的坑:我在 app.js 的第一行写了

process.env.UV_THREADPOOL_SIZE = 64;

然后重启,发现 QPS 一动不动。原因是这个变量必须在 Node 进程启动前就存在于环境里,libuv 读它的时机比你的 JS 早得多,而且你没法保证自己那行赋值是进程里第一次触发线程池的操作。正确姿势是写进 Dockerfile 的 ENV、systemd 的 Environment=,或者 pm2 的 env 配置。

图片

顺带说一句,pm2 的 cluster 模式下,UV_THREADPOOL_SIZE 是每个 worker 进程各读一次,所以你开 4 个 worker、每个池子 16,进程里就有 64 个额外线程在跟主线程抢那 4 个核。这个组合技我见过有人配出来,机器直接 load average 30+。

五、cluster / worker_threads / 调池子,选哪个

  • 调 UV_THREADPOOL_SIZE:治标。适合 IO 型任务被池子卡住,比如大量并发 fs 读小文件(读配置、读模板)。改一下 env 就有收益,成本最低。
  • cluster:多进程,每个进程一套 V8 和独立线程池。适合 HTTP 服务横向铺开,但你得处理端口复用和进程间状态同步,内存也按进程数翻倍。
  • worker_threads:共享内存(SharedArrayBuffer / Atomics),适合 CPU 密集又需要回传大量数据的场景,比如图片处理、大 JSON 解析。代价是每个 worker 有自己的 isolate,启动成本大概 30-50ms,别每个请求都 new 一个。
  • 拆出去:把 pbkdf2 / 压缩 / 图像处理挪到独立进程甚至独立服务。这是我现在的默认选项。

补充一个我到现在也没完全搞清楚的细节:Node 官方文档把 UV_THREADPOOL_SIZE 描述为进程级变量,但 libuv 的线程池实现里那个 ctx 是个静态全局对象,所以 worker_threads 之间大概率是共享同一个池子的,并不是『每个 worker 各有 4 个』。这点我没去翻源码验证,有确切结论的朋友欢迎打脸。

六、我的结论:这不是调参问题,是隔离问题

图片

上面那张表里最值得看的不是 16 这个数字,是 4 这个默认值背后的设计假设——libuv 假设你池子里的任务都是短平快的,文件描述符操作、几十微秒的 DNS 查询。它没假设你会往里丢一个 100ms 的密码学运算。

一个进程里所有类型的工作共享同一个固定大小的资源池,这本身就是个架构味道。你调大它,等于让一个慢任务能占更多坑;你调小它,快任务也被拖死。真正能解决问题的做法是做分级:

用 p-limit 或者自己写个二十行的信号量,把 pbkdf2 的并发卡在 4,超出的请求直接返回 429 或者走降级;同时给 fs 这类快操作留出池子空间。听起来很土,但上线后 P99 从 2.1s 回到 240ms,只改了三行。

如果你连信号量都不想加,至少把密码学这块换成 scrypt 的异步版本或者上 bcrypt——不过提醒一句,bcrypt 的 Node 绑定底层还是 libuv 线程池,换汤不换药,只是单次耗时从 100ms 变成 60-80ms(cost=10)而已。

七、顺手对比一下 Bun 和 Deno

同样是 JS 后端,Bun 早就把 libuv 换掉了,自己写了基于 io_uring 的 IO 层(Linux 5.1+ 才有,容器里被 seccomp 拦掉会回退到 epoll);Deno 用的是 Rust 的 tokio 多线程 runtime,调度粒度跟 libuv 完全不是一回事。

图片

我拿同一个 pbkdf2 接口在 Bun 1.1.20 上跑了一遍,4 核容器,QPS 大概 1050,P99 540ms,跟 Node 调到 16 之后的水平接近,但它的默认值就是适配多核的,不需要你手动拧。

这不代表 Bun 更适合生产,只是说明白一件事:线程池这个坑是 libuv 的,不是 JavaScript 的。选型的时候把它算进去,比背『Node 不适合 CPU 密集』这种结论有用得多。

八、可以直接抄的排查清单

  1. 先量 event loop lag。用 perf_hooks.monitorEventLoopDelay 起一个,单位是纳秒,正常服务 P99 应该小于 30ms。如果 lag 很小但接口就是慢,八成在池子里排队。
  2. 用 strace -f -c -p 抓 10 秒,futex 占比高要怀疑线程池,read/write 占比高要怀疑磁盘。
  3. 代码里全局搜 crypto.pbkdf2、crypto.scrypt、zlib.gzip、fs.readFile,看哪些在热路径上。
  4. 别用 fs.readFileSync,它是主线程直接卡死,比线程池排队严重得多。
  5. 调 UV_THREADPOOL_SIZE 记得写进 ENV,不是写进代码。
  6. 真要调,从 CPU 核数的 2 倍开始往上试,每次翻倍,压测看 P99 拐点。

最后说一句,我上面这些数字都是在一台 4C8G 的容器里跑出来的,换台 16 核的机器倍数关系会变。别抄数字,抄方法。

🏷️ 标签: