Node.js 后端项目的异常处理,往往决定了系统在流量峰值时的生死。很多开发者习惯在控制器里用 try-catch 一把梭,或者更糟,完全依赖框架的兜底机制,让未捕获的异常直接顶穿进程。这种做法在业务逻辑简单时看不出问题,一旦链路变长,涉及数据库、缓存、消息队列和第三方服务调用,一个未处理的 rejected promise 就能引发雪崩,让整个 Node 进程 crash。解决这个问题的核心思路不是“捕获所有错误”,而是建立分层、分类、可观测的异常处理体系。

把异常当作一等公民:错误类型的语义化设计

Node.js 原生的 Error 对象过于单薄,只携带 message 和 stack,无法承载业务上下文。要让异常处理机制真正可用,第一步就是自定义错误类。你可以从通用的 AppError 基类开始,派生出 AuthError、ValidationError、NotFoundError、DatabaseError、ExternalServiceError 等子类。每个子类强制携带 HTTP 状态码、业务错误码、是否可对外暴露详情等属性。

class AppError extends Error {
  constructor(message, statusCode, errorCode, isOperational = true) {
    super(message);
    this.statusCode = statusCode;
    this.errorCode = errorCode;
    this.isOperational = isOperational;
    Error.captureStackTrace(this, this.constructor);
  }
}

class ExternalServiceError extends AppError {
  constructor(serviceName, originalError) {
    super(`Service ${serviceName} unavailable`, 502, 'EXT_SERVICE_FAIL', true);
    this.serviceName = serviceName;
    this.originalError = originalError;
  }
}

这样做的好处是,错误本身变成了可编程的载体。上层捕获时,不需要用 instanceof 去猜错误类型,直接通过 errorCode 就能决定响应策略和告警级别。isOperational 标记尤其关键,它区分了“预期内的业务异常”和“代码缺陷导致的程序错误”。前者需要优雅降级,后者需要触发进程退出并重启。

全局异常捕获不是终点,而是最后防线

很多文章会教你用 process.on('uncaughtException') 和 process.on('unhandledRejection') 兜底,但这只是最后的安全网。正确的做法是,在请求生命周期的入口处就建立统一的异常拦截层。如果你使用 Express,不要在每个路由里写 try-catch,而是封装一个高阶函数,自动包装异步中间件,将抛出的错误透传给错误处理中间件。

const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

// 使用
router.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await userService.findById(req.params.id);
  if (!user) throw new NotFoundError('User not found');
  res.json(user);
}));

错误处理中间件是整个体系的枢纽。它负责解析错误对象,决定返回给客户端的 HTTP 状态码和响应体,同时将错误详情输出到日志系统。关键点在于,只有 isOperational 为 true 的错误,才将具体 message 返回给客户端;对于程序错误,只返回通用 500 响应,避免泄露内部信息。

app.use((err, req, res, next) => {
  const statusCode = err.statusCode || 500;
  const response = {
    code: err.errorCode || 'INTERNAL_ERROR',
    message: err.isOperational ? err.message : 'Internal Server Error'
  };
  
  logger.error({
    message: err.message,
    stack: err.stack,
    errorCode: err.errorCode,
    requestId: req.id,
    path: req.path
  });

  res.status(statusCode).json(response);
});
Promise 链和 async/await 的陷阱

Node.js 中 async 函数返回的 Promise,如果内部抛出异常,该 Promise 会被 reject。问题在于,很多开发者在调用 async 函数时忘记 await,或者在不该并行的地方用了 Promise.all,导致某个 Promise 的异常被静默吞掉。你需要在开发规范中强制要求,所有 Promise 调用必须有明确的错误处理分支,或者最终被全局 unhandledRejection 捕获。更好的做法是在代码审查中通过 ESLint 规则 no-floating-promises 来拦截这种危险写法。

对于事件发射器 EventEmitter,错误处理更加隐蔽。如果 stream 或 EventEmitter 实例抛出 error 事件而没有监听器,Node 进程会直接崩溃。你必须为所有 EventEmitter 实例绑定 error 事件处理器,尤其是在处理文件流、数据库连接池、消息队列消费者时。

const consumer = kafka.consumer({ groupId: 'order-group' });
consumer.on('error', (err) => {
  logger.error('Kafka consumer error', err);
  // 触发重连逻辑,而非让进程崩溃
});
异常与日志、告警的联动

异常处理机制如果脱离可观测性,就等于蒙着眼睛排障。每一次异常捕获,都应该生成结构化的日志条目,包含 requestId、userId、errorCode、stack trace 和关键业务参数。requestId 需要从请求入口注入,贯穿整个调用链,这样你才能在海量日志中快速关联单个请求的所有异常。

告警规则应该基于 errorCode 和频率来设定。ExternalServiceError 连续出现 10 次以上,意味着第三方服务可能宕机,需要立即通知;而 ValidationError 即使频率高,也属于客户端错误,告警级别应该较低。你可以在错误处理中间件里集成告警逻辑,但更好的做法是将错误日志输出到集中式日志平台,由平台侧的监控规则来触发告警,保持应用层的纯粹性。

优雅降级与重试策略

异常处理不只是返回错误响应,更高级的形态是系统在部分依赖不可用时仍能提供降级服务。比如 Redis 缓存挂了,你可以直接穿透到数据库查询,虽然性能下降,但服务不中断。这要求在代码中显式处理 ExternalServiceError,并实现 fallback 逻辑。

async function getProductDetail(productId) {
  try {
    const cached = await redis.get(`product:${productId}`);
    if (cached) return JSON.parse(cached);
  } catch (err) {
    logger.warn('Redis unavailable, fallback to DB', err);
  }
  
  const product = await db.query('SELECT * FROM products WHERE id = ?', [productId]);
  if (!product) throw new NotFoundError('Product not found');
  
  try {
    await redis.set(`product:${productId}`, JSON.stringify(product), 'EX', 3600);
  } catch (err) {
    // 缓存写入失败不影响主流程
  }
  
  return product;
}

对于网络抖动导致的临时性错误,重试机制必不可少。但重试必须谨慎,要设置最大重试次数、指数退避间隔,并且只对幂等的操作重试。你可以使用 p-retry 这类库来封装重试逻辑,避免自己实现复杂的退避算法。

数据库和外部调用的异常隔离

数据库操作是异常的高发区。连接池耗尽、查询超时、死锁、唯一约束冲突,每种异常的处理方式都不同。你需要在数据访问层统一捕获这些底层驱动错误,转换为自定义的 DatabaseError 并附加 SQL 信息和参数,方便追踪。同时,事务中的异常必须触发回滚,否则会造成数据不一致。

async function transferFunds(from, to, amount) {
  const conn = await db.getConnection();
  try {
    await conn.beginTransaction();
    await conn.query('UPDATE accounts SET balance = balance - ? WHERE id = ?', [amount, from]);
    await conn.query('UPDATE accounts SET balance = balance + ? WHERE id = ?', [amount, to]);
    await conn.commit();
  } catch (err) {
    await conn.rollback();
    throw new DatabaseError('Transfer failed', err);
  } finally {
    conn.release();
  }
}

对于 HTTP 调用外部 API,需要设置合理的超时时间,并对 DNS 解析失败、连接拒绝、响应超时、非 2xx 状态码分别处理。建议封装统一的 HTTP 客户端,内置超时、重试、熔断逻辑。当外部服务连续失败达到阈值,熔断器应该打开,直接返回降级响应,避免请求堆积拖垮自身服务。

异常处理与进程管理

Node.js 单线程模型下,未捕获的异常会污染整个进程状态。对于标记为 isOperational 为 false 的程序错误,最安全的做法是让进程退出,由进程管理器(如 PM2 或容器编排系统)重新拉起。这听起来激进,但能防止内存泄漏或状态错乱导致的更严重后果。你可以在 uncaughtException 处理器中执行优雅关闭:停止接收新请求,等待已有请求处理完毕,关闭数据库连接,然后 process.exit(1)。

process.on('uncaughtException', (err) => {
  logger.fatal('Uncaught exception, shutting down', err);
  server.close(() => {
    db.pool.end();
    process.exit(1);
  });
  // 强制超时退出
  setTimeout(() => process.exit(1), 10000);
});

但要注意,unhandledRejection 在未来 Node 版本中会强制终止进程,所以现在就应该养成习惯,对所有 Promise 进行显式错误处理,而不是依赖这个事件。

测试异常路径,而不是只测快乐路径

异常处理机制的完善程度,最终由测试覆盖来保证。单元测试中,你需要模拟各种异常场景:数据库连接断开、第三方 API 返回 500、Redis 超时、消息队列推送失败。集成测试中,通过故障注入工具验证降级逻辑和重试机制是否按预期工作。只有把异常路径当作正常功能来测试,你的异常处理体系才不是纸上谈兵。

异常处理不是一次性的配置工作,而是随着业务演进持续优化的过程。每次线上事故的复盘,都应该产出一个具体的改进项,落到错误分类、降级策略或告警规则上。让异常处理机制从被动响应,进化成主动防御,这才是 Node.js 后端项目在复杂分布式环境中稳定运行的根基。