ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Node.js单线程为何能支撑高并发?事件循环与非阻塞I/O深度解析

Node.js单线程为何能支撑高并发?事件循环与非阻塞I/O深度解析 第一次接触 Node.js 的后端开发基本都会被一个问题卡住Node 是单线程的凭什么还敢说自己能支撑高并发我当年从 Java 转过来的时候心里也犯过嘀咕。在 Java 的世界里处理大量请求几乎是“线程池 连接池”的标配思维一个请求一个线程Thread Per Request 模式几乎成了后端高并发的默认答案。突然跑来一个单线程模型还宣称能够处理大量并发请求这完全违背直觉。但跑过一段时间的 Node 服务之后你会发现这个“违背直觉”的结论其实是成立的。问题不在于 Node 是否真的单线程而在于我们对“高并发”的理解可能从一开始就跑偏了。高并发场景下真正让服务器忙不过来的往往不是 CPU 计算而是海量的 I/O 等待——数据库查询要等、外部 API 要等、文件读写要等。Node 的精明之处在于它不跟这些“等待”硬碰硬而是换了一套调度逻辑让一个主线程把所有等待时间利用到极致。这篇文章我会把 Node 事件驱动、非阻塞 I/O 的底层原理掰开揉碎用一次高并发请求的完整生命周期推演讲清楚它为什么能不用多线程就扛住大量请求。同时也会把哪些场景会把 Node 卡死、卡死之后怎么救以及多个核心 CPU 该怎么用起来这些问题一并说透。这篇内容适合刚接触 Node 的初学者也适合已经写了几年 Node 但没认真研究过事件循环的资深开发者。1. 反直觉的真相Node 单线程模型为什么能扛住高并发很多人对“Node 是单线程的”这句话有误解以为 Node 整个进程从头到尾只有一个线程在干活。真实情况比这个复杂也更有意思Node 的主线程确实是单线程JavaScript 代码也确实只在一个线程上执行但这个单线程不负责等待 I/O 完成。1.1 先理解阻塞式 I/O 为什么是高并发杀手传统的阻塞式服务器模型里每个请求进来服务器会为它分配一个线程。这个线程从头到尾负责处理这个请求包括等待数据库返回结果、等待磁盘读写完成。问题在于一个请求处理过程中真正的 CPU 计算时间往往只有几毫秒剩下的几十毫秒甚至几秒全是在等 I/O 完成。你可以把这种模式类比成餐饮店的老板亲自接待客人来了一个客人老板从点餐、下单、等后厨做菜、上菜、结账全程一对一服务。虽然老板一次只能服务一个客人但来的客人多了怎么办雇更多的服务员。这就是“线程池”的思路。可服务员一多人力成本内存开销、管理成本上下文切换都上来了。而且大部分服务员大多数时间不是在干活而是在等后厨出菜。阻塞式 I/O 模型最大的浪费就在这里线程在为“等待”买单。1000 个并发请求意味着至少 1000 个线程在仓库里排队等 I/O这个资源消耗是非常惊人的。1.2 Node 的解题思路不等了先去干别的Node 换了种思路我不雇那么多服务员就一个店长主线程这个店长不亲自等后厨出菜。客人点完菜店长告诉后厨“菜好了喊我”然后立刻去招待下一位客人。后厨出菜时通知店长店长再抽空把菜端上去。这个处理方式的核心机制就是两个事件驱动和非阻塞 I/O。非阻塞 I/O发起一个数据库查询、文件读取或者网络请求调用立刻返回不用死等结果。事件驱动I/O 操作完成时系统会发出一个事件Node 把这个事件放进任务队列主线程在处理完当前任务后从队列里取出事件对应的回调函数继续执行。这样设计的好处非常直观主线程永远不会被 I/O 等待卡住CPU 永远在处理某个任务而不是干等着。大量请求的 I/O 等待时间被有效重叠起来了同一个主线程可以同时管理成百上千个连接。1.3 单线程的价值不只是省资源更是省心单线程模型还有一个不太容易被注意到的优势没有并发竞争就不需要加锁。多线程模型下多个线程同时访问同一个共享变量需要各种同步机制锁、原子变量、信号量。这部分逻辑不仅写起来费劲而且极易出 bug。死锁、竞态条件、内存可见性问题随便一个都能让线上服务出大事故。Node 的主线程只有一个JavaScript 代码层面天然没有数据竞争问题开发者不需要考虑传统多线程编程里那些让人头皮发麻的同步问题。对于业务开发来说这简直是福音。不过这里要补充一句Node 并不是完全没有线程后续会讲到 libuv 线程池和 worker_threads但那些属于底层辅助和 CPU 密集任务的补充方案完全不影响“JavaScript 主线程单线程”这个核心模型。2. 事件循环内部请求从进入到返回到底经历了什么要真正理解 Node 为什么能单线程扛高并发必须看它的事件循环是怎么工作的。事件循环是 Node 处理所有异步操作的发动机它不复杂但几个阶段必须搞清楚。2.1 点一份外卖看透整个异步流程我给初学者讲课的时候喜欢把 Node 的异步流程比作点外卖你主线程打开外卖 App下单点了一份饭发起 I/O 操作。你不会盯着手机干等饭送到而是继续刷视频、回消息执行其他任务。骑手把饭送到楼下系统给你推送通知“外卖已送达”I/O 完成事件。你看到通知后下楼取餐执行回调函数。整个过程里你在等待外卖的时间并没有白白浪费而是用来做了其他有意义的事。Node 处理大量请求本质上就是这个流程的高并发版本同时点几十份外卖哪份到了就先处理哪份的送达通知而不是点一份饭就傻等一份。2.2 事件循环的六个阶段Node 的事件循环由 libuv 库实现是一个不断循环的进程每个循环周期会顺序经过六个阶段阶段英文名主要处理内容1. 定时器阶段timers执行 setTimeout 和 setInterval 到期的回调2. 待定回调阶段pending callbacks处理某些系统错误比如 TCP 连接错误3. 空闲/准备阶段idle/prepare仅供 libuv 内部使用4. 轮询阶段poll获取新的 I/O 事件执行与 I/O 相关的回调核心阶段5. 检查阶段check执行 setImmediate 回调6. 关闭回调阶段close callbacks执行 socket 或 handle 关闭的回调每次进入轮询阶段时Node 会先检查是否有待处理的事件。有的话就依次执行回调没有的话如果有 setImmediate就进入检查阶段如果既没有事件也没有 setImmediateNode 会在这里等待新的事件出现。这个顺序不是随便定的。timers 放在最前面是因为定时器的回调往往需要得到最及时的处理I/O 事件的回调集中在 poll 阶段是事件循环最核心的工作区setImmediate 放在 poll 之后是为了让开发者可以在 I/O 回调之后立即执行某些逻辑。2.3 宏任务和微任务一个容易被忽略的细节每个阶段之间Node 会执行微任务队列microtask。微任务主要包含 Promise 的回调then、catch、finally等和 process.nextTick。这里有个不太符合直觉的地方process.nextTick 的优先级比 Promise 更高它会在当前操作完成后立即执行而不是等整个阶段结束。看一段代码就明白了setTimeout(() { console.log(setTimeout 执行); }, 0); Promise.resolve().then(() { console.log(Promise 执行); }); process.nextTick(() { console.log(nextTick 执行); });执行结果是nextTick 先输出Promise 第二setTimeout 最后。原因就是 nextTick 在当前宏任务结束后立刻执行Promise 回调在微任务队列中执行而 setTimeout 属于宏任务要等事件循环走到 timers 阶段才会触发。这个优先级问题在项目里确实踩过坑有一次在数据库查询回调里依赖 nextTick 去更新状态结果因为 nextTick 执行时机太早状态还没准备好就报了错。后来把 nextTick 换成 setImmediate 才解决问题。这类细节不深入源码很难意识到但真正遇到的时候会给排查带来不小的麻烦。2.4 任务队列里排队的不只有 I/O 回调事件循环处理的“事件”比很多人想象中要广得多。除了网络请求、文件读取这些 I/O 事件还包括定时器到期触发的事件进程信号事件如 SIGINT自定义事件EventEmitter 触发的事件Promise 和 nextTick 产生的微任务这也是为什么 Node 能用一个线程管理各种不同类型的异步任务它本质上是一个“事件分发器”任何异步操作完成后都会以事件的形式进入队列等待主线程处理。3. 一次高并发请求的全程推演1个线程怎么照顾上千个连接原理讲清楚了接下来做一次实战推演。假设一个 Node 服务同时收到 1000 个请求每个请求都需要查一次数据库数据库查询耗时约 50ms计算耗时约 1ms。我们来演算一下事件循环是怎么调度它们的。3.1 感受一下重叠起来的等待时间在传统阻塞模型里每个请求占一个线程要处理 1000 个请求要么同时开 1000 个线程要么排队逐个处理。开 1000 个线程的内存开销大约在1000 × 1MB 1GB左右线程默认栈大小各平台不同实际占用不止栈空间这还没算线程切换的 CPU 消耗。在 Node 的模型中1000 个请求进来主线程一个个读取请求数据将数据库查询这个异步操作抛给底层系统然后立刻继续处理下一个请求。所有 1000 个数据库查询几乎同时“并行”进行着——但这里并不是 Node 自己并行而是由于它们都是异步 I/O底层操作系统或 libuv 线程池在等待这些查询完成。50ms 后数据库查询陆续返回事件循环的 poll 阶段开始出现大量完成事件。主线程依次执行这些回调每个回调只需要 1ms 的计算时间处理完一个连接的结果并响应给客户端接着处理下一个。1000 个回调全部执行完只需要约 1 秒。而这段时间里主线程依然没有被任何等待卡住。这里的关键数字是对于高并发 I/O 密集场景Node 的模式让等待时间完全重叠而计算时间线性累加。1000 个请求的计算时间如果每个只有 1ms那 1 秒内就能全部处理完这个吞吐量对于绝大多数业务场景是够用的。3.2 网络 I/O 到底谁在处理有人会问Node 把数据库查询抛出去谁来等着拿结果这里要分情况说明。网络 I/O比如 HTTP 请求、数据库驱动通过 TCP 发起查询这种事操作系统本身就支持异步。通过 epoll、kqueue 这类 I/O 多路复用机制操作系统会替你“看着”一堆 I/O 句柄哪个有数据了就通知你。Node 主线程只需要在 poll 阶段不断调用内核接口获取事件即可。文件 I/O 和部分 DNS 操作操作系统对普通文件的异步支持并不完善libuv 内部会使用一个线程池来处理这些逻辑默认线程数是 4可以通过环境变量UV_THREADPOOL_SIZE调整最大不能超过 1024。所以“Node 不需要多线程”这句话更准确的理解是Node 的业务编程模型不需要你自行管理线程但底层 libuv 确实会使用少量线程来处理部分无法异步化的系统调用。这就像餐厅后厨有灶台线程池但店长主线程只需要负责调度不亲自下厨。3.3 libuv 线程池的边界要搞清楚libuv 线程池默认 4 个线程这个数字可能是很多人踩坑的来源。当一个 Node 服务同时处理大量文件读写操作时如果文件 I/O 是同步的或者所有异步文件操作同时堆积线程池会被占满其他依赖线程池的任务会被阻塞排队。这里就直接关系到你项目里遇到的一个典型问题——node 分片上传文件时报错request aborted。原因往往是请求体过大、客户端提前断开也可能是因为服务端同时在进行大量的文件写入线程池被挤占导致上传处理回调迟迟无法执行客户端等不及就断了。处理办法有两个方向一是调整UV_THREADPOOL_SIZE把文件 I/O 的处理能力提上去但线程数不是越大越好它占用系统资源二是把文件接收和文件持久化解耦先接收保存到临时文件再通过异步队列或者单独的服务去处理写入。大多数 Web 请求场景下线程池不太会成为瓶颈因为浏览器发起的网络请求走的是系统异步 I/O不占用线程池。但一旦你的服务里涉及大量文件操作就要认真评估这个线程池的容量了。3.4 一个直观的实验用 setTimeout 模拟并发纸上谈兵可能还不够直观给一个可以自己跑的验证脚本const http require(http); // 模拟一个耗时 100ms 的 I/O 操作 function queryDatabase(callback) { setTimeout(callback, 100); } http.createServer((req, res) { const start Date.now(); queryDatabase(() { res.end(done, elapsed ${Date.now() - start}ms); }); }).listen(3000);用压测工具并发发 200 个请求你会发现所有请求的响应时间都在 100ms 左右而不是 200 × 100ms 20 秒。原因就是这 200 个 setTimeout 几乎是同时间挂到定时器上的100ms 后它们的回调会依次执行。这个实验虽然简单但它把 Node 非阻塞 I/O 的价值演示得非常清楚你的服务没有因为处理大量请求而变慢因为等待时间是被重叠利用的。4. 别让 Node 卡死哪些代码会毁掉事件循环单线程模型有好处也有它的死穴。最致命的问题就是如果主线程里有一段耗时很长的同步代码整个进程都会被卡住。其他请求再进来也只能排队等着因为事件循环根本转不动了。4.1 那些常见的阻塞操作逐个排查根据我在实际项目里的经验以下几种代码最容易成为事件循环的“杀手”第一CPU 密集型计算。比如图像处理、加解密、复杂的排序搜索算法等。一段耗时 500ms 的同步加解密操作足以让服务在高峰期出现大批量超时。我接手过的一个老项目每个请求都同步做一次 RSA 验签并发一上来CPU 直接被打满服务近乎瘫痪。第二同步的 fs 模块方法。fs.readFileSync、fs.writeFileSync这类方法会阻塞线程池吗并不会它们直接阻塞的是主线程。在 Node 的 Web 服务中使用同步文件操作相当于在 2000 米赛跑的中途停下来系鞋带。尤其注意那些在请求处理路径中调用同步 fs 的代码有时候是团队新成员图省事写下的有时候是重构时顺手带进来的。第三过度复杂或规模过大的 JSON 解析和序列化。JSON.parse和JSON.stringify本质上是 CPU 密集操作。当传入超大的 JSON 字符串时这个操作的耗时可能会超乎预期。我记得有一个专门做数据导出的接口内部用JSON.stringify序列化一个几十 MB 的数组高峰期几个请求就能把 CPU 拉到 100%整个服务的响应速度雪崩。第四恶意正则表达式灾难性回溯。形如(a)$、(a|aa)b这类嵌套量词的正则在匹配特定字符串时会产生指数级的回溯导致 CPU 被锁死。第五无节制的 console.log。控制台输出在终端是同步的特别是在 Windows 的某些终端环境下当日志量非常大的时候console.log 会成为被忽视的性能杀手。生产环境尽量用异步日志库或者将日志写入到专门的日志服务。4.2 线上 CPU 突然飙升怎么定位阻塞点遇到线上事件循环被阻塞第一件事不是拍脑袋猜而是先拿到阻塞的证据。Node 官方和社区提供了一套非常实用的排查手段。使用node --prof启动服务收集一段时间 CPU profile然后执行node --prof-process生成可读的分析报告# 启动并采集 60 秒 node --prof server.js # 等采集结束后处理生成的分析文件 node --prof-process isolate-*.log profile.txt分析profile.txt中耗时排前的函数基本就能锁定阻塞点。如果你是线上环境不方便重启服务可以用v8-profiler或者 Node 自带 inspector 方式连接在线采集。还有一个小技巧利用process.hrtime()在关键路径上计时定位慢操作。const start process.hrtime.bigint(); // 某段可疑代码 const cost Number(process.hrtime.bigint() - start) / 1e6; if (cost 100) { logger.warn(slow code detected, cost ${cost}ms); }4.3 阻塞代码改造的三个原则发现阻塞代码后改造时可以按优先级顺序来能用异步就用异步。优先检查文件读写、数据库访问、网络请求等 I/O 操作是否有同步调用被误用。fs.readFile换fs.promises.readFile这类改动通常很机械但收益立竿见影。拿到循环之外做计算。如果计算逻辑无法避免考虑分批处理。把大计算量拆成小块下一块交给setImmediate或setTimeout(0)执行让事件循环有机会处理其他请求。这个方案治标不治本但可以让服务不至于完全瘫痪。把脏活交给其他进程。这是最推荐的方案具体做法有 Worker Threads、子进程、微服务拆分。下一章详细展开。这个环节经常会遇到一个衍生问题node-gyp 和 Node 版本不匹配。很多优秀的 Node 库尤其是涉及原生绑定的库在安装时会通过 node-gyp 编译原生代码如果 Node 版本和 node-gyp 版本不兼容编译过程会报错导致整个服务无法启动。遇到这种问题优先检查你的 Node 版本是否在库的兼容范围内然后确认编译环境是否完整Linux 下需要python3、make、g。这时候一个好的 Node 版本管理器比如 nvm就非常关键了它让你可以快速切换 Node 版本匹配不同库的编译要求。5. 还是有多线程需求怎么办cluster 与 worker_threads 的正确打开方式前面花了大量篇幅论证单线程的优越性但必须承认单线程模型确实有覆盖不到的场景。最常见的两类CPU 密集型任务以及多核 CPU 的充分利用。这时候就要动用 Node 的扩展手段了。5.1 一个 Node 进程只能用一个核太浪费了在单核 CPU 上Node 单线程模型没有问题。但现在的服务器动辄 16 核、32 核如果你只启动一个 Node 进程那其他核就闲着显然不合理。方式有两种多进程cluster 模式和多线程worker_threads。先看 cluster 多进程模式。Node 的 cluster 模块可以在主进程下创建多个工作进程每个工作进程都有自己的事件循环共享同一个服务器端口const cluster require(cluster); const os require(os); if (cluster.isMaster) { const cpuCount os.cpus().length; for (let i 0; i cpuCount; i) { cluster.fork(); } cluster.on(exit, (worker) { console.log(worker ${worker.process.pid} died, respawning); cluster.fork(); }); } else { require(./server.js); // 启动你的 Node 服务 }cluster 模式之所以能共享端口是因为主进程会监听端口并将连接分发给各个工作进程。每个工作进程都是独立的 V8 实例有自己的内存空间互不干扰。这样一台 8 核服务器就能同时跑 8 个事件循环理论上吞吐量提升约 8 倍。但注意这台机器上跑的是 8 个 Node 进程每个进程内部依然是单线程的事件循环。所以 cluster 解决的是“用满多核 CPU”并没有解决“让单个实例并行处理 CPU 密集任务”的问题。5.2 CPU 密集场景的首选worker_threads如果你在 Node 进程内部需要执行 CPU 密集任务又不想阻塞主线程worker_threads 是正确的选择。它是 Node 10.5 之后引入的12 版本正式成为稳定特性。它让 JavaScript 代码可以真正地多线程并行执行同一进程内多个线程每个线程都有自己的 V8 实例和事件循环。const { Worker, isMainThread } require(worker_threads); if (isMainThread) { // 主线程代码 const worker new Worker(__filename); worker.on(message, (message) { console.log(收到加密结果:, message); }); worker.on(error, (error) { console.error(worker error:, error); }); } else { // 子线程代码 const result expensiveEncrypt(data); parentPort.postMessage(result); }worker_threads 适合处理体积较大的计算任务比如图像压缩、PDF 生成、复杂数据转换。但线程的创建和通信是有开销的如果任务过小反而会因为线程调度和消息传递的成本变得更慢。经验值是单个任务的计算量低于 毫秒级不适合开线程。还有一点必须注意worker_threads 虽然共享进程内存但每个线程有自己独立的 JavaScript 堆线程之间交换数据需要通过postMessage传递底层会做结构化克隆或者转移 ArrayBuffer。这就意味着线程之间没有传统多线程编程那种共享内存的问题不需要加锁但也因此不适合做大量数据的频繁交换。5.3 三种扩展方案怎么选一张表说清楚方案解决什么问题资源开销适用场景cluster 多进程利用多核 CPU 提升服务吞吐量每个进程独立 V8 实例内存开销较大常规 Web 服务部署高并发请求child_process 子进程执行独立的外部任务进程级隔离互不影响定时任务、shell 命令、独立脚本worker_threads 多线程不阻塞主线程的 CPU 密集计算线程共用进程空间开销较小图像处理、加解密、数据转换拿实际案例来说如果你要处理大量 PDF 生成任务可以考虑用 worker_threads 放在同一个进程内做因为任务比较独立且计算量适中如果任务里还涉及很多外部工具调用那用另外部署一个 Worker 服务子进程或独立服务反而更清晰避免子线程的不确定性影响主服务稳定性。5.4 扛不住的流量还得靠架构升级当你把 Node 实例的横向扩展做到极限之后仍有大量流量进来这时就该考虑架构层面的解耦了。一个比较实用的思路是把 Node 做成无状态的服务挂在负载均衡后面。每个请求落到任意一个 Node 实例都能处理因为状态都存在外部比如 Redis 或数据库。如果业务里有明显的“重任务”场景比如视频处理、报表汇总不要指望 Node 单挑。把任务丢给消息队列由专门的工作服务可以是 Node worker也可以是其他更合适的语言写的服务去消费。Node 服务只需要负责任务的接收和状态查询这种拆分方式既保留了 Node 在高并发 I/O 场景下的优势又避开了 CPU 密集场景的短板。说到 Node 环境的搭建这里也顺带提一句既然要玩 awr 持久化和多实例部署Node 版本管理和环境一致性就非常重要了我自己的习惯是用 nvm 来管理开发机的 Node 版本在部署机上则固定使用某个 LTS 版本。不同 Node 版本的原生模块兼容性差异比较大曾经有同事在 Node 16 上开发部署机用的是 Node 12结果 node-gyp 编译出来的原生模块直接加载报错。版本管理虽然是个小环节但在实际操作中省下来的时间比我投入踩坑的时间多得多。6. “不需要多线程”不等于“不需要扩展”我的选型心得写到这里我又想起了两年前跟一个同事的争论。他在用 Node 重写一个旧服务时坚持认为我们需要引入多线程因为报表接口太慢了。后来定位发现慢的根源不是并发能力不够而是接口内部在同步读文件、同步做数据校验、还连续发了三次无关紧要的 HTTP 请求。这些操作全部走的是主线程上的同步路径相当于把异步模型直接废掉了。改造完成之后同样一台机器上QPS 翻了将近十倍全程没有引入任何线程。这件事给我带来的思考是Node 的大多数性能问题其实不是“单线程不够用”而是“没有按照单线程模型的规矩来使用它”。只要你在代码里遵守异步原则把 I/O 操作全部非阻塞化把 CPU 密集任务拆出去Node 的高并发处理能力是足够惊人的。6.1 Node 擅长什么不擅长什么经过这些年的实际使用我对 Node 的能力边界有了几条比较清晰的认识Node 特别适合的场景Web API 网关、BFF 层Backend For Frontend、实时消息推送WebSocket、代理服务、资源聚合层。这些业务的核心特点都是 I/O 密集型大量的网络请求和响应短时间内需要处理大量连接计算量相对较小。Node 不太适合的场景CPU 密集型的核心服务比如大规模数据计算、复杂图像视频处理、底层算法实现。不是说完全不能做而是你得额外付出很多代价去管理线程、做任务拆分收益远远低于直接用擅长这个领域的语言。有一个判断标准很多年我一直用如果你把业务里的“等待”时间抽掉之后真正的计算时间依然长到无法接受那就不适合用 Node 做核心计算但如果你的业务大头就是等待数据库、等待下游服务、等待文件系统Node 就是非常适合的选择。6.2 从单实例到高可用扩展开的阶段性打法我之前帮一个创业团队做过一次 Node 服务改造他们的服务从日请求几万涨到几百万中间经历了几个阶段第一阶段单实例单进程数据库连接池做了精细化调优扛住了初期流量。第二阶段单实例 cluster 多进程充分利用服务器的多核 CPU吞吐量直接翻倍。此时把日志规范、监控指标都建立起来这为后续扩容打好了基础。第三阶段多实例部署到负载均衡后面无状态化改造完成之后每次发布、扩容、缩容都变得非常轻量。数据库和 Redis 连接数也做了统一的连接池管理避免多实例重复建连把数据库打爆。第四阶段突发流量超出单体数据库承载范围部分读接口引入 Redis 缓存部分重任务丢进队列异步处理。这时候整个架构的瓶颈已经不在 Node 了而在下游基础设施的能力。这套演进路径里Node 始终只扮演它最擅长的角色高性能、高并发的 I/O 密集型接入层。6.3 最后分享几个我实际踩过的坑文章的最后把我在 Node 并发和事件循环上踩过的坑做一个总结希望能帮你绕过这些“有代表性的雷区”。第一个坑把数据库查询的回调里做了大量同步计算。比如查询出一批数据后在回调里逐个做复杂的 JSON 加工、坐标计算等。这个回调看似在异步流程里但它的执行仍然是在主线程上计算量一大照样卡死事件循环。改造的方向是把计算量大的部分拆分出去或者优化算法减少不必要的计算。第二个坑在 Promise 链里嵌套了同步文件读取。有些代码在 .then 里顺手加了一个 fs.readFileSync看似只读一个小配置文件但请求量一上来这个同步文件读取就成了全局瓶颈。排查时你会发现单个文件读取很快但高并发下大量同步读取排队性能直接断崖式下滑。第三个坑依赖 setTimeout 做精确任务调度。前端背景的同事经常会这么写但 setTimeout 并不是精确的定时器它只保证“至少延迟这么长时间”。如果事件循环正忙定时器回调会延后执行。如果业务对定时精确度有要求建议使用perf_hooks或第三方调度库。第四个坑分片上传大文件时出现 request aborted。这个在前面提到过本质上是因为 Node 主线程或 libuv 线程池的繁忙导致服务端响应不及时客户端提前断开了连接。解决思路除了扩大线程池还可以优化路由设计把文件接收流程从密集计算中剥离出来保持接收路径轻量快速。整体看下来Node 这套单线程事件驱动的模型确实称得上是解决高并发 I/O 问题的一手好棋。它不是靠蛮力去硬扛大量线程切换而是用智慧的调度方式让每一次等待都产生价值。理解了这一点再回头看那些“Node 能撑住多少并发”的讨论你会更容易找到自己的判断依据。
返回列表