深度复盘:比 EventLoop 更隐蔽!Node 微任务堆积引发的线上接口雪崩事故

线上像雪崩,机器又没真死

事故出在一个 BFF 聚合接口上。

这个接口要给活动页拼商品卡片,一次请求大概会带出几百到一千多条数据。为了省上游压力,接口不是一条条打远程服务,而是先批量拉基础数据,再在应用层做标签、价格、库存、活动态这些二次拼装。

平时跑得挺顺,我对这段代码甚至有点放心。因为数据库查询都收敛过,Redis 也做了批量读,理论上不该炸得太离谱。

结果到了晚上流量高峰,报警开始连着响:

  • 接口 RT 从几百毫秒飙到秒级
  • Nginx 侧开始出现大量 upstream timed out
  • 上游服务也被重试流量带着抖
  • 机器 CPU 只是在往上爬,没有那种一眼假的 100% 锁死
  • Redis、MySQL 监控看着都不算离谱

最烦的是,它不像那种"某台机器直接死透"的故障。服务还在回请求,只是回得更慢,慢到前面的超时、重试、补偿把整条链路一起拖下水。

那一刻我脑子里的关键词只有四个字:EventLoop 卡了。

我先去抓同步阻塞,连 GC 都被我怀疑了一遍

我第一眼的判断很直白。

Node 接口一旦大面积超时,最容易想到的就是主线程被什么大活堵住了。比如超大 JSON 解析、同步循环、模板渲染、正则回溯,或者某段代码把 CPU 咬死了不松口。

于是我先往这几个方向翻:

  • 看有没有超大的 JSON.parse / JSON.stringify
  • 查是不是某段排序、分组、去重在高峰数据量下炸了
  • monitorEventLoopDelay
  • 顺手把 GC 日志也翻了一遍,怀疑是对象分配太猛

监控给我的感觉很别扭。

event loop delay 的确在涨,但它不是那种"单段同步代码卡 3 秒"的形状,更像服务一直在忙,可我又说不清它在忙什么。GC 有抖动,但也没抖到能单独背锅。数据库和 Redis 延迟都在可接受范围内,远程依赖也没先倒。

我当时已经有点拧巴了。

因为这些现象都像主线程被占着,可代码里又找不出一个能把线程狠狠干死的同步大块头。

一条日志把我从数据库拽回了应用层

卡了很久之后,我往接口里补了一段很土的观测。

我在每个批次处理结束后,顺手打了一次时间点;同时又塞了个 setImmediate,想看看"这一轮业务逻辑做完后,事件循环多久才能重新拿到机会"。

ts 复制代码
const begin = Date.now();

setImmediate(() => {
  logger.info('yield back to loop after %dms', Date.now() - begin);
});

await buildCards(items);

正常情况下,这个日志应该很快就出来。

线上高峰那阵子,它能拖到几百毫秒,严重的时候一秒多。怪的是,buildCards 里面又没有哪个同步 for 在硬顶 CPU。

这就把范围缩小了不少。

如果不是单块同步逻辑卡住,那就更像是当前这轮任务虽然一直在结束,可结束完又立刻续上了下一轮,导致 EventLoop 迟迟回不到 I/O 那边。

我顺着这个方向往 buildCards 里翻,最后翻出一段我当时还挺满意的"优化代码"。

真凶就躲在这句我自以为很文明的 await Promise.resolve()

当时为了避免一次性 Promise.all 太猛,我把卡片拼装拆成了分批执行。

代码大概是这个味道:

ts 复制代码
async function buildCards(items: RawItem[]) {
  const result: CardDTO[] = [];

  for (let i = 0; i < items.length; i += 100) {
    const chunk = items.slice(i, i + 100);

    const rows = await Promise.all(
      chunk.map(async (item) => {
        const base = await buildBase(item);
        const tag = await fillTag(base);
        const price = await fillPrice(tag);
        return fillStock(price);
      }),
    );

    result.push(...rows);

    await Promise.resolve();
  }

  return result;
}

我写这段时脑子里的想法很朴素:

  • 分批,别一口气把几千条都扔出去
  • 每批做完 await Promise.resolve(),给事件循环一点空挡

看着很讲武德。

问题就出在第二条。我以为自己在"让路",结果根本没让到路口。

我前面一直以为 await 了就算让路,翻到这里才发现完全不是一回事

这块卡我最久,不是因为 API 难,而是因为直觉太容易带偏。

await 确实会把后续逻辑挂到异步队列里,可挂去哪儿,差别很大。

await Promise.resolve()queueMicrotask()、大量已经完成的 Promise 回调,它们都不是把执行权交回 I/O。它们是在当前这轮结束后,继续往微任务队列后面排。

只要这批微任务还没清完,EventLoop 就没法痛快回到 poll 阶段去收网络事件、定时器回调、socket 可写通知。

而我这段代码偏偏最会制造微任务:

  • chunk.map(async ...) 每条数据都会拆出 Promise 链
  • buildBasefillTagfillPricefillStock 里有不少分支命中本地缓存,返回的是很快就完成的 Promise
  • 一批 100 条,四层 await,一轮就是一大串 continuation
  • 每批结束我还补了个 await Promise.resolve(),继续往微任务队列尾巴上接

回头看,这根本不是"每批做完休息片刻",这是"每批做完换个姿势继续跑,不许 EventLoop 插嘴"。

最要命的是,这种堵法比纯同步死循环更会伪装。

因为线程不是完全不动,日志在刷,Promise 在完成,CPU 也不是瞬间顶满。外面看像服务只是慢了,里头却是微任务把执行片段切得稀碎,还一段接一段地占着前排。

线上为什么会一路滚成雪崩

把这层看明白之后,前面的现象就都串起来了。

单个请求一旦在应用层塞进太多微任务,Node 就会花很长时间清这条队列。清队列这段时间里:

  • 新进来的 socket 事件拿不到及时处理
  • 已经查完 Redis / MySQL 的回调也得继续排队
  • 响应写回变慢,前面的网关开始超时
  • 调用方一重试,请求量再抬一层
  • 每个请求又继续制造新的微任务堆

这就形成了一个很脏的正反馈。

服务没立刻死,但吞吐一直往下掉。前面的超时把后面的流量挤得更高,后面的高流量又让每轮微任务清得更久,最后看上去就像接口突然雪崩。

前面我一直把目光放在"哪个依赖慢了",方向没全错,只是漏掉了一层:依赖不一定先慢,应用层也可能先把回调处理权饿住。

改法不复杂,关键是别再拿微任务假装礼让

根因锁定后,我先把两类写法直接撤了。

第一类是"同步就同步,别硬包 async"。

buildBase 这种纯拼对象的逻辑,我直接改回同步函数,不再为了统一写法给它套一层 Promise。少一层 await,就少一层微任务续命。

第二类是"真要给事件循环喘口气,就用真正会回到 loop 的东西"。

我把那句 await Promise.resolve() 改成了 setImmediate

ts 复制代码
import { setImmediate as yieldToLoop } from 'node:timers/promises';

async function buildCards(items: RawItem[]) {
  const result: CardDTO[] = [];

  for (let i = 0; i < items.length; i += 50) {
    const chunk = items.slice(i, i + 50);

    const rows = await Promise.all(chunk.map((item) => enrichCard(item)));
    result.push(...rows);

    await yieldToLoop();
  }

  return result;
}

function buildBase(item: RawItem) {
  return {
    id: item.id,
    title: item.title,
    cover: item.cover,
  };
}

async function enrichCard(item: RawItem) {
  const base = buildBase(item);

  const [tag, price, stock] = await Promise.all([
    fillTag(base),
    fillPrice(item.id),
    fillStock(item.id),
  ]);

  return {
    ...base,
    tag,
    price,
    stock,
  };
}

还有一刀也很关键:批次从 100 继续往下收,配合接口并发上限一起压。

因为这个问题不是只靠"换个 yield 方法"就能一劳永逸。单请求内部如果还在疯狂扩 Promise,外部并发再一叠,服务照样会抖。把每批规模和接口并发一起拧下来,RT 才真正稳住。

这坑过后,我第一眼会先盯这几种代码

这版改完上线,我盯高峰监控盯了两轮。

接口 RT 先掉下来了,upstream timed out 也跟着退,最明显的是那种"依赖明明回得不算慢,Node 却像堵在半路"的别扭感没了。

现在再看这类问题,我不会只盯"有没有同步大循环"了。下面这几种写法,我一眼就会先翻:

  • 大列表上直接 map(async ...),中间还串了很多本地同步拼装
  • 明明是同步函数,非要包成 async 图个格式统一
  • await Promise.resolve()queueMicrotask() 当"让出事件循环"
  • 一层批处理套一层 Promise 链,批次还开得很大

Node 这玩意最会骗我的地方就在这儿。

很多人一说 EventLoop,被脑补出来的都是同步死循环、CPU 100%、服务直接卡住。微任务堆积不是这个表情包,它更阴一点。它会让服务看着还活着,日志也在冒,依赖也没全红,然后在高峰期把响应一点点拖成事故。

这个坑再来一次,我第一反应不会先开 Redis 监控了。我会先把那段批处理代码翻出来,看是不是有人在循环里塞了一串 async,或者拿 await Promise.resolve() 当暂停键。上次我就是栽在这儿,表面写得挺干净,一到高峰期就开始背后捅刀。

相关推荐
名字还没想好☜2 小时前
Go slice 的 append 陷阱:共享底层数组导致的数据串改
开发语言·后端·golang·go·slice
诸神缄默不语2 小时前
FastAPI后端配置CORS中间件支持浏览器跨域访问
后端·fastapi
Fanta丶2 小时前
14.Activiti7 网关 排他网关ExclusiveGateway、并行网关ParallelGateway
后端
万敏2 小时前
Vue3 全栈实战第二周:Pinia + Vite + Router + axios 完整记录
vue.js·node.js·全栈
明月_清风2 小时前
robots.txt 完全指南:从入门到精通
后端·爬虫
jvmind_dev2 小时前
Java GC 实战指南(番外篇):被忽视的隐形杀手 —— Class Unloading 如何拖垮 GC
java·后端
明月_清风2 小时前
从零写一个"像人"的爬虫:反爬攻防实战指南
后端·爬虫
贰先生2 小时前
Xiuno BBS 审计之问题13:管理员发帖 doctype=0 时存储型 XSS
后端
贰先生2 小时前
Xiuno BBS 审计之问题12:HTTPS 请求关闭 SSL 证书校验,存在中间人攻击风险
后端