监控无法察觉的 Node.js Bug
你的健康检查显示一切正常。耗时仅需一毫秒。随后流量增长。突然间,你的 p99 延迟飙升至 400ms。你查看仪表盘,一切看起来都是绿色的。
CPU 使用率适中。事件循环延迟(Event loop lag)平稳。内存状况良好。你的 APM 显示请求缓慢,但没有告诉你原因。没有慢查询数据库调用,也没有报错。
时间都耗费在了 libuv 线程池中。
标准的 Node 可观测性侧重于事件循环。而线程池是一个独立的队列,它处于你的监控范围之外。
Node 在事件循环上运行 JavaScript。它会将繁重任务推送到 libuv 线程池。这包括:
- 文件系统操作 (
fs.readFile,fs.writeFile)。 - 加密任务 (
bcrypt,scrypt,pbkdf2)。 - 压缩 (
zlib gzip,deflate)。 - DNS 查询 (
dns.lookup)。
线程池默认只有四个线程。无论你的机器有多少个 CPU 核心,情况都是如此。
四个线程是不够的。以下是线程池导致故障的三种方式:
登录时的
bcrypt。单个bcrypt哈希可能需要 250ms。如果四个人同时登录,所有插槽都会被占满。第五个人必须在队列中等待。他们仅仅为了开始操作就要付出 250ms 的代价。你的延迟直接翻倍。大型
gzip操作。压缩大型响应会占用一个线程池插槽。如果四个请求同时进行此操作,其他所有任务都必须等待。DNS 查询和文件读取会被卡在队列中。DNS 查询。大多数 Node 应用使用
dns.lookup。它使用阻塞式系统调用,并将任务放入线程池。如果你的网络出现波动,这些查询会拖慢整个线程池。
卡在线程池队列中的请求是不可见的。它不占用 CPU,也不运行 JavaScript。它只是处于“停泊”状态。
如何发现它:
如果你的 p99 延迟在负载增加时上升,但事件循环延迟保持平稳,请检查线程池。
最快的测试方法: 增加 UV_THREADPOOL_SIZE。在你的环境变量中将其设置为 64 并重启。如果延迟下降,你就找到了问题所在。
如何正确修复:
- 使用
worker_threads处理像bcrypt这样的重型加密任务。这可以避免它们占用 libuv 线程池。 - 使用
dns.resolve代替dns.lookup。它是一个真正的异步解析器。 - 对
zlib操作使用 streams,以便更快地释放插槽。 - 避免在主请求路径上进行繁重的文件系统操作。
不要再盯着绿色的仪表盘,而让你的用户在等待。
来源:https://dev.to/r9v/the-nodejs-bug-thats-invisible-to-your-monitoring-oo8
