设计模式和清晰边界让变化被限制在局部,也让失败开始沿着明确的依赖方向传播。但这只是稳定性的起点:调用失败时谁负责补充上下文,谁决定重试,哪个错误只该结束当前请求,哪个错误意味着整个进程已经不可信?
如果这些决定没有统一出口,结构越清楚,错误也可能只是更整齐地被吞掉。
凌晨一点多,订单查询接口开始间歇性返回 500。
奇怪的是,数据库延迟正常,上游没有报警,CPU 和内存也很平稳。更迷惑的是,同一笔订单连续请求两次,一次成功,一次失败;把某个实例重启后,它又能安静运行十几分钟。健康检查始终是绿色,负载均衡自然也没有摘掉它。
我最初盯着数据库连接池,怀疑连接复用出了问题。接着又猜是某个上游偶发超时,甚至临时调大了调用超时。两个方向都错了,而且调大超时只让失败请求挂得更久。
真正的转折来自一条早于接口报警十分钟的日志:
cache refresh failed: Cannot read properties of undefined (reading 'status')一个后台定时任务正在刷新订单状态缓存。它先清掉旧索引,再逐批写入新数据;写到一半时,某条异常数据触发了 TypeError。更糟的是,进程注册了 uncaughtException 监听器,处理逻辑只有 console.error(error)。异常被记了一行,进程却继续接收请求,于是内存里留下了半新半旧的索引。请求命中正常部分就成功,命中破损部分就报错,健康检查则只会返回一句“进程还活着”。
那晚让我真正警醒的不是一个漏掉的空值判断,而是三件事同时发生了:错误跨出了它该停下的边界,日志丢掉了定位所需的上下文,已经不可信的进程还被允许继续工作。
后来我检查 Node.js 服务的错误处理,不再只搜索有多少个 try/catch,而是沿着失败路径追问四件事:
错误处理真正保护的不是一行代码,而是从单次调用、单个请求一直到整个进程的状态边界。
排查之初,我看到日志里既有数据库超时,也有 TypeError,下意识把它们都归进“服务报错”。这个分类几乎没有行动价值。数据库超时和空值访问都会表现成 500,但它们需要的处置完全不同。
更有用的分法,是先判断错误属于操作错误还是程序错误。
操作错误是在程序逻辑正确时仍可能发生的失败:用户提交了非法参数、订单确实不存在、连接被重置、上游返回 503、磁盘写满。它们是运行环境的一部分,代码应该识别它们,把它们转换成稳定结果,必要时进行有边界的重试或降级。
程序错误意味着代码的假设被打破了:本不该为空的对象成了 undefined,金额字符串被直接参与运算,一个回调执行了两次,或者内存状态更新到一半才检查前置条件。它们需要修代码、补测试;给它们换一条更友好的提示,并不会让进程重新可信。

我现在会把判断结果落成明确动作:
第二行容易引起争议:程序错误是不是一出现就要杀掉进程?不一定。如果一次请求在统一边界内失败,没有修改共享内存,也没有破坏资源状态,返回 500 后继续服务是合理的。真正危险的是错误已经逃到进程顶层,或者它发生时共享状态只完成了一半。此时继续运行不是“高可用”,而是在让未知状态处理更多流量。
那次事故的日志只有 cache refresh failed,没有稳定错误码,也没有原始原因链。聚合平台只能按文案搜索;文案一改,告警规则就失效。
原生 Error 提供 name、message 和 stack,Node.js 的系统错误通常还会带 code,例如 ENOENT、ETIMEDOUT、ECONNRESET。程序分支应该依赖稳定字段,而不是匹配一段随时可能改写的 message。
我会给应用层保留一个很薄的错误模型:
// errors.mjs
export class AppError extends Error {
constructor(
message,
{
code = 'INTERNAL_ERROR',
status = 500,
expose = false,
retryable = false,
cause,
details,
} = {},
) {
super(message, { cause });
这里最容易被低估的是 cause。数据库驱动抛出 ETIMEDOUT 后,服务层可以把它翻译成 DEPENDENCY_TIMEOUT,同时用 cause 保住原始堆栈。客户端只看到稳定协议,排查者仍能顺着原因链走回现场。
不要给每句错误文案都创建一个类。只有稳定错误码、处置方式或公开范围不同,才值得进入错误模型。否则类层级会快速膨胀,却没有增加任何信息。
try/catch,错误就不会丢事故里的缓存刷新任务并不在请求调用栈上。路由周围的 try/catch 再严密,也捕获不到十分钟后定时器回调里的异常。要判断错误会去哪里,必须先辨认 Node.js 中的四条传播通道。
throw 抛出,当前调用栈上的 try/catch 能捕获。async 函数中的 throw 也会让返回的 Promise 变成 rejected。EventEmitter 对象通过 'error' 事件报告失败;没有监听器时,'error' 事件可能直接成为未捕获异常。
同步代码的边界最直观:
try {
const config = JSON.parse(rawConfig);
startService(config);
} catch (error) {
console.error('配置解析失败', error);
}到了异步代码,关键不只是“用了 await”,而是 Promise 有没有留在一条可见的调用链上:
async function loadUser(id) {
try {
return await userRepository.findById(id);
} catch (error) {
throw new AppError('读取用户失败', {
code: 'USER_STORE_FAILED',
status: 503,
retryable: true,
cause: error,
});
}
}这里的 await 让 rejection 发生在当前 try/catch 能观察到的位置。如果当前函数没有恢复能力,完全可以不捕获,让错误继续上浮。最糟糕的写法反而是捕获后打印一句日志,再返回 undefined:调用方会把“读取失败”误认为“用户不存在”,错误的语义和堆栈同时消失。
回调 API 要在回调内部转交错误。包住 fs.readFile() 的外层 try/catch 没用,因为回调执行时,发起调用的那一轮调用栈早已结束:
import fs from 'node:fs';
app.get('/config', (req, res, next) => {
fs.readFile('./config.json', 'utf8', (error, text) => {
if (error) return next(error);
res.type('json').send(text);
事件型 API 则应该在资源创建后立刻注册 'error' 监听器:
app.get('/reports/:id', (req, res, next) => {
const stream = createReportStream(req.params.id);
stream.once('error', (error) => {
if (!res.headersSent) return next(error);
res.destroy(error);
});
stream.pipe(res);
如果响应头已经发送,状态码和响应结构都无法重新来过。此时继续写一份 JSON 只会制造第二个错误,关闭连接才是诚实的结果。
Express 5 会自动把路由或中间件返回的 Promise的 rejection 交给 next(error):
app.get('/orders/:id', async (req, res) => {
const order = await orderService.findById(req.params.id);
if (!order) throw new NotFoundError('订单', req.params.id);
res.json({ data: order });
});但下面的任务已经离开请求链,Express 根本看不到它的结果:
app.post('/exports', async (req, res) => {
// 错误:这个 Promise 没有被 await、return 或显式处理。
createExport(req.body);
res.status(202).json({ accepted: true });
});如果生成导出文件是当前请求必须完成的工作,就 await createExport(...)。如果它应该在响应之后执行,就把任务写进带持久化、重试和死信机制的队列。一个没有 await 的 Promise 不是后台任务系统,只是一段失去所有者的代码。
定时器同理。不能恢复的失败应该被转交给明确的任务边界;可以降级的失败也必须带上任务名和运行批次,而不是静默吞掉:
setTimeout(() => {
void refreshCache().catch((error) => {
logger.error({ error, job: 'refresh-cache' }, '缓存刷新失败');
});
}, 1_000);这段写法只解决“rejection 无人处理”,并不自动保证缓存一致性。刷新共享状态还应先在临时对象中构建完整结果,通过校验后再原子替换引用:
async function refreshCache() {
const records = await loadOrderStates();
const nextIndex = buildOrderIndex(records); // 失败时旧索引仍然可用
validateOrderIndex(nextIndex);
currentOrderIndex = nextIndex;
}这正是那次事故最该有、却缺失的保护。
Promise.all() 和 Promise.allSettled() 也体现了边界语义:前者在任意一项失败时让整体失败,适合“缺一步都不能继续”的任务;后者等待所有任务给出结果,适合关闭多个资源或批处理后汇总。选哪个,取决于失败后还能不能接受部分成功。
现场最费时间的不是复现,而是把一次请求与一条错误日志对应起来。接口只返回“服务错误”,日志只有一段堆栈;高并发下,两者之间没有任何可搜索的共同字段。
修复时,我把路由当成一条失败管道:底层只补充语义和原因,错误一路上浮,最后在请求出口统一完成四件事——规范化未知错误、选择 HTTP 状态、记录内部上下文、输出稳定响应。

对外协议不需要复杂,稳定更重要:
{
"error": {
"code": "RESOURCE_NOT_FOUND",
"message": "订单不存在",
"requestId": "d4e9f86a-8f5e-4dd2-94f3-5b121353ba96"
}
}code 供客户端程序判断,message 供人阅读,requestId 把响应和服务端证据连在一起。堆栈、SQL、绝对路径、依赖地址、Token、Cookie 和密钥片段只属于内部诊断,而且敏感信息连内部日志也不该记录。
下面这份 Express 5 示例用 AsyncLocalStorage 保存请求 ID。即使代码跨过多个 await,日志函数仍能取到同一个关联标识:
// server.mjs
import express from 'express';
import { randomUUID } from 'node:crypto';
import { AsyncLocalStorage } from 'node:async_hooks';
import { AppError, NotFoundError } from './errors.mjs';
const app = express();
const requestContext = new AsyncLocalStorage();
app.use(express.json({ limit: '1mb' }));
错误处理中间件必须放在路由和普通中间件之后,并保留 (error, req, res, next) 四个参数。若响应头已经发出,就继续调用 next(error),让框架关闭连接,而不是强行拼接另一份响应。
HTTP 状态表达一大类结果,业务错误码说明具体原因。我的映射表通常保持克制:
把所有失败都塞进 200,代理、监控和客户端就无法区分成功与失败;把所有失败都写成 500,又会把用户输入问题伪装成系统故障。
我还清理了另一种“看起来很努力”的日志:仓储层、服务层和路由层分别打印同一异常。一笔失败生成四条告警,值班者很容易把它误判成四次故障。
更可靠的约定是:底层用 cause 和结构化字段补充上下文,真正决定返回、重试、降级或退出的边界负责记录。请求错误日志至少应该包含服务、环境、错误码、HTTP 状态、请求 ID、方法、路径、耗时、堆栈和原因链。用户 ID、订单 ID 可以按数据规范记录,密码、Token、Cookie、银行卡号和完整请求体则应明确禁止。
跨服务调用时,请求 ID 或标准追踪上下文还要继续向下游传递。否则同一个 requestId 只能解释入口服务,看不到真正慢下来的那一跳。
事故中最致命的一行代码,是那个“为了稳定”而注册的 uncaughtException 监听器。它确实阻止了进程退出,也顺手阻止了进程恢复。
Promise 被拒绝后,如果一轮事件循环内仍没有处理器,Node.js 会发出 unhandledRejection。在默认 throw 模式下,没有相应监听器的未处理拒绝会进一步成为未捕获异常。uncaughtException 则表示异常已经逃出受控调用边界,到达事件循环;Node.js 默认会打印堆栈并以非零状态退出。
注册 uncaughtException 监听器会改变默认退出行为。如果监听器只写日志、不退出,损坏的实例就可能继续接流量。进程顶层不是更大的业务 try/catch,而是最后一道事故证据边界。
我的处理方式是让致命记录尽量同步、尽量小,然后明确结束实例:
import fs from 'node:fs';
function writeFatal(error, origin) {
const line = JSON.stringify({
level: 'fatal',
origin,
message: error?.message ?? String(error),
stack: error?.stack,
time: new Date().toISOString(),
});
fs.writeSync(process.stderr.fd, `${line
这里的 process.exit(1) 是致命兜底,不用于处理普通请求错误。一个已经进入 Express 错误中间件的数据库超时,只影响当前请求;只有错误逃出全部受控边界时,才进入进程级策略。实例退出后,应由 systemd、容器编排平台或其他进程管理器重新拉起,而不是靠进程自我修复未知状态。
顶层钩子也不适合做复杂的异步网络上报。此时事件循环和应用状态都可能不可靠,最关键的信息应该能同步写到标准错误或受控文件。
只有一条 JavaScript 堆栈仍不足以解释句柄、系统资源或原生层状态时,可以让 Node.js 生成诊断报告:
if (process.report) {
process.report.directory =
process.env.DIAGNOSTIC_DIR ?? './diagnostics';
process.report.reportOnUncaughtException = true;
process.report.reportOnFatalError = true;
}诊断目录要提前创建并挂到持久存储,否则容器退出后证据也会消失。报告可能包含文件路径、环境变量和网络信息,应限制访问权限和保留时间。它能补充现场,却不能代替结构化日志、指标和追踪。
uncaughtException 到来时,应用的不变量可能已经被破坏。顶层钩子的职责是留下足够证据并结束实例,而不是证明这个进程“还能撑住”。
未捕获异常意味着状态可能不可信,适合快速记录并失败退出。SIGTERM 则通常来自发布、缩容或节点维护,收到信号时应用状态仍然可信,可以有序排空。把两者混成一套逻辑,要么让致命进程拖得太久,要么让正常发布粗暴断流。

一次有期限的优雅关闭,顺序应该清楚:
server.close() 停止接受新连接,并等待在途 HTTP 请求完成。我会把停机函数写成幂等的,避免连续收到两次信号后重复关闭资源:
let ready = true;
let shuttingDown = false;
app.get('/livez', (req, res) => {
res.json({ status: 'alive', uptime: process.uptime() });
});
app.get('/readyz', (req, res) => {
res.status
server.close() 会停止接受新连接并等待活动请求完成;closeAllConnections() 会连活动连接一起切断,所以只放在超时兜底里。WebSocket、HTTP/2 和自行创建的 Socket 不一定受这两个方法完整管理,需要单独维护连接集合。后台定时器、队列消费者和数据库连接也都应有清晰的所有者与关闭接口。
如果停机代码不知道谁拥有某个资源,优雅关闭就只是一句口号。
排查早期,我曾想给数据库查询统一加三次重试。后来算了一遍才发现:浏览器重试一次,网关重试两次,服务再重试三次,下游看到的请求量可能成倍放大。依赖越脆弱,重试风暴施加的压力反而越大。
超时、重试和熔断不是三个独立开关,它们共同约束一次失败最多占用多少时间、连接和并发。

假设入口最多允许 1 秒,内部三个依赖却各自能等待 1 秒,那么代码即使“每一处都有超时”,整体也一定会突破预算。先确定端到端上限,再给连接、首字节、读取和重试退避分配子预算,还要给响应与日志留出收尾时间。
Node.js 的 fetch 可以通过 AbortSignal.timeout() 设置调用上限,并把调用方的取消信号一起传下去:
export async function fetchJson(url, { timeoutMs = 800, signal } = {}) {
const timeoutSignal = AbortSignal.timeout(timeoutMs);
const combinedSignal = signal
? AbortSignal.any([signal, timeoutSignal])
: timeoutSignal;
try {
const response = await fetch(url, { signal: combinedSignal });
if
超时只表示当前调用方停止等待,不等于上游已经停止执行。扣款、创建订单等有副作用的请求必须使用幂等键、唯一约束或状态机,避免调用方超时重试后把同一个动作执行两遍。
连接重置、短暂超时、429、502、503、504 可能适合重试,前提是操作幂等,并且还剩足够的总时间预算。参数非法、权限不足、资源不存在和程序缺陷不应重试。
一段可控的重试逻辑至少要有限制次数、指数退避、随机抖动和取消信号:
import { setTimeout as delay } from 'node:timers/promises';
export async function retry(
operation,
{
attempts = 3,
baseDelayMs = 80,
signal,
shouldRetry = (error) => error?.retryable === true,
} = {},
) {
let
随机抖动的意义,是避免大量实例在同一时刻醒来,再次冲击正在恢复的依赖。更重要的是,整条调用链只能有一个真正负责重试的层级。这个层级必须同时知道操作是否幂等、还剩多少预算,以及失败是否真的短暂。
当依赖持续失败,继续等到超时只会让更多请求堆在连接池和内存里。熔断器统计时间窗口内的失败,超过阈值后暂时停止调用下游,直接返回可识别的快速失败;冷却后只放少量探测请求,成功才逐步恢复。
三种机制各管一层:超时限制单次调用,重试吸收偶发失败,熔断阻止持续失败扩散。再加上并发上限、独立连接池和有界队列,一个慢依赖才不至于耗尽整个进程的 Socket、内存和事件循环时间。
降级结果也必须保持真实。推荐列表可以暂时返回缓存,支付接口却不能为了“可用率”伪造成功。无法守住业务语义时,快速失败比假装成功更可靠。
事故实例始终返回 200,是因为健康接口只回答了“HTTP 服务器能不能响应”,却被拿来代表“这个实例能不能接业务流量”。这其实是两个问题。
存活检查判断进程是否还能运行。它应该轻量,不应因为数据库的一次短暂抖动就触发平台反复重启。就绪检查判断实例此刻是否适合接收流量。启动尚未完成、正在排空,或关键能力长期不可用时,它应该返回 503。

接口本身可以很短,难点在于给状态下准确的定义:
let ready = false;
app.get('/livez', (req, res) => {
res.json({ status: 'alive' });
});
app.get('/readyz', (req, res) => {
res.status(ready ? 200 : 503).json({
不要让每次探针请求都执行昂贵 SQL 或遍历所有下游。依赖状态可以由后台任务定期探测并短暂缓存,接口只返回必要状态,不输出连接串、主机列表和异常堆栈。
就绪也不是“所有依赖必须完美”。邮件服务故障但核心交易仍可运行时,摘掉整个实例可能扩大影响;数据库不可写导致核心请求全部失败时,就应该停止接流量。决定就绪状态的是业务能力,而不是依赖数量。
不过,程序错误破坏了共享状态时,不应该只把实例永久挂在未就绪状态等待人工查看。正确动作仍是保留证据、退出并由外部系统替换它。就绪检查是流量开关,不是进程墓地。
修完那次问题后,我没有满足于“正常请求通过”。错误处理最危险的地方,就是平时几乎不执行,评审时看起来也总是合理。验证必须主动制造失败,同时观察响应、日志、进程状态和恢复动作。
可以先把对外错误转换抽成纯函数,确认未知异常绝不会泄露内部信息:
// public-error.test.mjs
import test from 'node:test';
import assert from 'node:assert/strict';
import { AppError } from './errors.mjs';
function toPublicError(error, requestId) {
const known = error instanceof AppError && error.expose;
return {
status: error instanceof AppError ? error.status : 500
接着补请求级集成测试:让仓储层主动抛出超时,断言响应状态符合契约、body 不含 stack、响应头和日志拥有同一个 requestId。异步测试本身也必须被 await;否则测试函数提前结束,测试制造的 rejection 反而会变成未处理拒绝。
最后,把恢复链路当成一组可以重复执行的题目:
让一个可控的假下游延迟超过调用预算,确认请求在总预算内结束,只有幂等调用发生有限次数重试,监控能区分超时与普通 500。
让下游持续失败,观察熔断器是否开启、新请求是否快速失败;恢复下游后,再确认半开探测逐步放量,而不是让全部请求瞬间涌回。
给服务进程发送 SIGTERM,确认就绪检查先变成 503,新请求停止进入,在途请求完成后数据库和队列连接才关闭。
在隔离的测试进程里制造未捕获异常,确认致命日志包含堆栈和触发位置、退出码非零、外部管理器能够启动健康实例。
一次演练通过不代表系统永远可靠。依赖延迟、连接池大小、停机期限和流量结构都在变化,失败路径必须反复验证。真正值得追踪的不是“从不报错”,而是错误多久被发现、影响被限制在多大范围、系统能否按预期恢复。
那次事故之后,我把稳定性治理压缩成一条从内到外的检查路径:
'error' 事件,都必须有清晰的接收者;消灭浮空 Promise。一个稳定的服务从来不是“不会失败”的服务。它只是把失败限制在最小边界内:一次调用失败,不拖垮整条请求;一次请求失败,不污染整个进程;一个实例失控,不继续伤害后续流量;一个依赖崩溃,不把压力无限扩散。
我后来再看到“捕获异常后继续运行”这类代码,都会先问一句:我们是真的恢复了,还是只是把报警器的声音关掉了?这两者之间,就是错误处理与服务稳定性的全部距离。
可用性稳定以后还有一条不能混淆的边界:服务返回 200,不代表它做的事情正确;身份校验通过,也不代表当前用户有权访问这个对象。错误处理回答“系统失败时怎样收住影响”,安全与认证则回答“即使系统正常运行,谁仍然不应该获得什么能力”。下一次事故发生时,所有健康指标都是绿色的。
记录发现故障、停止影响和完全恢复分别用了多久,以及是否出现请求丢失或重复。把人工步骤变成测试、告警或自动化,再重新演练。