Node.js 单进程模型的特殊之处在于:一个未捕获的异步异常可能直接把进程带崩。错误处理因此不只是"写 try/catch",而是一套从异常分类、全局兜底、错误响应规范到结构化日志、链路追踪的完整工程。本文按这个顺序给你一套生产级方案。
1. 错误分类与错误对象设计
1.1 错误的三类来源
| 类别 | 例子 | 处理态度 |
|---|---|---|
| 同步异常 | 参数错误、逻辑错误 | try/catch 或先校验后操作 |
| 异步异常 | Promise reject、回调异常 | 必须显式捕获,否则 unhandledRejection |
| 事件循环层 | 未捕获异常 | process 级兜底,保命用 |
1.2 统一的自定义错误
散落的 new Error('xxx') 无法被上层统一识别。做法:继承 Error,带上状态码与错误码:
class AppError extends Error {
constructor(statusCode, code, message, details) {
super(message);
this.statusCode = statusCode; // 400/401/403/404/500
this.code = code; // 业务错误码:USER_NOT_FOUND
this.details = details; // 额外上下文(可选)
this.name = 'AppError';
Error.captureStackTrace(this, this.constructor);
}
}
throw new AppError(404, 'USER_NOT_FOUND', '用户不存在', { userId: 10086 });
一句话:错误必须"可分类、可识别、可响应"——用统一 AppError 承载 HTTP 状态码、业务错误码与上下文,是所有下游(中间件、日志、告警)能干活的前提。
2. 捕获:同步、异步与全局兜底
2.1 同步错误
function parseJSON(s) {
try {
return JSON.parse(s);
} catch (e) {
throw new AppError(400, 'BAD_JSON', 'JSON 格式错误', { input: s.slice(0, 100) });
}
}
2.2 异步错误:await 自带 try/catch
// async 函数里 await 的 reject 会进 try/catch
async function getUser(id) {
try {
const row = await db.find(id);
if (!row) throw new AppError(404, 'USER_NOT_FOUND', '用户不存在');
return row;
} catch (e) {
if (e instanceof AppError) throw e; // 业务错误透传
throw new AppError(500, 'DB_ERROR', '查询失败', { id }); // 基础设施错误包装
}
}
2.3 兜底:永远别让进程崩
// 未捕获的 Promise 异常
process.on('unhandledRejection', (reason) => {
logger.error({ reason }, 'unhandled rejection');
// 记录后决定:重试?重启?——至少不要静默
});
// 未捕获的同步异常
process.on('uncaughtException', (err) => {
logger.fatal({ err }, 'uncaught exception');
// 进程可能处于不安全状态:记录 + 优雅退出,由 PM2 拉起
gracefulShutdown().finally(() => process.exit(1));
});
一句话:同步 try/catch、异步 await 兜底、进程级 unhandledRejection/uncaughtException 三道防线——前两道是主动防御,第三道是"崩之前先留证据、再优雅退出"。
3. Express / NestJS 全局错误中间件
3.1 Express:错误中间件收口
Express 的错误中间件有四个参数(err, req, res, next),放在路由之后:
// 所有 throw 的异常最终汇到这里统一响应
app.use((err, req, res, next) => {
if (err instanceof AppError) {
return res.status(err.statusCode).json({
code: err.code,
message: err.message,
details: err.details,
traceId: req.traceId,
});
}
// 未知错误:记日志,返回统一 500(不泄露内部细节)
logger.error({ err, url: req.url, traceId: req.traceId }, 'unhandled error');
res.status(500).json({ code: 'INTERNAL_ERROR', message: '服务器内部错误', traceId: req.traceId });
});
3.2 async 路由的坑
Express 4 的 async 路由抛错不会自动进错误中间件,必须手动 catch:
// ✗ 路由里 await 抛错 → 直接未捕获,可能崩进程
app.get('/user/:id', async (req, res) => { throw new AppError(400, 'X', 'y'); });
// ✓ 包一层 wrapper,reject 转给 next
const wrap = (fn) => (req, res, next) => fn(req, res, next).catch(next);
app.get('/user/:id', wrap(async (req, res) => { ... }));
3.3 NestJS:Filter 统一处理
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const res = ctx.getResponse();
if (exception instanceof AppError) {
return res.status(exception.statusCode).json({ code: exception.code, message: exception.message });
}
return res.status(500).json({ code: 'INTERNAL_ERROR', message: '服务器内部错误' });
}
}
一句话:全局错误中间件/Filter 是错误处理的"收口站"——业务异常按码响应、未知异常统一 500 不泄露细节;Express 4 的 async 路由要 wrap 一下才能被收口。
4. 错误响应规范:给前端稳定契约
4.1 统一响应结构
{
"code": "USER_NOT_FOUND",
"message": "用户不存在",
"traceId": "0a2b3c4d",
"details": { "userId": 10086 }
}
| 字段 | 用途 | 约定 |
|---|---|---|
code | 机器可读错误码 | 大写下划线,稳定不变 |
message | 人类可读 | 可国际化 |
traceId | 关联日志 | 贯穿全链路 |
details | 可选上下文 | 不塞敏感信息 |
4.2 错误码字典
export const ErrorCodes = {
BAD_REQUEST: 400, USER_NOT_FOUND: 404, FORBIDDEN: 403,
DB_ERROR: 500, TIMEOUT: 504, RATE_LIMITED: 429,
};
一句话:错误响应是前后端契约的一部分——
code稳定、message可读、traceId可追踪,前端才能可靠地做错误分支与提示。
5. 结构化日志:Pino vs Winston
5.1 选型
| 维度 | Pino | Winston |
|---|---|---|
| 性能 | 极快(JSON 直出) | 较快 |
| 生态 | 现代(Fastify 官方配 Pino) | 传统、插件多 |
| 传输 | 单一 JSON line | 多 transport 灵活 |
| 适用 | 高吞吐服务首选 | 需要多目标输出时 |
5.2 Pino 实践
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
base: { app: 'order-service', env: process.env.NODE_ENV },
// 生产直接 JSON,开发可配 pino-pretty
});
// 永远传对象,别字符串拼接(省序列化 + 字段可检索)
logger.info({ orderId, userId }, 'order created');
logger.error({ err, orderId }, 'create order failed');
5.3 日志级别语义
| 级别 | 使用 |
|---|---|
| trace/debug | 排障细节,生产默认关闭 |
| info | 关键业务事件、调用出入口 |
| warn | 可恢复、可疑情况 |
| error | 失败,必须带上下文与 err |
| fatal | 进程即将崩溃 |
一句话:结构化日志 = 对象字段 + JSON 输出 + 语义分级——用 Pino 保吞吐,字段写全(业务 ID、err、耗时),别用字符串拼接埋没了可检索性。
6. 链路追踪与日志关联
6.1 traceId 贯穿
// 入口中间件注入 traceId 到 request,并写入日志上下文
app.use((req, res, next) => {
req.traceId = req.headers['x-trace-id'] || crypto.randomUUID();
res.setHeader('x-trace-id', req.traceId);
logger = logger.child({ traceId: req.traceId }); // Pino child 自动带上
next();
});
6.2 跨服务与 OpenTelemetry
Web 请求(带 traceId)
→ 网关/入口(生成 traceId,注入 header)
→ 服务A → 服务B(header 透传 x-trace-id)
→ DB / MQ(span 记录)
最终:一条 traceId 串起所有日志,日志平台按 traceId 检索即可还原完整调用链。
接入 OpenTelemetry 后还能拿到 span 树(耗时分布),与日志通过 traceId/spanId 关联。
一句话:traceId 是日志可检索的锚点——入口注入、服务透传、平台检索三步到位后,任何一次线上报错都能在 30 秒内拉出整条调用链。
7. 错误监控:Sentry 与告警
7.1 Sentry 集成
import * as Sentry from '@sentry/node';
Sentry.init({ dsn: process.env.SENTRY_DSN, tracesSampleRate: 1.0 });
// 业务异常不要全上报(噪声),只上报"未知异常"
app.use((err, req, res, next) => {
if (!(err instanceof AppError)) {
Sentry.captureException(err, { extra: { url: req.url, traceId: req.traceId } });
}
next(err);
});
7.2 告警分级
| 级别 | 触发 | 通知 |
|---|---|---|
| error 上报 | 未知异常 | Sentry + 群通知 |
| 5xx 突增 | 错误率超过基线 | 值班告警 |
| 慢查询/超时 | p95 超过阈值 | 预警 |
| 磁盘/内存水位 | 资源告急 | 预警 |
一句话:告警的价值在**“从海量日志里挑出该被人类看见的那一条”**——业务错误按码吞掉、未知异常上 Sentry、错误率与延迟超基线才告警,避免告警疲劳。
8. 踩坑清单
| 坑 | 现象 | 对策 |
|---|---|---|
| async 路由抛错不处理 | 进程崩溃 | wrap + catch(next) |
| 未处理 unhandledRejection | 静默内存泄漏 | 进程级兜底日志 |
| 字符串拼接日志 | 不可检索 | 对象字段 + JSON |
| 未知异常返回详情 | 泄露内部细节 | 统一 500 脱敏 |
| 业务异常全告警 | 告警噪声 | 只上报未知异常 |
| 无 traceId | 无法关联 | 入口注入 + 服务透传 |
| 错误码不统一 | 前端分支乱 | 错误码字典 |
| catch 里吞异常 | 出错无迹 | 至少 log + 重抛 |
9. 总结
| 环节 | 要点 |
|---|---|
| 错误对象 | 统一 AppError:状态码 + 业务码 + 上下文 |
| 捕获 | 同步 try/catch、异步 await、进程级兜底 |
| 收口 | Express 错误中间件 / NestJS Filter |
| 契约 | code 稳定、message 可读、traceId 可追踪 |
| 日志 | Pino 结构化,对象字段 + 分级 |
| 追踪 | traceId 入口注入、服务透传、平台检索 |
| 告警 | 未知异常上 Sentry,错误率超基线才告警 |
一句话记住:错误处理的目标是"异常发生时,系统不崩、用户有响应、工程师 30 秒内定位"——统一异常、三道捕获防线、全局收口、结构化日志与 traceId 关联,这一套串起来就是生产级错误工程。
延伸阅读
- Node.js 可观测性实践:OpenTelemetry 全链路 — 链路追踪与监控大盘
- Node.js Express 实战指南 — 中间件与错误处理细节
- Node.js NestJS 全栈指南 — Filter 与异常管线
- Node.js 测试策略:异常路径用例 — 错误场景的自动化测试
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。