Node.js 错误处理与日志工程:从异常到可观测

系统讲解 Node.js 错误处理与日志工程:错误分类与自定义错误对象、同步/异步/事件循环错误捕获、Express 与 NestJS 全局错误中间件、错误码与错误响应规范、Pino/Winston 结构化日志、链路追踪与日志关联、Sentry 告警集成。

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 选型

维度PinoWinston
性能极快(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 关联,这一套串起来就是生产级错误工程。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「nodejs」更多文章

  1. Node.js CLI 工具开发实战:参数、交互、打包与发布
  2. Node.js 输入校验与数据契约:Zod、类型安全与工程实践
  3. Node.js 缓存架构实战:内存、Redis 与一致性策略