线上像雪崩,机器又没真死
事故出在一个 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 链buildBase、fillTag、fillPrice、fillStock里有不少分支命中本地缓存,返回的是很快就完成的 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() 当暂停键。上次我就是栽在这儿,表面写得挺干净,一到高峰期就开始背后捅刀。