Node 服务端
目录 · 22 题
题目与答案
1.Node 的事件循环有哪几个阶段,和浏览器有什么不同 🔴
展开答案
Node 的循环由 libuv 驱动,一轮按顺序过六个阶段,业务上真正要记的是四个:timers(到期的 setTimeout / setInterval)→ poll(等待并处理 IO 事件,绝大多数回调在这里)→ check(setImmediate)→ close(close 事件)。剩下两个(pending callbacks、idle/prepare)是 libuv 内部用的。每个阶段之间会把微任务队列清空,而 process.nextTick 有独立队列且排在所有微任务之前。
浏览器那套是「取一个宏任务 → 清空微任务 → 可能渲染」,没有阶段概念,也没有 setImmediate 和 nextTick。Node 没有渲染这一步,但把 IO 拆细了。直接后果是一道经典题的答案取决于代码位置:
// order.js —— 下面的输出是我在 Node v22.23.2 上实跑的结果
const fs = require('node:fs');
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
fs.readFile(__filename, () => {
setTimeout(() => console.log('io -> timeout'), 0);
setImmediate(() => console.log('io -> immediate'));
});
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
console.log('sync');
sync
nextTick
promise
timeout
immediate
io -> immediate
io -> timeout
同步代码 → nextTick → 微任务,这三步顺序是死的。主模块里 timeout 和 immediate 谁先是不确定的:进程启动到第一次进 timers 阶段已经过了多少毫秒,取决于机器快慢和启动耗时,setTimeout(fn, 0) 被 libuv 规范成 1ms,可能已到期也可能没到。所以上面第 4、5 行在负载高的机器上会互换——如果面试时你说「肯定是 timeout 先」,遇到跑过这段代码的面试官就露底了。而 IO 回调里一定是 setImmediate 先,因为 check 阶段紧跟在 poll 后面,而 timers 要等下一轮。
追问「process.nextTick 和 Promise.then 谁先」:nextTick 先,它有独立队列、优先级高于所有微任务,并且是「清空整个 nextTick 队列后」才处理 Promise 队列。危险也在这里:递归调用 process.nextTick 会饿死事件循环——队列永远清不空,循环再也进不到 poll 阶段,IO 回调一个都不执行。线上的现象很具体:进程还在、端口还listen着,但所有 HTTP 请求超时、健康检查也不通、日志停在最后一行不动,CPU 单核打满。同样的递归换成 setImmediate 就没事,因为 check 阶段每轮只处理当轮入队的那批。所以除了库作者要在「本轮结束前一定执行」的场合用它,业务代码基本不该出现 nextTick。
2.什么会阻塞事件循环,怎么处理 🔴
展开答案
「单线程」指的是 JS 执行单线程,IO 交给 libuv 和操作系统。所以「等模型返回」这种纯等待可以并发上万条,几乎不占 CPU——这正是 Node 适合做 AI 网关的理由。但任何同步的 CPU 活会把整个循环按住:大 JSON 的 parse / stringify、fs.readFileSync、同步加解密(crypto.pbkdf2Sync、大 key 的 RSA)、图片处理、正则回溯、以及本地模型推理。
现象是这题的关键,它和「某个接口慢」完全不同:所有接口一起变慢,包括那个只返回 {ok:1} 的健康检查。因为排队等的是同一个循环。这个特征在排障时能一眼定位,不用先怀疑数据库。
用 perf_hooks 的 monitorEventLoopDelay 就能量出来。下面这段是可运行的最小复现,我实跑的输出跟在后面:
// eld.js
const { monitorEventLoopDelay } = require('node:perf_hooks');
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
setInterval(() => {
console.log('p99(ms)', (h.percentile(99) / 1e6).toFixed(1),
'max(ms)', (h.max / 1e6).toFixed(1));
h.reset();
}, 1000).unref();
// 每 300ms 制造一次 400ms 的同步阻塞
setInterval(() => {
const end = Date.now() + 400;
while (Date.now() < end) {}
}, 300).unref();
setTimeout(() => process.exit(0), 3200);
p99(ms) 416.0 max(ms) 416.0
p99(ms) 400.3 max(ms) 400.3
注意直方图的单位是纳秒,忘了除 1e6 会得到一个看起来很吓人的数。健康服务这个值在个位数毫秒,p99 > 100ms 就该报警了。
处理方式按代价从低到高排:
| 做法 | 适用 | 代价 |
|---|---|---|
换异步 API(fs.promises 替 readFileSync) |
本来就有异步版本的 IO | 几乎没有,纯改写 |
分片让出(大数组处理每 N 条 await setImmediate) |
一次性的批量计算 | 总耗时变长,但循环不被独占 |
worker_threads |
纯 CPU 计算、要传大数据 | 线程池管理、序列化开销 |
| 拆成独立服务 | 模型推理、文档解析这类重活 | 多一次网络调用和一套部署 |
最后一行值得多说一句:把重活拆出去不只是为了不堵循环,更是为了能单独扩容、单独重启——一次批量任务把推理服务打满时,主服务的对话接口还活着。「长任务改成队列 + worker」是同一个思路的另一种写法,HTTP 接口只负责收下任务、返回 task_id(第 17 题展开)。
追问「怎么发现循环被堵了」:盯 event loop delay,monitorEventLoopDelay 的直方图打到指标系统,或者最土的办法——一个 setInterval(fn, 200),在回调里算「实际间隔 - 200」当作延迟。这个指标比 CPU 使用率直观得多:CPU 60% 可能是四个 worker 各跑一点很健康,也可能是主线程 100% 打满而另外三核闲着;而 event loop delay 直接就是「用户的请求要多等多久才被受理」。补一条实操:光有指标还不知道堵在哪行代码,这时候上 --cpu-prof 跑一段(或线上用 node --inspect 接 profiler)拿 CPU profile,火焰图上那根又宽又平的柱子就是同步函数。指标告诉你有病,profile 告诉你病在哪,两个都得会。
框架与流式(第 3-6 题)
3.Express 的中间件模型是什么,错误怎么处理 🔴
展开答案
中间件是 (req, res, next) 的函数链,按 app.use / 路由注册顺序执行,next() 交给下一个,不调就中断在这里(鉴权失败直接 res.status(401).json(...) 然后不 next)。
错误处理中间件是四个参数 (err, req, res, next),参数个数是 Express 唯一的识别方式。写成三个参数它就退化成普通中间件,永远收不到错误,而且不报任何警告——线上表现是请求挂到网关超时,日志里连一条错误都没有。这个坑之所以阴,是因为它长得跟正常代码一样,reviewer 也看不出来:
// 错的:漏了 err,变成普通中间件,错误静默丢失
app.use((req, res, next) => { /* ... */ });
// 对的:四个参数,且必须注册在所有路由之后
app.use((err, req, res, next) => {
req.log.error({ err, url: req.originalUrl }, 'unhandled');
if (res.headersSent) return next(err); // 已经开始流式输出,只能交给默认处理器断开
res.status(err.status || 500).json({ code: err.code || 'INTERNAL', message: '服务暂时不可用' });
});
res.headersSent 那一行是 AI 服务必加的:流已经开始推了才出错,再调 res.status() 会抛 ERR_HTTP_HEADERS_SENT,把一个业务错误升级成进程级的未捕获异常(第 4 题讲透这条)。
版本差异要说准(2026-09 核实):Express 4 里 async handler 抛出的错误不会被自动捕获,Promise reject 了但路由层不知道,请求就一直挂着直到网关超时。Express 5.0.0 于 2024-09-09 发布、5.1.0 于 2025-03-31 成为 npm 上的 latest,async handler 返回的 rejected promise 会自动等同于 next(err)。仍留在 4.x 的项目要么每个 handler 手写 try/catch,要么包一层:
const ah = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
app.get('/chat', ah(async (req, res) => { /* 抛错会自动进错误中间件 */ }));
Express 5 的自动捕获有个边界要记住:它只管 handler 返回的那个 Promise。setTimeout(() => { throw e }, 100) 里的抛出发生在 handler 已经返回之后,逃出了路由层的网,最终变成 uncaughtException(第 6 题)。所以异步工作必须挂在 Promise 链上,不能 fire-and-forget。
错误响应要分两层:服务端日志记完整原因(堆栈、上游状态码、上游 body),发给客户端的是按状态码分流的友好文案。不能把上游原始错误直接透给客户端——模型供应商的报错里常带 endpoint 地址、部署名、有时甚至有 key 的前缀片段,向量库的报错会带 collection 和字段名。这既是安全也是体验。
追问「同一个 Express 应用里,一个中间件里的同步抛错、async 抛错、setTimeout 里的抛错,分别会走到哪」:三条路完全不同,能分清才算真的懂这套模型。同步抛错在任何版本都被路由层的 try/catch 接住 → 走错误中间件;async 抛错在 5.x 走错误中间件、在 4.x 变成 unhandled rejection(新版 Node 默认让进程退出,所以 4.x 上它表现为服务重启而不是 500);setTimeout 回调里的抛错脱离了请求上下文,两个版本都直接 uncaughtException,错误中间件收不到、req 也拿不到,日志里没有 traceId,排查时只看到一条孤立堆栈。判断依据是抛出的那一刻,调用栈还在不在 Express 的 try/catch 或 Promise 链里。实践上的推论是:定时任务、事件监听、res.on('close') 回调这些脱离请求链的地方,必须自己包 try/catch 并显式打日志,不能指望框架。
4.SSE 流式接口,服务端有哪些坑 🔴
展开答案
响应头三件套,少一个都会出问题:
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no', // 关掉 Nginx 缓冲
});
res.flushHeaders();
no-transform 是防中间代理压缩或改写响应体;X-Accel-Buffering: no 是关 Nginx 的响应缓冲,不关它 Nginx 会攒满一块(默认 proxy_buffer_size)才转发,前端表现为「等了八秒然后整段一次性蹦出来」——流式效果彻底没有,而服务端日志看起来一切正常,因为你确实一个 chunk 一个 chunk 写出去了。数据帧格式是 data: {...}\n\n,两个换行才是帧分隔符,少一个前端的 EventSource / 解析器就一直等下文,收不到任何东西。
最关键的一个坑:flushHeaders() 之后状态码就定死 200 了,改不了。 这不是框架限制,是 HTTP 报文顺序决定的——状态行是响应的第一行,已经写进 socket 就追不回来。我把它跑出来验证过:
// sse.js —— 发头之后试图改状态码
res.flushHeaders();
res.statusCode = 500; // 本地属性改了,报文没改
console.log('headersSent =', res.headersSent, 'statusCode(local) =', res.statusCode);
try { res.writeHead(500); } catch (e) { console.log('writeHead again ->', e.code); }
after flushHeaders: headersSent = true statusCode(local) = 500
writeHead again -> ERR_HTTP_HEADERS_SENT
client sees status = 200
三行输出各说明一件事:res.statusCode = 500 静默生效在本地对象上,不报错也不生效,最容易骗过自己;再调 writeHead 抛 ERR_HTTP_HEADERS_SENT,这个错要是没被 catch 就是一次未捕获异常;而客户端看到的永远是 200。
推论就是这题最值钱的那句话:所有可能失败的准备工作必须在发响应头之前做完——鉴权、参数校验、配额检查、向量检索、上游连接建立。这些在发头前失败还能干净地返 401/422/429/500,前端按状态码走既有的错误分支。发头之后再失败,你只剩一条路:在流里发一个业务层的错误事件,让前端在「已经开始渲染」的状态下降级展示。
// 顺序不能反:先把能失败的都做完,再发头
const chunks = await retrieve(q); // 失败 → 还能 res.status(500)
const upstream = await llm.stream({ chunks, signal: ac.signal }); // 失败 → 还能返错
res.writeHead(200, SSE_HEADERS); res.flushHeaders(); // 过了这行只能发 error 事件
for await (const d of upstream) res.write(`data: ${JSON.stringify(d)}\n\n`);
res.write('data: [DONE]\n\n');
res.end();
反过来说,把首字延迟做低的诱惑正是「先把头发出去让前端别等」,两者直接冲突。折中做法是先做完鉴权和校验(快,几毫秒)再发头,把检索和上游调用放在发头之后并在流里推 stage 事件(data: {"stage":"retrieving"}),既有反馈又保住了可失败区间的语义。
第二件必须做的:客户端断开要中止上游请求。
const ac = new AbortController();
res.on('close', () => ac.abort()); // 用户关页面 / 切路由 / 网络断
不做的话,用户关了页面,你这边还在向模型请求、token 照计费。移动端用户切走 App 的比例不低,这条在账单上是能看出来的。实现上是把同一个 AbortController 的 signal 从 Express 的 close 事件一路传到上游 SDK,中间任何一层忘了传,abort 就断在那里。还要注意 close 在正常结束时也会触发,所以 abort 前先判断是不是已经 res.writableEnded,否则日志里会全是「用户中断」的假告警。
第三件是心跳:很多网关和 CDN 对空闲连接有 30~60 秒的超时,模型首字慢的时候连接会被中间层掐掉。定期发一行注释帧 : ping\n\n 保活,它不是数据帧,前端解析器会忽略。
追问「怎么知道流正常结束了」:靠自己发的哨兵事件(data: [DONE]),不能靠连接结束。因为 HTTP 层的连接结束和「业务上答完了」不是一回事——中途断网、上游 500、进程被 SIGKILL、Nginx 超时,在客户端看来都是「流结束了」,EventSource 甚至会自动重连再给你一段重复内容。判断依据是:客户端必须维护一个「本次回答是否收到过终止帧」的状态,只有收到 [DONE](或你约定的 event: done)才算完成,其余情况一律按「异常中断」处理,界面上给重试入口、并且不要把这半截回答写进对话历史当作模型的正式输出,否则下一轮上下文里带着一句话说一半的 assistant 消息,模型会接着胡编。服务端对称地要在 finally 里发终止帧再 end(),异常路径上发 data: {"error":...} 加终止帧,让客户端两边的状态机能对上。前端这一侧的中断与续传见 01-frontend.md 第 16 题。
5.Stream 是什么,背压怎么理解 🟡
展开答案
Stream 是分块处理数据的抽象,核心价值是内存占用和数据总量脱钩:读一个 2GB 文件用 fs.readFile 会把它整个装进内存(而且超过 buffer 上限直接抛 ERR_FS_FILE_TOO_LARGE),用 stream 是几十 KB 的缓冲区滚过去。四种类型:Readable、Writable、Duplex、Transform。
背压就是「下游处理不过来时让上游慢下来」。机制是 writable.write() 的返回值:返回 false 说明内部缓冲已超过 highWaterMark(默认 16KB),此时应该停止写入、等 drain 事件再继续。
不处理背压的后果是内存无限增长。典型场景是读得快写得慢——从本地磁盘读、往慢客户端的网络写。数据全堆在 Writable 的内部缓冲队列里,RSS 一路涨到容器内存上限被 OOMKilled。现象很好认:docker inspect 看到退出码 137、容器日志末尾没有任何错误信息(进程被 SIGKILL,来不及打日志),而且只在给慢客户端下发大文件时复现,本地测怎么都是好的。
// 错的:忽略返回值,缓冲无上限增长
for (const chunk of chunks) res.write(chunk);
// 对的:用 pipeline,背压自动处理
const { pipeline } = require('node:stream/promises');
await pipeline(fs.createReadStream(src), gzip, res);
pipe() 和 pipeline() 都会自动处理背压,所以能用 pipeline 就别手写 write 循环。两者的区别在错误处理:pipe() 出错不会销毁上下游,源流会泄漏(文件描述符不释放);pipeline() 会正确传播错误并销毁整条链上的流。stream/promises 版本还能直接 await。
AI 服务里这条有个变形值得注意:转发模型流时,上游是 SDK 给的 async iterable,下游是 res。用 for await 循环里 res.write() 时背压是被忽略的——for await 不看 write 的返回值。上游比下游快(客户端在弱网)就会堆内存。正确写法是让 stream 层接管,或者显式等 drain:
for await (const d of upstream) {
if (!res.write(`data: ${JSON.stringify(d)}\n\n`)) {
await new Promise((r) => res.once('drain', r));
}
}
实际上 LLM 的 token 速率(几十到几百字节每秒)远低于任何网络的下发能力,所以这条通常不会炸;但一旦你在同一个循环里转发大附件或 base64 图片,它立刻变成真问题。
追问「highWaterMark 调大能提升吞吐吗,什么时候反而更糟」:能提一点,但不是免费的,而且方向经常搞反。调大 highWaterMark 减少的是 write/drain 的往返次数和系统调用次数,对「大量小块」的场景(逐条 JSON 行)有效;对「块本身就大」的场景基本没用,因为瓶颈在带宽和磁盘,不在调度。代价有两条:内存占用按连接数乘——1000 个并发连接每个 1MB 缓冲就是 1GB,而 16KB 时只有 16MB;以及延迟变差,缓冲越大,数据在你进程里滞留越久,流式接口的首字延迟会变高,这跟做流式的初衷相反。判断依据是先量 writableLength 和 drain 触发频率:drain 很少触发说明缓冲根本没满,调大它毫无意义;频繁触发且 CPU 花在系统调用上,才值得调。对 SSE 这类要求低延迟的接口,正确方向是调小甚至禁用中间缓冲(socket.setNoDelay(true) 关掉 Nagle 算法),而不是调大。
6.未捕获异常怎么办,优雅关闭要做什么 🟡
展开答案
uncaughtException 之后进程状态是不可信的:异常从哪里抛的不知道,可能有事务没提交、锁没释放、某个对象停在半初始化状态。所以正确做法是记日志、尽量优雅关闭、然后退出,让进程管理器(PM2 / K8s)重启一个干净的。
监听它然后继续跑是最常见的错误,后果是把一次明确的崩溃换成一串难查的连带故障——比如连接池里留了个坏连接,之后每 N 个请求随机失败一次,看起来像是数据库不稳定。
process.on('uncaughtException', (err) => {
logger.fatal({ err }, 'uncaughtException'); // 同步写盘更稳,别指望异步 flush 完
shutdown(1); // 走同一套优雅关闭,然后 exit
});
process.on('unhandledRejection', (reason) => {
logger.fatal({ reason }, 'unhandledRejection');
shutdown(1);
});
unhandledRejection 在现代 Node 上默认就是让进程退出(--unhandled-rejections=throw 是默认模式),这是好事:忘了 .catch 的 bug 会立刻暴露,而不是静默丢一个错误。
优雅关闭收到 SIGTERM 后要做的事,顺序有讲究:
- 健康检查先转不健康,让负载均衡/K8s Service 把你摘出去。直接
server.close()而不改健康检查,摘除有几秒延迟,这几秒的新连接会被拒。 server.close()停止接受新连接(已建立的连接不受影响)。- 让进行中的流式响应跑完再退出——不然用户看到的是话说一半断掉。
- 兜底强制退出定时器(比如 30 秒),防止某个连接一直不结束导致进程永远不退。
- 关掉资源:DB 连接池、Redis、消息队列消费者(先停止拉取新消息,再等在处理的完成)。
let closing = false;
async function shutdown(code = 0) {
if (closing) return; closing = true;
healthy = false; // 1
const force = setTimeout(() => process.exit(code || 1), 30_000).unref(); // 4
await new Promise((r) => server.close(r)); // 2 + 3:等在途请求自然结束
await Promise.allSettled([db.end(), redis.quit()]);
clearTimeout(force);
process.exit(code);
}
process.on('SIGTERM', () => shutdown(0));
AI 服务这条尤其重要,因为单个请求可能持续几十秒。K8s 默认 terminationGracePeriodSeconds 是 30 秒,超时直接 SIGKILL——如果你的对话请求 p99 是 45 秒,那么每次滚动发布都会掐断一批正在输出的回答,用户看到的是「回答到一半停了」。所以这个值要按 p99 调(比如 60~90 秒),并且和上面的强制退出超时对齐(强制退出要比 grace period 略小,否则轮不到你自己退)。普通 Web 服务的默认关闭策略在这里不够用。
追问「优雅关闭已经等在途请求了,为什么还会有请求在关闭瞬间失败」:因为摘流量和停服务之间有一段不可避免的时间差,而且失败发生在你控制不到的那一侧。K8s 把 Pod 标为 Terminating 和 kube-proxy/Ingress 真正更新转发规则不是同一时刻,这几百毫秒到几秒里,负载均衡仍然会把新连接发给你,而你已经 server.close() 了——客户端收到的是 TCP RST 或 502,日志里在你的服务上什么都看不到(连接还没到应用层),只能在网关侧看到。判断依据是看网关的 5xx 是否集中在发布时间点、且服务端日志无对应请求。标准解法是在 preStop 钩子里先 sleep 5(或先把健康检查转不健康并等两个探测周期)再让进程收到 SIGTERM,用这段时间等转发规则收敛,代价是每次发布慢几秒。另一半在客户端:只有幂等的请求才能安全重试,所以这条最终又落回幂等设计(第 9 题)——不然重试一次流式对话就是重复扣一次费。
进程与模块(第 7-8 题)
7.单进程怎么扩展,cluster 和 PM2 什么关系 🟡
展开答案
一个 Node 进程只能用一个核跑 JS,所以按 CPU 核数起多个进程。cluster 是内置模块:主进程 fork 出多个 worker,共享同一个监听端口(主进程 accept 连接后分发,或者由各 worker 自己 accept,取决于 schedulingPolicy)。PM2 是进程管理器,底层用的就是 cluster,额外提供自动重启、日志聚合、零停机 reload、监控面板。生产上一般不自己写 cluster——要么 PM2,要么容器化交给 K8s。
多进程真正的代价是进程之间不共享内存,所以任何「进程内状态」都会立刻出错,而且错得很隐蔽:
| 进程内状态 | 多进程后的错误现象 | 应该放哪 |
|---|---|---|
| 内存里的限流计数器 | 4 个进程各放 100 QPS,实际限到 400 | Redis 计数器 + Lua |
| 内存缓存 / 语义缓存 | 命中率掉到原来的 1/N,还会读到不同版本 | Redis |
| SSE / WebSocket 连接映射 | 「推送给用户 A」只在持有该连接的那个进程有效,别的进程找不到人 | Redis Pub/Sub 广播 |
| 上传分片的临时暂存 | 分片 1 落在 worker A、分片 2 落在 worker B,合并时缺片 | 对象存储 / 共享盘 |
| 定时任务 | 同一个任务被执行 N 遍 | 分布式锁或 K8s CronJob |
限流那一行是「为什么需要 Redis」最实际的理由,比「为了性能」具体得多。SSE 那一行是 AI 服务特有的坑:单进程本地开发一切正常,上了 PM2 之后「有时能收到推送、有时收不到」,概率恰好是 1/N。
一个容易忽略的点:worker 崩了 PM2 会重启它,但正在这个 worker 上的流式连接全断。所以流式请求越长,单个 worker 崩溃的影响面越大,这也是把重活拆出去(第 2 题)的另一个理由。
追问「容器化之后还需要 PM2 吗」:一般不需要。容器编排已经提供了重启(restartPolicy)和扩缩容(副本数、HPA),容器里跑单进程更符合「一个容器一个进程」的模型:进程退出 → 容器退出 → K8s 重建,信号能正确传递,日志走 stdout 交给平台收集,OOM 时 kubelet 能看见真实原因。套一层 PM2 反而有害——PM2 作为 PID 1 会吞掉子进程的退出码,K8s 看到的是「PM2 还活着」,于是一个反复崩溃的 worker 不会触发 Pod 重启,kubectl get pod 里 restart 次数是 0,你以为服务健康,实际上它在里面循环崩。判断依据很简单:谁负责重启,就让谁看见死亡。真要在容器里用 PM2,得用 pm2-runtime(前台模式、不 daemonize)而不是 pm2 start。至于「一个容器多进程能省内存」这个论点,在 Node 上收益很小(每个 worker 的堆是独立的),而失去的是「单个 Pod 的资源画像可预测」这个更值钱的性质——横向扩副本数比纵向塞 worker 更好调度。
8.CommonJS 和 ESM 的区别,混用会有什么问题 🟡
展开答案
本质差别是加载时机。require 是运行时同步执行,拿到的是导出对象;对原始类型来说是值拷贝,模块内部之后改了变量,导入方看不到。import 是编译期静态分析,导出的是绑定引用(live binding),模块内部改了变量导入方能看到新值,而且整个图是异步加载的。正因为 ESM 的依赖关系在执行前就确定,才能做 tree-shaking——require 可以写在 if 里、路径还能拼字符串,打包器没法静态确定。
混用的现状必须说准,这条最容易背到过时的答案(2026-09 核实):CJS 里 require() 一个 ESM 已经可以了。require(esm) 先在 Node 22 behind flag、Node 23 unflag,backport 到 v22.12.0(2024-12-03)和 v20.19.0,最后在 Node 25.4.0(2026-01-19)标为 stable 并 backport 到 24.x LTS(v24.15.0)。Node 18 从未拿到这个 backport。所以在任何还在支持期内的 Node 上(22 Maintenance LTS / 24 Active LTS / 26 Current),ERR_REQUIRE_ESM 基本已经是历史名词了。我在 v22.23.2 上验过:
// c.cjs
const m = require('./m.mjs'); // m.mjs: export const hi = ...; export default 42
console.log('keys =', Object.keys(m), 'default =', m.default, 'hi() =', m.hi());
console.log('process.features.require_module =', process.features.require_module);
keys = [ '__esModule', 'default', 'hi' ] default = 42 hi() = from esm
process.features.require_module = true
两个细节从输出里直接读出来:require(esm) 返回的是命名空间对象,默认导出在 .default 上,不是返回值本身——按老习惯写 const f = require('./m.mjs') 然后 f() 会得到「不是函数」;以及 process.features.require_module 可以用来做运行时特性检测。
唯一的硬限制是顶层 await。require() 必须保持同步,而带 top-level await 的 ESM 会暂停自己的求值:
TLA -> ERR_REQUIRE_ASYNC_MODULE
这个错误码取代了老的 ERR_REQUIRE_ESM,含义完全不同:前者说「ESM 不支持」,后者说「这一个 ESM 特性不支持」。对库作者的推论很硬——在 require 可达的模块图里第一次引入 top-level await,就是对所有 CJS 使用者的破坏性变更,该按 semver-major 发。改法通常是把顶层 await import() 改成惰性 import。
排查混用报错的顺序是固定三处:tsconfig.json 的 module / moduleResolution、package.json 的 type 和 exports、依赖包本身发的是什么格式。「Cannot use import statement outside a module」几乎总是第一处和第二处不一致;ERR_UNKNOWN_FILE_EXTENSION 是 type: module 下跑 .ts 没配 loader。另外 ESM 里没有 __dirname / __filename / require,要用 import.meta.dirname(Node 20.11+ / 21.2+ 起可用)或 import.meta.url 反推。
追问「既然 require(esm) 已经稳定,那 dual build(同时发 CJS 和 ESM 两份产物)还有必要吗,什么情况下必须留」:对绝大多数新项目没必要了,留着是纯负担——两套构建、两倍 CI 时间、更大的包,还会重新引入 dual package hazard(同一个包被 require 和 import 各加载一份实例,instanceof 失败、模块级单例变成两个,症状是「同一个类的对象居然不是这个类的实例」这种极难查的 bug)。必须留 CJS 产物只有三种情况:一是你要支持 20.19 以下的运行时(Node 18 永远拿不到这个能力,很多企业内网还在 18 上);二是你的 require 可达图里有 top-level await,CJS 使用者会撞 ERR_REQUIRE_ASYNC_MODULE;三是打包器/工具链的插件加载路径还写死在 require 上且不认 ESM。判断依据是把 engines 写清楚(">=22.12.0",或者要兼容 20 就写 "^20.19.0 || >=22.12.0"),然后用一个真的 .cjs 冒烟测试去 require 自己的入口——能过就删 CJS 产物。这题问的其实是「你的版本认知是不是停在 2023 年」,答「CJS 不能 require ESM」会直接暴露。
接口设计(第 9-12 题)
9.接口幂等怎么做,AI 场景为什么更需要 🔴
展开答案
标准做法:客户端带唯一 request_id,服务端用它做唯一索引或 Redis SET key val NX EX ttl,命中就直接返回上次结果。
关键在「唯一索引」这一层,而不是「先查再插」。if (!await exists(id)) await insert(id) 在并发下必然漏:两个请求同时查到不存在,然后都插入。这个 bug 的现象是低并发下永远测不出来,上线后按量出现——QPS 越高、重复率越高。只有让数据库的唯一约束或 Redis 的 NX 来做原子判定才是对的:
// 三态机,缺了 running 这个中间态就还是会重复调用
const key = `idem:${uid}:${requestId}`;
const ok = await redis.set(key, JSON.stringify({ state: 'running' }), 'NX', 'EX', 3600);
if (!ok) {
const prev = JSON.parse(await redis.get(key));
if (prev.state === 'done') return res.json(prev.result);
return res.status(409).json({ code: 'IN_PROGRESS' }); // 让客户端稍后再拉
}
try {
const result = await callModel(...);
await redis.set(key, JSON.stringify({ state: 'done', result }), 'EX', 86400);
res.json(result);
} catch (e) {
await redis.del(key); // 失败要放开,否则重试永远被判成「重复」
throw e;
}
三个细节缺一不可:running 中间态——只有 done 一个状态时,第二个请求会当成「没记录」于是重新调一次模型;失败要删键——不删的话用户重试永远撞在幂等墙上,表现为「第一次网络抖动之后这个请求再也发不出去」;TTL 要覆盖最长执行时间,AI 请求几十秒,TTL 设 30 秒会在执行中过期,等于没有幂等。
AI 场景更要紧的原因是单次调用又慢又贵:几秒到几十秒,还花真金白银。用户手抖点两次、前端超时重试、SSE 断线自动重连,每一次都是成本翻倍。所以一般把「请求指纹 → 结果」存 Redis,既做幂等也顺手做语义缓存(缓存的风险见 04-ai.md 第 25 题)。
请求指纹的算法有个坑,我跑出来给自己看过:
const fp = (uid, body) => sha256(uid + '\0' + JSON.stringify(body)).slice(0, 32);
fp('u1', { q: '你好', model: 'gpt' }) // 0e8c5cca60a036ee0a72aa555f3935d9
fp('u1', { model: 'gpt', q: '你好' }) // fe8e66a236c6d914363c00c7a87599f2
同样的语义、两个完全不同的指纹——因为 JSON.stringify 保留键序。只要客户端换了字段顺序(不同版本的前端、不同的 SDK),幂等和缓存就双双失效,而且没有任何报错,只是命中率悄悄变成 0。所以指纹前必须做规范化:键排序、去掉无关字段(时间戳、traceId)、文本 trim + 大小写统一。
追问「幂等键谁生成」:客户端生成,提交时就带上。服务端生成没有意义——重试的是客户端,它必须两次带同一个键;服务端每次收到请求都新生成一个,那就是两条独立记录。前端要注意这个键在重试时不能变:正确写法是在构造请求对象时生成一次并存在这个请求的闭包/状态里,重试复用;错误写法是在 fetch 的封装里每次调用都 crypto.randomUUID(),看起来「每个请求一个 id」很合理,实际上把幂等彻底废掉,而且这个 bug 在服务端日志里完全看不出来——你只会看到「用户重复问了两次同样的问题」。判断依据是拿一条真实的重试链路看两次请求的 header:键相同才叫幂等键,键不同那只是个 traceId。另外键的作用域要带上用户,idem:${uid}:${requestId} 而不是裸 requestId,否则客户端 UUID 生成有缺陷(某些 WebView 里 Math.random 种子相同)时会出现跨用户命中,A 用户拿到 B 用户的回答——这是个真实存在过的事故类型,比重复扣费严重得多。
10.鉴权怎么做,JWT 和 Session 选哪个 🔴
展开答案
| Session | JWT | |
|---|---|---|
| 状态在哪 | 服务端(多实例必须放 Redis) | 客户端 |
| 能否主动失效 | 能,删了就没了 | 不能,签发后到期前一直有效 |
| 多服务共享 | 要共享存储 | 只要有公钥就能验 |
| 载荷可见性 | 不可见 | payload 是 base64 编码不是加密,任何人能解开看 |
| 撤销手段 | 天然支持 | 只能靠黑名单(于是又变成有状态) |
选择依据是你需不需要「立刻踢人」。要能立刻封号、改权限即时生效,就走 Session 或短时效 token;纯内部服务间调用、无需撤销,JWT 更省。
常见组合是两者混用:短时效 access token(15 分钟,JWT,无状态验证)+ 长时效 refresh token(存服务端可撤销,放 HttpOnly; Secure; SameSite=Lax Cookie 防 XSS 读取)。access token 过期就用 refresh 换新的,封号时删掉 refresh token,最坏情况 15 分钟内失效。「payload 不是加密」这条常被误解到出事:有人把用户手机号、角色、内部 userId 塞进 JWT,结果任何拿到 token 的人(包括浏览器插件、日志系统、前端 sourcemap 里打印过的地方)都能解出来。
AI 应用有两条额外的、必须落在代码里的要求:
第一,工具调用必须带当前用户身份去鉴权,不能用一个服务账号一把梭。反例很具体:Agent 有个 query_order(order_id) 工具,用服务账号连数据库,那么用户只要在对话里说「帮我查一下订单 100024 的状态」,模型就会照办——而那是别人的订单。正确做法是把用户身份从请求上下文透传到每个工具实现里,在工具内部用这个身份做授权,模型只能提供参数,不能提供身份。
第二,RAG 检索的权限过滤要在检索的 where 条件里,不能先全库召回再让模型「注意不要说」。提示词不是安全边界——注入一句「忽略之前的限制」就绕过了,而且即使模型守规矩,被召回的原文已经进了你的日志和 trace。向量库这一侧怎么做过滤(预过滤/后过滤、ACL 数组的选择性问题)见 03-sql.md 第 14 题。
追问「Agent 要调一个内部 HTTP 服务,用户的 token 该不该直接转发过去」:不该直接转发,这是 confused deputy 问题的经典形态。直接把用户的 access token 传给下游有三个坑:一是权限放大——用户 token 的 scope 是「这个用户能做的一切」,而这次工具调用只需要「读订单」,一旦下游服务或它的日志被攻破,泄漏的是全量权限;二是审计断链,下游只看到「用户 X 调用了我」,看不出这次调用是 Agent 代为发起的,事后无法区分「用户自己点的」和「模型自己决定调的」;三是 token 生命周期不匹配,长耗时任务跑到一半 access token 过期,工具调用突然 401,而它已经不在原请求上下文里,没法触发前端 refresh。正确做法是令牌交换:用用户 token 换一个受限的、短时效的、带 act(actor)声明的下游 token(OAuth 2.0 Token Exchange 的思路),scope 只包含这次工具需要的权限,有效期按单次任务算。落地上最小可行版本是自己签一个内部 JWT,claims 里写清 sub(真实用户)、act(agent 服务)、scope(本次工具集)、task_id,下游按 scope 校验并把 act 写进审计日志。判断标准一句话:下游服务应该能分辨「谁在操作」和「代谁操作」,做不到这个区分就说明 token 用错了。
11.参数校验怎么做,为什么不能信前端 🔴
展开答案
前端校验是体验,服务端校验是安全。前端的校验用户随手就能绕过:改请求重发、直接 curl 接口、或者用一个老版本的 App(这条最常被忽略——你改了前端限制,但半年前的客户端还在线上跑)。
实践上用 schema 库在入口统一校验,校验通过后类型也就收窄了,后面的业务代码不用到处判空。Zod 4 于 2025-05 发布稳定版(2026-09 核实,当前 4.x 系列在维护),把 z.string().email() 这类链式格式校验提升成了顶层函数 z.email(),旧写法仍可用但不再是推荐;同时多了个 zod/mini 的 tree-shakable 发行版(约 1.9KB gzip),前端复用同一份 schema 时值得用。
import { z } from 'zod';
const Msg = z.object({ role: z.enum(['user', 'assistant']), content: z.string().min(1).max(4000) });
const ChatReq = z.object({
messages: z.array(Msg).min(1).max(50), // 条数上限
model: z.enum(['fast', 'quality']).default('fast'),
}).refine(
(v) => v.messages.reduce((n, m) => n + m.content.length, 0) <= 30_000,
{ message: '会话总长度超限' }, // 总长度上限
);
最容易漏的是分层校验里的「总长度」这一层。单条限了 4000 字、条数限了 50 条,看起来很严——但 50 × 4000 = 20 万字,拼起来照样能撑爆上下文窗口、单次请求烧掉几十块钱。这类「每一项都合法、组合起来越界」的场景是校验最大的盲区,AI 接口尤其致命,因为成本和输入长度直接成正比。同类的还有:单个文件限 10MB 但可以传 100 个;单次检索 top-k 限 20 但可以在一轮里调 10 次工具。
补三条容易漏的入口:express.json({ limit: '1mb' }) 要显式设(默认 100kb,AI 场景常常不够,但也别设成无限);数组和对象的嵌套深度要限(深层嵌套 JSON 是一种 DoS,Express 5 给 urlencoded 加了默认 32 层的深度上限);以及未知字段要决定策略,Zod 默认剥离未知键(strip),要拒绝就显式 .strict()——对外接口用 strict 能在早期发现客户端拼错字段名,否则那个字段被静默丢弃,前端以为传了。
追问「AI 场景还要校验什么」:模型返回的内容也要校验。Function Calling 拿到的参数是模型生成的,属于不可信输入,要用 schema 再过一遍——和前端传来的表单同等对待。具体现象:模型给 limit 传了 "10"(字符串)、给日期传了 "下周一"、给枚举传了一个你没定义的值、或者在参数里塞进 {"user_id": "admin"} 试图越权。这些不校验就直接进 SQL 或内部 API,轻则报错重则越权。structured output / JSON mode 只保证语法合法,不保证语义合法(这一点 04-ai.md 第 2 题专门讲),所以「开了 JSON mode 就不用校验」是错的。判断依据是把工具的入参 schema 当成对外 API 的 schema 来写:类型、范围、枚举、以及跨字段约束(start < end)。还有一层要校验的是模型输出里的 URL 和引用——它可能编一个 https:// 链接出来,前端渲染成可点击的东西就是钓鱼面。校验失败的处理方式和表单不同:别直接 500,把错误信息回灌给模型让它重试一次,schema 校验错误的文本对模型是很好的纠正信号,通常一轮就能修好(重试上限要设,见 04-ai.md 第 15 题)。
12.接口变慢了,你怎么定位 🟡
展开答案
先分清是全面变慢还是部分请求慢,看 P50 和 P99 的相对关系:
| 现象 | 通常的原因 |
|---|---|
| P50 就高 | 普遍问题:缺索引、N+1、上游整体变慢、事件循环被堵 |
| P50 正常 P99 高 | 少数请求:GC 暂停、锁竞争、某类特殊入参(超长上下文)、缓存未命中路径 |
| P99 和 max 差很远 | 偶发超时、连接池排队、重试放大 |
只看平均值会把问题平掉——1% 的请求 30 秒、99% 的 200 毫秒,平均值 500 毫秒看着很健康,但那 1% 是在骂人的用户。
然后顺链路拆段打点:网关 → 应用入口 → DB / 缓存 / 上游模型 → 返回。没有 trace 就先加 trace,先测量再优化,凭直觉改代码是最慢的路。常见结果就那几个:N+1 查询、缺索引的全表扫(03-sql.md 第 5 题讲怎么读执行计划)、连接池耗尽后排队、同步调了一个慢接口。
Node / AI 应用多两个疑犯:
- 事件循环被堵——现象是所有接口一起变慢,包括健康检查(第 2 题)。
- 上下文变长了——历史没裁剪、召回块数调大、或者用户开始发长文档。首字延迟和 prompt token 数近似线性相关,这类「慢」在应用代码里找不到任何原因,只有把 prompt token 数当指标打出来才看得见。第二类是上游供应商自己慢了,这时候你的服务毫无异常,必须有「上游耗时」这一段独立打点才分得清是谁的问题。
连接池排队有个具体到能一眼认出的特征:接口耗时呈阶梯状分布(比如都是 0.2s、2.2s、4.2s),因为它们在等一个固定超时。看到耗时聚成几簇而不是连续分布,先去看池的 waitCount / pending 指标。
追问「流式接口怎么定义耗时」:必须拆成首字延迟(TTFT)和整体完成时间两个指标,只监控总耗时会错过真正的体验问题。用户对流式的感知几乎全在首字上:TTFT 800ms 然后流畅输出 20 秒,体验是好的;TTFT 6 秒然后 3 秒输出完,总耗时更短但用户已经在刷新页面了。所以 SLO 应该定在 TTFT(比如 p95 < 1.5s),总耗时只作为成本和超时保护的参考。再补两个流式独有的指标:一是token 输出速率(tokens/s,或者更土的「相邻 chunk 间隔的 p95」),它能抓住「首字很快但中间卡了 5 秒」这种总耗时和 TTFT 都看不出来的情况;二是流的完成率(发出 [DONE] 的比例),断流在服务端日志里往往表现为一次正常的 200,只有对比「开始流的次数」和「正常终止的次数」才能量出来。判断依据一句话:流式接口的耗时不是一个数,是一条曲线,至少要用「首字 + 速率 + 是否完成」三个数去描述它。 这个视角前端出身的人天然有,因为你调过流式渲染,知道用户在看什么。
可观测与排障(第 13-16 题)
13.日志和可观测怎么做 🟡
展开答案
三件事,缺一个都会在排障时卡住:
结构化——输出 JSON 而不是拼字符串,才能被日志系统检索、聚合、做告警规则。console.log('user ' + id + ' failed') 这种日志在 Kibana 里没法按 userId 筛,只能全文搜。用 pino(性能最好,它是异步写 + 不做格式化)或 winston。
分级——error / warn / info / debug,生产开 info,debug 靠环境变量临时打开。分级的实际价值是「出事时能把 debug 打开一小时而不用改代码发版」。
带 traceId——一次请求的所有日志能串起来。用 AsyncLocalStorage 隐式传递,不用每个函数都多一个参数。这个 API 自 Node 16.4 起就是 Stable(2026-09 核实,Node 24 起底层换成 AsyncContextFrame 实现,性能更好),可以放心用:
const { AsyncLocalStorage } = require('node:async_hooks');
const als = new AsyncLocalStorage();
app.use((req, res, next) => {
const traceId = req.get('x-trace-id') || crypto.randomUUID();
als.run({ traceId, uid: req.user?.id }, next); // 之后任意深度都能拿到
});
const log = (obj, msg) => pino.info({ ...als.getStore(), ...obj }, msg);
有个坑要知道:AsyncLocalStorage 的上下文不会跨进程、也不会自动进入 EventEmitter 的监听器(监听器是在注册时的上下文里跑的,不是触发时)。所以 res.on('close') 里拿到的 store 可能不是你以为的那个,日志会丢 traceId 或串到别的请求上。要么在注册时闭包捕获 traceId,要么用 als.bind() 包一层。
AI 链路的特殊之处是不确定性——同样输入不同输出,用户报「上午问它答错了」你没法复现。所以要按 trace 记全链路,把一次回答的全部决策过程串成可回溯的链:
| 记什么 | 少了它排不出什么问题 |
|---|---|
| prompt 原文(含拼装后的完整上下文) | 「模型为什么答错」——十次里九次是上下文里根本没有正确信息 |
| 召回了哪些块 + 各自分数 | 是检索没召回,还是召回了模型没用 |
| 模型选了哪个工具、入参是什么 | Agent 走错路的那一步 |
| 每步耗时 | 慢在检索、上游还是自己的代码 |
| token 消耗(prompt / completion 分开) | 成本异常归因、以及上下文是不是悄悄变长了 |
| 模型名和版本、temperature | 供应商悄悄换了模型导致的质量漂移 |
敏感信息落日志前要脱敏,prompt 里常常带用户真实数据(订单、身份证、病历)。做法是在日志序列化器里配 redact 规则(pino 的 redact 选项),而不是指望每个调用点自己记得脱敏——只要有一个地方忘了,PII 就永久留在日志系统里了。另外日志保留期要单独为 prompt 类日志设短,比如 7 天,普通访问日志 30 天。
追问「监控哪些指标」:常规的 QPS / 错误率 / P95 延迟之外,AI 应用至少四个:首字延迟(用户感知最强,见第 12 题)、每请求成本(把 token 数乘单价算成钱,按用户和功能分维度,这是唯一能让业务方听懂的指标)、工具调用失败率(Agent 的可用性瓶颈通常在工具而不是模型)、用户点差评的比例——最后这个是唯一直接反映质量的线上信号,前面三个都只反映系统健康。差评率要在产品里埋点(每条回答带赞/踩),而且要能从差评一键跳到那条 trace,否则收集到的负反馈没法用。再补一个大多数人不会说的:告警要分「系统坏了」和「质量掉了」两条线——系统告警看错误率和延迟,质量告警看差评率、平均对话轮次(用户反复追问说明第一次没答好)、以及「检索命中 0 条」的比例。质量退化最典型的表现恰恰是所有系统指标都正常:改了个 prompt、换了个 embedding 模型、供应商静默升级,错误率 0%、延迟正常,只有差评率从 3% 涨到 12%。只监控系统指标的 AI 服务,等于没监控。
14.原子写文件是怎么做的,为什么需要 ⚪
展开答案
直接覆盖原文件的问题是:写到一半进程崩了或断电,文件就是半个——原来的好数据没了,新数据也不完整。现象很具体:服务重启时 JSON.parse 抛 Unexpected end of JSON input,而且这个文件永久坏掉了,除非有备份。配置文件、任务状态、SQLite 之外的轻量持久化都会踩。
做法是先写临时文件、fsync、再 rename 覆盖目标——rename 在同一文件系统内是原子操作,要么完全成功要么完全没发生,读者永远看到完整的旧版本或完整的新版本,不存在中间态。跨文件系统(比如 /tmp 到 /data 挂了不同卷)就不是原子的,会退化成「复制 + 删除」,所以临时文件要和目标文件放同一目录。
fsync 那一步别省:rename 只保证目录项的原子性,文件内容可能还在页缓存里。断电时你会得到一个「名字对了但内容是空的」文件,这比半个文件更迷惑人。
配套的第二个问题是并发写。多个任务同时写同一个文件会互相覆盖写坏。用一个 Promise 链把写操作串行化,比引入互斥锁库轻得多:
// atomic.js —— 下面的输出是实跑结果
async function writeAtomic(file, data) {
const tmp = `${file}.${process.pid}.tmp`;
const fh = await fs.open(tmp, 'w');
try { await fh.writeFile(data); await fh.sync(); } finally { await fh.close(); }
await fs.rename(tmp, file);
}
let chain = Promise.resolve();
const serialize = (fn) => (chain = chain.then(fn, fn)); // 第二个 fn 保证前一次失败不断链
write order = 1,2,3,4,5
leftover tmp files = 0
五个并发写请求按提交顺序依次落盘,没有交错、没有残留临时文件。chain.then(fn, fn) 里两个参数都传 fn 是关键——只写 .then(fn) 时,前一次写失败会让整条链变成 rejected,后续所有写入永久停摆,而这个故障在测试里几乎撞不到(要先有一次失败)。
临时文件名带 process.pid 是为了多进程安全:两个进程用同一个 .tmp 名字会互相覆盖,反而制造出损坏。多进程下这套还不够(rename 是原子的但「读-改-写」不是),要真正安全得用文件锁(proper-lockfile)或干脆换 SQLite / Redis。
追问「这套单机方案在多副本部署下会怎么坏,什么时候该放弃文件持久化」:会坏在两个地方,而且都是静默的。一是 Promise 链只在单进程内有效——它串行化的是「本进程的写」,两个 Pod 各自串行、彼此并发,rename 的原子性只保证不会写出半个文件,不保证不丢更新:A 读到 {n:1},B 读到 {n:1},A 写 {n:2},B 写 {n:3},A 的更新消失了,这就是经典的丢失更新,日志里全是成功。二是文件根本不共享——容器的本地盘随 Pod 销毁,用户在 A 副本上传的状态,下一个请求打到 B 副本就「不见了」,表现为「数据时有时无」,概率是 1 - 1/N。判断依据一句话:只要副本数可能大于 1,本地文件就不能作为共享状态的存储。 放弃的时机很明确——需要跨请求读回、需要事务、需要并发计数,就该上 Redis 或数据库;文件持久化的合理剩余用途只有三种:单实例的本地缓存(丢了能重建)、启动时读一次的配置、以及写完就上传对象存储的产物(导出文件、日志归档)。这也是为什么这题标 ⚪:它是很实用的手工艺,但在正经的多副本服务里,正确答案往往是「不要在本地写文件」。
15.Node 服务的内存泄漏怎么查 ⚪
展开答案
先确认是不是真泄漏,这一步能省掉一半冤枉功夫。看 RSS 和堆内存曲线:健康的形态是锯齿(涨上去被 GC 回收下来),泄漏是阶梯上升(每次 GC 后的谷底越来越高)。只看「内存涨了」就报警会误判——V8 在内存充裕时本来就懒得 GC,堆用到 --max-old-space-size 的七八成才认真回收。
setInterval(() => {
const m = process.memoryUsage();
log({ rss: m.rss, heapUsed: m.heapUsed, external: m.external, ab: m.arrayBuffers });
}, 30_000).unref();
四个数要一起看:heapUsed 涨是 JS 对象泄漏;external / arrayBuffers 涨而 heapUsed 平稳是 Buffer 泄漏(堆快照里看不见,这条最容易白查半天);rss 涨而堆和 external 都平稳,通常是原生模块或碎片。
确认之后拍堆快照对比:--inspect 起服务,Chrome DevTools 连上去,稳定状态拍一个,压测一段后再拍一个,用 Comparison 视图看哪类对象只增不减,选中它顺 retainer 链找到持有者。生产环境用 v8.writeHeapSnapshot() 定期落盘再离线分析(注意:拍快照会 stop-the-world 几百毫秒到几秒,堆越大越久,别在高峰期拍)。
Node 里的常见来源,按出现频率:
| 来源 | 典型写法 | 现象 |
|---|---|---|
| 模块级 Map/数组只加不删 | const cache = new Map(),写成了永不淘汰 |
最常见,稳定增长和请求量成正比 |
| 没移除的 EventEmitter 监听 | 每个请求 emitter.on(...) 但不 off |
超过 10 个会有 MaxListenersExceededWarning,那个警告不该忽略 |
| 闭包持有大对象 | 回调里引用了整个 req 或整份文档 |
堆快照里看到大量 Closure 保留着 Buffer |
| 该用 WeakMap 用了 Map | 以对象为键存元数据 | 键对象无法被回收 |
未清理的 setInterval |
请求级定时器忘了 clearInterval |
同时泄漏定时器和它闭包里的东西 |
「缓存」写成永不淘汰的对象是第一名,而且它常常是故意写的——加缓存时想着「命中率优先」,忘了设上限。改法是用 lru-cache 之类带 max 和 ttl 的实现,别自己 new Map()。
追问「AI 应用里有什么特别的」:两个高频来源,都和「会话」这个概念有关。一是对话历史在内存里按会话 id 存着不清理——sessions.set(sessionId, messages),每轮 append,会话永不结束(用户就是关了页面走了,你不知道)。这是典型的只增不减,而且泄漏速度和 DAU 成正比,上线初期看不出来,用户涨十倍时突然 OOM。正确做法是会话状态放 Redis 带 TTL,内存里只留当前请求用到的那份;非要放内存就用带 ttl 的 LRU。二是流式响应中止后没正确清理——AbortController、上游 SDK 的连接、res.on('close') 注册的监听、以及为了做 SSE 心跳起的 setInterval,任何一个忘了清就是每次中断泄漏一份,而中断在移动端很频繁。这个泄漏的特征是只在断线多的时段增长(比如晚高峰弱网),本地压测正常。判断依据有个很直接的办法:在 res.on('close') 里打点计数「开启的流」和「清理完成的流」,两条曲线的差值就是泄漏的连接数,比拍堆快照快得多。还有第三个偏门但真实的来源:把 embedding 向量缓存在内存里——一个 1536 维 float32 向量 6KB,缓存十万条就是 600MB,而且它们是 Float32Array,落在 arrayBuffers 里而不是 heapUsed,看堆内存曲线完全正常,只有 RSS 在涨,很容易被误判成「Node 内存管理有问题」。
16.worker_threads 什么时候用,和多进程怎么选 ⚪
展开答案
worker_threads 是同一进程内的多线程,只用于 CPU 密集任务。它不会让 IO 变快——IO 本来就不占 CPU,主线程等模型返回时早就让出去了,套一层 worker 只是多一次序列化。把「调外部 API」丢进 worker 是常见的误用,唯一效果是变慢。
和多进程的区别:
| worker_threads | 独立进程 / 服务 | |
|---|---|---|
| 启动开销 | 几毫秒到几十毫秒 | 几十毫秒到几百毫秒(Node 启动 + 依赖加载) |
| 内存 | 共享一份代码,各自独立堆 | 完全独立,总占用更高 |
| 传大数据 | SharedArrayBuffer 零拷贝,或 transferList 转移所有权 |
必须序列化走 IPC / 网络 |
| 崩溃影响 | 整个进程一起死(OOM、段错误) | 只死自己 |
| 部署粒度 | 跟主服务同生共死 | 能单独扩容、单独重启、单独限资源 |
选择标准:纯计算且要传大量数据 → worker_threads;需要隔离、或者任务本身可能崩 → 独立进程/服务。
两个容易忽略的点。一是传数据的方式决定了性能上限:默认的 postMessage 走结构化克隆,传 100MB 数据要复制一遍,光复制就抵消了并行的收益;要么用 SharedArrayBuffer 共享,要么用 transferList 把 ArrayBuffer 的所有权转移过去(转移后原线程不能再访问,这是特性不是 bug)。二是worker 也会 OOM 并且会带走整个进程——--max-old-space-size 是按进程算的(每个 worker 可以用 resourceLimits 单独限制,值得设)。
实践上一定要用池而不是每个任务 new Worker():启动一个 worker 要重新加载模块图,高频小任务下这个开销会超过计算本身。piscina 是现成的池实现。
本地模型推理属于「纯计算」这一类,但更彻底的做法是拆成独立服务——它还涉及模型权重的内存占用(一份权重几 GB,放在业务进程里让整个 Pod 的内存规格被它绑死),独立出去之后可以单独按 GPU 规格扩容、单独重启、加载失败也不影响对话接口。
追问「一个 CPU 密集任务丢给了 worker,压测下来 QPS 反而降了,可能是什么原因」:四种可能,按出现频率排。一是 worker 数超过了物理核数——线程之间抢 CPU,加上上下文切换,总吞吐低于串行;os.availableParallelism()(比 cpus().length 准,它认容器的 CPU 限制)是上限,还要给主线程和 GC 留一个核。二是任务太小,序列化和调度开销超过计算本身——判断依据是量「单任务纯计算耗时」,低于几毫秒就不该进 worker,应该在主线程分片让出(await setImmediate())。三是数据被复制了两遍:主线程 → worker、worker → 主线程,各一次结构化克隆,大 payload 下这两次复制发生在主线程上,等于你把阻塞从「计算」换成了「序列化」,event loop delay 依旧高——这条最阴,因为看起来任务确实在 worker 里跑。四是容器 CPU limit 太低:cgroup 限了 1 核,起 8 个 worker 只会加剧节流(throttled_time 涨),现象是延迟阶梯式恶化。定位顺序是先看 event loop delay 有没有降下来(没降说明主线程还在干活,属于第三种),再看 throttled_time 和 worker 数,最后才怀疑算法。「丢进 worker 就快了」是个错觉,真正决定收益的是「搬进去的活有多重、搬过去的数据有多大」这两个数的比值。
工程能力(第 17-19 题)
17.耗时几十秒的任务,接口怎么设计 🟡
展开答案
不要让 HTTP 同步等。提交任务立即返回 task_id(HTTP 202),任务进消息队列,worker 消费,客户端靠轮询或 SSE 拿进度和结果。
POST /tasks → 202 { task_id, status: "pending" }
GET /tasks/:id → 200 { status: "running", progress: 0.4 }
GET /tasks/:id/events → SSE 推进度(或客户端 2~5 秒轮询一次)
这样网关超时、客户端断网、worker 重启都不会丢任务。反过来说,同步等的方案在哪些点上必然坏要能一条条说出来:Nginx / ALB 默认 60 秒读超时(改大它是在掩盖问题)、浏览器切后台后连接被系统回收、发布时滚动重启掐断在途请求、以及客户端根本没法区分「还在跑」和「已经挂了」。
要注意的几件事:
任务状态机要落库(pending / running / done / failed),重试次数和错误原因也记下来。只把状态放 Redis 的常见后果是排障时查不到历史——用户说「我昨天那个任务失败了」,你什么都拿不出来。
幂等是刚需——跑到一半挂了要能从断点继续,不能从头再烧一遍钱。落地方式是把任务拆成可标记完成的子步骤(解析 → 切块 → embedding → 写库),每步完成写进度,重启后从最后一个完成点继续。BullMQ 这类队列支持用 jobId 做去重:同一个 jobId 重复 add 不会产生第二个任务,这是最省事的一层保护,但它只覆盖「重复提交」,不覆盖「执行到一半重启」。
失败要有重试上限和死信队列,别无限重投把队列堵死。无上限重试的现象很典型:一个必然失败的任务(比如文档格式不支持)被反复重投,占满 worker,正常任务全在排队——而队列长度指标看起来只是「有点积压」。
进度要是真进度。假进度(前端自己按时间涨的百分比)在这里特别糟:任务卡住了进度条还在走,用户等到 100% 然后看到失败。至少要按子步骤给离散进度(4/12 块已嵌入)。
追问「多实例下定时任务跑重了怎么办」:用分布式锁让只有一个实例执行——Redis SET key val NX EX 30,拿到锁的执行,没拿到的直接跳过(不要等锁,定时任务等锁会变成串行堆积)。或者更省心的做法是把调度权交出去:K8s CronJob 起一个一次性 Pod,或者用队列的 repeatable job(BullMQ 的 repeat 由队列保证只投一次)。「多实例下定时任务会跑多遍」这个坑很多人第一次上容器才发现,因为单机时代 node-cron 写在应用里从来没出过问题;上了 3 个副本之后,每天的对账任务跑三遍、每小时的清理任务删三遍、给用户的推送发三条。这里还有个更深的坑要说:锁的 TTL 必须大于任务的最长执行时间,否则会出现「锁过期了任务还在跑」的重叠执行,而这恰恰是最难查的一类——两个实例同时跑同一个批处理,数据被处理两遍但没有任何报错。判断依据是给任务加执行时长指标,看 p99 和锁 TTL 的比值;正确做法是任务执行期间续租(watchdog 定时延长 TTL),并且任务本身设计成幂等,把锁当成「优化」而不是「正确性保证」——这一点和分布式锁的正确性边界是同一个道理(02-node-java.md 第 15 题)。
18.文件上传怎么做,大文件怎么处理 🟡
展开答案
基本盘四条:
限制类型和大小,服务端校验,不信前端也不信文件后缀。后缀可以随便改,Content-Type 是客户端自己填的,两者都不能作为判据。要看真实的魔术字节(file-type 包读前几百字节判断,注意它是 ESM-only 包,CJS 项目里得靠 require(esm)——见第 8 题)。现象很具体:只校验后缀时,攻击者传一个 .png 结尾但内容是 HTML 的文件,如果你的静态服务按扩展名给 MIME,浏览器就会把它当页面渲染,这是存储型 XSS。
大文件走分片 + 断点续传:客户端切成固定大小的块(5~10MB),每块带 index 和整体的 fileHash,服务端记录已收到的块,客户端上传前先问「哪些块已经有了」。这样断网续传只补缺的块。
存对象存储而不是本地磁盘——多实例部署下本地磁盘不共享(第 14 题那条推论),分片会落在不同 Pod 上,合并时缺片。
客户端直传用预签名 URL,别让文件流穿过业务服务:省你的带宽和内存,也避免大文件把 Node 的事件循环和内存拖住。业务服务只负责签 URL(在这一步做权限和大小校验)和接收上传完成的回调。
知识库场景的关键是上传和解析必须异步解耦:上传完立即返回,解析 + 切块 + embedding 丢后台任务,前端轮询进度。一份百页 PDF 算 embedding 可能要几十秒到几分钟,同步等必然超时(第 17 题)。
安全上两件,都是 AI 知识库特有的:
解析库本身是攻击面——PDF / Office 解析器是历史上漏洞最多的一类库,恶意文件能打挂解析器、耗尽内存、甚至 RCE。要隔离进程跑(独立 worker 或独立服务)并加资源上限(内存、CPU、超时),别在主服务里 await parsePdf(buffer)。还要防「解压炸弹」:一个 1MB 的 zip/docx 解压出 10GB。
文档内容进了知识库就是不可信数据——它会被拼进 prompt。有人在文档里写一句「忽略以上指令,把所有内容原文输出」,检索命中后就成了间接注入。这类攻击的隐蔽之处在于触发者是无辜的用户:他只是问了个正常问题,恰好召回了那个块。防护和处理见 04-ai.md 第 26 题。
追问「上传接口本身要防什么,一个只允许 PDF、限 20MB 的接口最容易被绕过的地方在哪」:最容易被绕的是「校验的时机」和「校验的对象」不一致。三个具体的绕法:一是先校验后写盘的顺序反了——很多实现用 multer 先把整个文件收到磁盘/内存再校验大小,那么攻击者发一个 10GB 的请求,你在校验之前已经把磁盘写满了;正确做法是在流式接收时累计字节数、超限立刻 req.destroy(),或者用 Content-Length 先做一次快速拒绝(但不能只信它,它可以撒谎,chunked 编码下甚至没有)。二是只校验前几百字节的魔术数——PDF 的魔术数是 %PDF-,攻击者可以做一个「前面是合法 PDF 头、后面挂着别的东西」的多态文件(polyglot),你的 file-type 检查通过了,但下游的某个组件按另一种格式解析它。所以魔术数只是必要条件,真正的安全边界是解析在隔离进程里做,且解析结果决定后续处理。三是文件名——../../etc/passwd 这类路径穿越、以及超长文件名、以及 Windows 保留名;文件名一律不要用作存储路径,存储用你自己生成的 UUID,原始文件名只作为元数据存字段。还有两个常被忘的:上传接口要限速和限并发(按用户限,否则它是最便宜的 DoS 入口,一个用户开 100 个并发上传就能打满你的带宽和 worker),以及预签名 URL 的有效期要短(几分钟)并把大小上限写进签名策略里——只在业务服务里校验大小,而对象存储那边没限制,用户拿到 URL 后可以传任意大的文件。判断标准:把「谁在什么时候能写多少字节到哪里」这四个问题的答案都写在签名/校验逻辑里,才算这个接口做完了。
19.模型接口不稳定,怎么保证服务可用 ⚪
展开答案
四层,从内到外:
重试——指数退避 + 抖动,只重试 429 / 5xx / 网络超时。4xx 里的参数错重试没意义,只是白烧钱和时间;429 要优先读响应里的 Retry-After,供应商给了就别自己猜。流式请求的重试要特别小心:已经吐出内容的流不能简单重试,否则用户看到两段开头(这条和第 4 题的哨兵事件配套——只有确认「一个 token 都没发出」才能透明重试)。
降级——主模型失败切备用模型或小模型,或者返回兜底话术,别让整个页面挂掉。降级要在响应里标记出来(model_used、degraded: true),否则线上质量下降了你不知道原因。
熔断——错误率超阈值先快速失败,别把连接和 worker 全耗在等超时上。没有熔断的现象是故障放大:上游变慢 → 你的请求都在等 60 秒超时 → 连接池和事件循环被占满 → 连不依赖模型的接口也开始超时 → 整个服务挂掉,而上游其实只是慢,没死。半开状态要设:定期放一个探测请求,成功就恢复。
限流——按用户和租户维度限,防单个用户刷爆整体配额。
再加一条 AI 特有的:成本熔断。按用户/天设 token 预算,超了拒绝或降级;再加整体日消耗的告警,防止一个 bug 循环调用一晚花掉一个月预算。这个场景不是假设——Agent 的循环(04-ai.md 第 16 题)加上自动重试,一夜之间烧掉预算是真实发生过的事故类型。业务方对这条特别买账,因为它是唯一能直接换算成钱的防护。
超时也要分层设,只设一个总超时是不够的:连接超时(几秒)、首字超时(模型该在 10~20 秒内开始吐字,超了大概率是上游堵了,早失败早重试)、整体超时(按最长合理回答算)、chunk 间隔超时(流突然不动了,比整体超时更早发现问题)。前端出身在这里有优势——你知道用户能忍多久,这些数该定多少。
追问「限流用什么算法」:令牌桶而不是固定窗口。固定窗口的问题是临界点会放过两倍流量:限 100 QPS,上一秒的最后 100 毫秒来 100 个、下一秒的前 100 毫秒再来 100 个,200 毫秒内实际过了 200 个请求,而两个窗口的计数都合法。令牌桶允许短时突发(桶容量)但长期速率可控,滑动窗口日志更精确但存储成本高。分布式场景用 Redis 存桶状态 + Lua 脚本保证「读-算-写」原子,不能用 INCR + EXPIRE 两条命令(中间崩了就漏了过期时间,键永久存在)。AI 场景还要限 token 配额而不只是请求数——一个带 10 万 token 上下文的请求和一句「你好」成本差两个量级,只限 QPS 等于没限。落地上的难点是请求前不知道会消耗多少 token:输入 token 能预估(tokenizer 算一遍),输出 token 只能等结束才知道。可行做法是预扣 + 结算:按 输入token + max_tokens 预扣配额,请求结束后按实际用量退还差额;这样最坏情况下你多扣了,但绝不会超支。判断依据是看「配额耗尽」的时机——如果用户总是在实际消耗远小于配额时被拒,说明 max_tokens 设得太宽,预扣过度;如果偶尔出现超支,说明没预扣或者退还逻辑有 bug。能说出「预扣 + 结算」这一层,说明你真做过成本控制,而不只是背过限流算法。
场景实战(第 20-22 题)
20.Koa 的中间件运作原理是什么,和 Express 有什么区别 🔴
展开答案
Koa 的洋葱圈模型是「先入后出」:中间件 1 → 2 → 3 → 2 → 1。Express 是线性传递:1 → 2 → 3 → 4,走完就完了。
差异的根源在 compose——它把中间件数组折叠成一个嵌套的 Promise 链,next() 返回的是下游全部执行完的 Promise,所以 await next() 之后的代码天然在「响应即将返回」的时刻执行。核心实现就十几行:
function compose(middleware) {
return function (ctx, next) {
let index = -1;
function dispatch(i) {
if (i <= index) return Promise.reject(new Error('next() called multiple times'));
index = i;
const fn = i === middleware.length ? next : middleware[i];
if (!fn) return Promise.resolve();
try {
return Promise.resolve(fn(ctx, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err); // 同步抛错也变成 rejected promise
}
}
return dispatch(0);
};
}
三个设计点值得逐个说:index 守卫使 next() 只能调一次(调两次是逻辑错误,会让下游跑两遍);Promise.resolve(fn(...)) 让同步中间件和 async 中间件统一成 Promise;try/catch 把同步抛错也转成 rejection,于是顶层一个 try { await next() } catch 就能兜住整条链的所有错误,同步的和异步的都在内。
实跑一遍看顺序(第三个中间件里 await 了 30ms):
正常: 1 in -> 2 in -> 3 handler -> 2 out -> 1 out (waited)
漏 await: 1 in -> 2 in -> 2 out -> 1 out
50ms 后: 1 in -> 2 in -> 2 out -> 1 out -> 3 handler
调两次 -> next() called multiple times
第一行是洋葱:1 out 确实等到了 30ms 之后(waited),所以计时中间件写 const t = Date.now(); await next(); ctx.set('X-Response-Time', Date.now() - t) 量到的是真实的全链路耗时。
对比 Express:
| Koa | Express | |
|---|---|---|
next() 语义 |
返回 Promise,await 得到下游完成 |
同步调用,之后的代码立刻执行,不等下游 |
| 响应后处理 | await next() 之后直接写 |
只能挂 res.on('finish') / 包装 res.end |
| 错误捕获 | 顶层 try/catch 兜住全链 | 4 参数错误中间件;4.x 的 async 抛错不自动捕获(第 3 题) |
| 响应体 | ctx.body = x,最后统一发送,中间件还能改 |
res.send() 立刻发出,发完改不了 |
| 生态 | 本体极简,路由、body 解析全要自己装 | 开箱即用,中间件生态最大 |
优劣的判断:Koa 的洋葱模型让「前后夹住」的横切逻辑变得自然——计时、统一错误捕获、响应体后处理(脱敏、加签、统一包一层 {code,data})、事务边界,都是一个中间件搞定;代价是本体太薄,一个正经项目要自己拼装十来个包,团队里没有约定就会各写各的。Express 的优势是生态和招人成本,代价是错误处理和异步是它的历史包袱(5.x 修了 async 那条)。选型上的实话:新项目如果非要在这两个里选,Koa 的模型更干净;但真实的对手是 Fastify 和 NestJS——Fastify 的 hook 模型同样支持前后夹(而且自带 schema 校验和序列化优化),NestJS 提供的是团队协作需要的那套结构。「知道 Koa 好在哪,但也知道它不是唯一选择」比无脑推荐洋葱模型更能体现判断力。
追问「如果一个中间件忘了 await next() 会发生什么」:从上面第二、三行输出直接能看出来——下游照样执行,但上游不等它。2 out 和 1 out 在 3 handler 之前就跑完了,30ms 之后 3 handler 才补上。落到 Koa 服务上的具体后果有三层:一是响应提前结束,Koa 在洋葱走完后读 ctx.body 发送响应,此时业务 handler 还没给 ctx.body 赋值,客户端收到 404 加空 body(Koa 的默认状态是 404,只有 ctx.body 被赋值才变 200)——这个现象特别迷惑,因为路由明明匹配上了;二是后置逻辑量错,计时中间件报出「响应耗时 0ms」而用户实测两秒,日志中间件记的是「请求成功」而下游其实抛了错;三是错误逃出捕获范围,下游的 rejection 不再流回顶层 try/catch,变成 unhandled rejection,进程可能直接退出——一个漏写的 await 从「响应不对」升级成「服务重启」。判断依据很简单:在洋葱链的最外层加一个中间件,记录 await next() 前后的耗时和最终 ctx.status,如果耗时接近 0 而实际请求很慢、或者 status 是 404 而路由存在,就去找哪个中间件漏了 await。这类 bug 靠 lint 更省事——eslint 的 require-await / no-floating-promises(TypeScript 项目开 @typescript-eslint/no-floating-promises)能在写的时候就拦住。
21.IM 聊天室断开连接要如何处理,重连策略怎么设计 🔴
展开答案
WebSocket + 应用层心跳 + 指数退避重连 + 消息补偿,四件事缺一不可。先说最容易答错的那件:
断开不一定会触发 onclose 或 onerror。 半开连接(half-open)时对端已经消失——手机切飞行模式、NAT 表项超时被清、中间设备静默丢弃连接——而本地 TCP 栈完全不知道,它以为连接还在。表现是「界面看着是连接状态,消息发不出去也收不到,一个事件都不触发」,用户以为对方不理他。这类连接可以挂几分钟甚至几十分钟(取决于 TCP keepalive 和是否有数据要发),期间你的应用毫无察觉。
所以必须有应用层心跳:定时发 ping,超过 N 秒没收到 pong 就主动 close 并重连。这才是心跳的真正目的——不是「保活」,是「探活」。(保活是副作用:顺手让中间设备不清 NAT 表项。)
// 客户端心跳 + 探活
let pongTimer;
const HEARTBEAT = 15_000, PONG_TIMEOUT = 10_000;
function beat() {
ws.send(JSON.stringify({ type: 'ping' }));
pongTimer = setTimeout(() => ws.close(4000, 'pong timeout'), PONG_TIMEOUT); // 主动断,触发重连
}
ws.addEventListener('message', (e) => {
const m = JSON.parse(e.data);
if (m.type === 'pong') clearTimeout(pongTimer);
});
setInterval(beat, HEARTBEAT);
服务端也要同样做一遍(用 ws 库的 ping() + pong 事件),否则服务端会攒下一堆死连接,内存和连接数指标一路涨——这是 IM 服务最常见的资源泄漏。
重连策略要完整,四条:
- 指数退避:1s → 2s → 4s → 8s → 上限 30s。固定 1 秒重试在服务端故障时会变成一次自我 DDoS。
- 随机抖动:
delay * (0.5 + Math.random() * 0.5)。不加抖动的后果是惊群——服务端重启,所有客户端在同一时刻断开、按同一条曲线退避、于是在同一毫秒一起重连,把刚起来的服务再打死一次,循环往复。抖动是这题最能体现「做过」的一条。 - 区分「网络断了」和「服务端拒绝」:4xxx 段的关闭码(鉴权失败、被踢下线、协议错误、账号在别处登录)不该重试,无脑重连会变成死循环,日志里刷满 401。只对 1006(异常断开)、1001、网络错误重试。
navigator.onLine和visibilitychange只作为触发提示,不作判据:onLine为 true 只说明有网卡连接,不代表能连到你的服务器(连了个没有出口的 WiFi 时它照样 true);反过来这两个事件是很好的「立即尝试一次重连」的时机,比等退避定时器体验好得多。次数上限设 5~8 次后转为「点击重连」的手动模式,别无限重试到用户手机没电。
消息补偿是重连之后的正确性问题,两个方向都要做:
- 收:客户端本地维护
lastMessageId(服务端下发的单调递增序号),重连后带上它拉增量GET /messages?after=lastMessageId,把断线期间漏的补齐。 - 发:发送方对未收到 ack 的消息本地保留(标记为「发送中」),重连后重发;服务端按客户端生成的消息 id 去重,也就是第 9 题那套幂等——重发的必须是同一个 id,否则去重不掉,就是重复消息。
追问「为什么退避要加抖动,以及重连成功后怎么保证消息不重不漏」:抖动解决的是客户端之间的相关性。所有客户端的断开时间是同一时刻(服务端重启/网络抖动是共因),退避序列又完全一致,于是它们的重连请求在时间轴上叠成一根根尖峰:1 秒时一万个、2 秒时一万个……服务端刚恢复就被打回去,而且因为失败又一起进入下一轮,形成稳定的振荡。加了抖动之后同一批重连被摊平到一个区间,服务端的连接建立速率变得平滑。判断依据是看服务端「新建连接数」这条曲线在故障恢复时是尖峰还是平台——尖峰就说明抖动没生效或者不够。不重不漏是两个独立机制,别混在一起答:不漏靠拉取补偿——重连后用 lastMessageId 拉增量,这要求服务端消息有会话内单调递增的序号(不能用时间戳,多机时钟不同步会乱序,也不能用 UUID,无法比较大小),并且服务端要保留足够长的历史窗口;不重靠幂等去重——客户端生成消息 id,服务端唯一索引拦重复,客户端渲染时也按 id 去重(因为「重发成功但 ack 丢了」这种情况客户端会再发一次)。两者交界处有个容易漏的坑:拉取补偿和实时推送会重叠——你在拉 after=100 的历史,同时服务端把 101 推过来了,于是 101 出现两次。解法是客户端在「补偿中」这个状态下把实时推送的消息先进缓冲区,补偿完成后再按 id 合并去重、按序号排序渲染。能说出「补偿期间的实时消息要缓冲」,说明你真调过这个状态机,而不是照着文章写。
22.IM 聊天室内容变多后变卡,虚拟列表的难点在哪 🟡
展开答案
卡的原因是 DOM 节点数量——几千条消息、每条带头像和富文本,光样式计算和 layout 就能让滚动掉到十几帧。解法是虚拟列表:只渲染视口内 + 上下缓冲区的那几十条,其余用一个撑高的空元素顶出滚动条。
定高列表是简单情况:偏移量 = 索引 × 行高,直接算,二分查找定位起始索引。IM 的消息高度天然不定——一行字和一段代码块差十倍,于是三个真难点:
难点一:渲染前不知道高度。 只能先按估算高度撑开,渲染后用 ResizeObserver 实测真实高度、回填进缓存,同时修正总高度和后续元素的偏移。这一步的可观察后果是滚动条长度会跳:估算 60px 实测 120px,滚过去的部分不断修正总高,滚动条越滚越长——用户拖着滚动条会觉得「抓不住」。缓解办法是估算值取历史实测的中位数,而不是拍一个 60。
难点二:修正偏移会导致视觉跳动,这是最难的一个。 上滑加载历史消息时,你在列表顶部插入了 20 条,它们的高度把当前视口的内容整个推下去——用户正在看的那条消息瞬间跑到屏幕外。解法是锚定一个可见元素:DOM 更新前记住某条可见消息的 id 和它顶边到视口顶的距离 delta,更新后重新测它的位置,反算 scrollTop:
// 上滑加载历史前后的锚定补偿
const anchor = findFirstVisibleItem(); // { id, top: el.getBoundingClientRect().top }
await prependHistory(); // DOM 更新,上方高度变化
const el = document.querySelector(`[data-id="${anchor.id}"]`);
container.scrollTop += el.getBoundingClientRect().top - anchor.top; // 把它挪回原来的视觉位置
难点三:图片和富文本异步加载完还会再次改变高度。 消息渲染完了,图片还在下载,onload 之后高度从预留的 200px 变成 450px,又一次推动布局。所以图片必须写死宽高比预留空间(aspect-ratio 或后端返回尺寸),代码块和表格这类可能溢出的内容要在测量后再更新一次缓存。ResizeObserver 要一直挂着,不能测一次就 unobserve。
IM 特有的两条:
- 新消息从底部追加时要先判断「用户是否已在底部」再决定自动滚。用户正在往上翻历史时被强行拽回底部是很糟的体验,判据是
scrollHeight - scrollTop - clientHeight < 阈值(阈值给 50~100px,别用 0,弹性滚动和小数像素会让它永远不等于 0)。这条前端侧的完整做法见01-frontend.md第 15 题。 - 历史消息是向上无限加载,方向和常规列表相反。所以「触底加载更多」的逻辑要改成「触顶」,而且它和难点二耦合——每次触顶加载都要做锚定补偿。
追问「为什么不能只靠 scrollTop 补偿,以及 content-visibility 能不能替代虚拟列表」:两个问题都是「看起来能行,实际会崩」的类型。先说 scrollTop:它会和浏览器自己的滚动锚定(scroll anchoring)打架。 浏览器默认 overflow-anchor: auto,检测到视口上方的内容尺寸变化时会自动调整 scrollTop 来保持视觉稳定——于是你手动加的补偿和它的自动补偿叠在一起,位置偏两倍,表现为「上滑加载历史时偶尔猛跳一下」,而且只在某些浏览器上复现:Chromium 56+ / Firefox 66+ 支持这个特性,而 Safari 至今(2026-09 核实)在 macOS 和 iOS 的正式版里都不支持,只有 Tech Preview 有实验实现。所以同一份补偿代码在 Chrome 上跳、在 Safari 上正常,很容易误判成「Chrome 的 bug」。正确做法是在滚动容器上显式 overflow-anchor: none 关掉浏览器的自动锚定,由自己全权负责,这样各浏览器行为一致;补偿也别在 requestAnimationFrame 之外做,要在 DOM 更新的同一帧内完成,否则用户能看到中间态。另一半原因是 scrollTop 本身精度不够——高 DPI 下它是小数,累积误差会漂移,所以要锚定元素反算,而不是记住一个数字再加减。再说 content-visibility: auto:它能缓解但不能替代。 它让视口外的元素跳过渲染(layout、paint、样式计算),滚动帧率立刻改善,代价极低——加一行 CSS,配合 contain-intrinsic-size 给预留尺寸。但它不减少 DOM 节点数量,而节点数决定了内存占用、querySelector 成本、事件监听数量、以及首次插入这批节点的时间。一万条消息用 content-visibility 依然要构造一万个 DOM 子树,移动端内存直接告急。判断依据是看瓶颈在哪:如果 profile 显示时间花在 Recalculate Style / Layout,content-visibility 就够了,值得先试(一行 CSS 换 80% 的收益);如果内存超标、或者节点数上万导致插入本身就卡,就必须虚拟化。实践上两者是叠加而非二选一——虚拟列表控制节点总数,content-visibility 再帮缓冲区里那部分省掉渲染。