Node

Node.js 错误处理与优雅降级实战:从 try/catch 到分布式追踪的完整方案

✎ -- 字 🕐 -- 分钟
字号

Node.js 错误处理与优雅降级实战

Node.js 服务一旦报错,后果往往比想象中严重:一个未捕获的异常就能让整个进程退出,一次数据库抖动可能拖垮整条链路。今天我们就把错误处理这件事从“加 try/catch”这个层面拔高到“系统级容错”,聊聊怎么让服务在出错时还能体面地活着。

一、Node.js 错误处理的特殊性

浏览器里抛错,最多影响一个标签页;Node.js 里抛错,可能影响整个进程。原因有三:

  • 单线程事件循环:JavaScript 主线程一旦崩溃,整个进程跟着挂。
  • 异步回调分散:错误栈经常断裂,try/catch 包不住异步代码。
  • I/O 依赖多:数据库、缓存、第三方 API 任何一个抖动,都会放大故障面。

所以 Node.js 的错误处理不是“捕获一下”就完事,而是要解决三个问题:怎么捕、怎么记、怎么不扩散

举个真实场景:某次促销期间,一个下游优惠券接口响应变慢,请求堆积导致事件循环阻塞,接着内存飙升,最后进程 OOM 退出。如果当时有超时熔断和快速失败,最多损失部分优惠券展示;但因为只在入口处加了 try/catch,异常没被真正处理,结果整个服务挂了半分钟。这种“单点故障放大为全局故障”的案例,在高并发 Node.js 服务里并不少见。

二、同步与异步异常捕获基础

2.1 同步代码直接用 try/catch

function parseConfig(path) {
  try {
    const raw = fs.readFileSync(path, 'utf8');
    return JSON.parse(raw);
  } catch (err) {
    // 捕获后做降级:返回默认配置
    logger.warn({ err }, 'config read failed, using fallback');
    return { port: 3000, env: 'production' };
  }
}

2.2 异步代码的四种捕获方式

异步是重灾区。下面这张表把常见写法列清楚了。

写法是否能捕获说明
try/catch + await推荐,async/await 让异步代码像同步一样写
.catch()链式调用时别忘了 return
callback 的 err 参数老 API 用,容易漏判
try/catch 包 Promise不能Promise 在下一轮事件循环才执行
// 推荐写法
async function getUser(id) {
  try {
    const user = await db.user.findByPk(id);
    return user;
  } catch (err) {
    throw new AppError('DB_QUERY_FAILED', 500, { cause: err });
  }
}

// 容易踩坑的写法
function badGetUser(id) {
  try {
    db.user.findByPk(id).then(user => user);
  } catch (err) {
    // 这里永远捕不到
    console.error(err);
  }
}

三、错误分类与恢复策略

不是所有错误都值得用同一种方式处理。我一般把错误分成四类,每类对应不同的恢复策略。

错误类型例子恢复策略
可预期的业务错误参数非法、库存不足、权限不够返回明确错误码,前端按码提示
可恢复的瞬时故障网络超时、数据库连接闪断指数退避重试,失败后降级
下游持续故障支付网关整体挂掉熔断 + 兜底 + 补偿对账
不可恢复的系统错误内存溢出、代码 bug记录日志,退出进程,由编排器重启

分类的意义在于避免“一刀切”。业务错误返回 400 让用户改,系统错误返回 500 并触发告警,超时错误走重试。混在一起处理,要么用户体验差,要么运维被淹没。

Node.js 生产级错误处理分层架构

四、Express / Koa 错误中间件实战

Web 框架的错误中间件是最后一道兜底防线。它的核心职责是:统一格式化、脱敏、记录、返回

3.1 Express 错误中间件

class AppError extends Error {
  constructor(code, status, message, meta = {}) {
    super(message);
    this.code = code;
    this.status = status;
    this.meta = meta;
    Error.captureStackTrace(this, this.constructor);
  }
}

// 业务路由里直接 throw
app.get('/api/orders/:id', async (req, res, next) => {
  const order = await orderService.get(req.params.id);
  if (!order) throw new AppError('ORDER_NOT_FOUND', 404, '订单不存在');
  res.json(order);
});

// 统一错误处理中间件,必须放在所有路由之后
app.use((err, req, res, next) => {
  const isDev = process.env.NODE_ENV === 'development';
  const status = err.status || 500;
  const code = err.code || 'INTERNAL_ERROR';

  logger.error({
    reqId: req.id,
    code,
    status,
    path: req.path,
    method: req.method,
    err: isDev ? err : undefined,
    message: err.message,
  }, 'request error');

  res.status(status).json({
    code,
    message: status >= 500 && !isDev ? '服务器繁忙,请稍后重试' : err.message,
    ...(isDev && { stack: err.stack }),
  });
});

3.2 Koa 的洋葱模型错误处理

app.use(async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    ctx.status = err.status || 500;
    ctx.body = {
      code: err.code || 'INTERNAL_ERROR',
      message: err.message,
    };
    ctx.app.emit('error', err, ctx);
  }
});

注意 Koa 推荐把日志交给 app.on('error'),不要在中间件里直接打印,否则可能重复记录。

五、未捕获异常与未处理的 Promise 拒绝

即便中间件再完善,也总有一些异常会溜走。比如事件监听器里的同步抛错、顶层 await 的拒绝。Node.js 提供了两个全局钩子。

process.on('uncaughtException', (err) => {
  logger.fatal({ err }, 'uncaught exception');
  // 必须退出,否则进程状态不可信
  process.exit(1);
});

process.on('unhandledRejection', (reason, promise) => {
  logger.error({ reason }, 'unhandled rejection');
  // 不要静默吞掉,至少给个标记
});

生产环境铁律:uncaughtException 发生后必须退出进程,交给 PM2、systemd 或 Kubernetes 重启。试图在钩子里继续运行,等于给自己埋雷。

配合优雅退出很重要。收到 SIGTERM 后,先停止接收新连接,等现有请求处理完再退出,避免直接把处理到一半的请求掐断。

process.on('SIGTERM', () => {
  logger.info('SIGTERM received, closing server gracefully');
  server.close(async () => {
    await db.close();
    await cache.quit();
    process.exit(0);
  });
});

六、优雅降级:熔断、兜底、重试

错误处理的高阶目标不是“不出错”,而是“出错后服务还能用”。这就需要三板斧。

5.1 重试:别让偶发抖动搞垮你

async function fetchWithRetry(url, options = {}, maxRetry = 3) {
  let lastErr;
  for (let i = 0; i <= maxRetry; i++) {
    try {
      return await fetch(url, options);
    } catch (err) {
      lastErr = err;
      if (i < maxRetry) {
        const delay = Math.min(1000 * 2 ** i, 8000);
        await new Promise(r => setTimeout(r, delay));
      }
    }
  }
  throw lastErr;
}

重试要配指数退避和抖动,别在对方已经过载时还猛冲。

5.2 熔断:防止级联故障

class CircuitBreaker {
  constructor(threshold = 5, timeout = 30000) {
    this.failureCount = 0;
    this.threshold = threshold;
    this.timeout = timeout;
    this.state = 'CLOSED'; // CLOSED OPEN HALF_OPEN
    this.nextAttempt = Date.now();
  }

  async call(fn) {
    if (this.state === 'OPEN') {
      if (Date.now() < this.nextAttempt) {
        throw new Error('CIRCUIT_OPEN');
      }
      this.state = 'HALF_OPEN';
    }

    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (err) {
      this.onFailure();
      throw err;
    }
  }

  onSuccess() {
    this.failureCount = 0;
    this.state = 'CLOSED';
  }

  onFailure() {
    this.failureCount += 1;
    if (this.failureCount >= this.threshold) {
      this.state = 'OPEN';
      this.nextAttempt = Date.now() + this.timeout;
    }
  }
}

5.3 兜底数据:缓存与默认值

async function getRecommendations(userId) {
  try {
    return await mlService.recommend(userId);
  } catch (err) {
    logger.warn({ userId, err }, 'ml service down, fallback to hot list');
    return await cache.get('hot:recommendations') || [];
  }
}
策略适用场景注意事项
重试网络抖动、偶发超时只对幂等接口做;非幂等用补偿或查询状态
熔断下游持续不可用半开状态要控制流量,避免瞬间打满
兜底体验优先、非核心数据默认值要有业务含义,不能返回空数组糊弄

七、错误日志:结构化、关联 ID、采样

日志不是越多越好,而是要能串起来。关键字段包括:请求 ID、用户 ID、错误码、调用链、时间戳。

const pino = require('pino');
const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  base: { service: 'order-service', version: '1.2.0' },
  serializers: pino.stdSerializers,
  redact: ['req.headers.authorization', 'user.password'],
});

// 每次请求挂载 child logger
app.use((req, res, next) => {
  req.id = req.get('x-request-id') || crypto.randomUUID();
  req.log = logger.child({ reqId: req.id });
  res.setHeader('x-request-id', req.id);
  next();
});

生产环境建议用 JSON 格式输出,方便日志平台解析。同时开启错误采样,避免高并发时日志把磁盘打满。

八、分布式追踪:从 Trace ID 到 OpenTelemetry

微服务时代,一个请求可能经过网关、BFF、订单服务、库存服务、支付服务。只看单点日志,根本定位不了问题。分布式追踪给每个请求一个 Trace ID,把整条链路串起来。

7.1 手动传递 Trace ID

async function callInventory(ctx, payload) {
  return fetch('http://inventory/api/deduct', {
    method: 'POST',
    headers: {
      'x-trace-id': ctx.traceId,
      'x-span-id': generateSpanId(),
      'content-type': 'application/json',
    },
    body: JSON.stringify(payload),
  });
}

7.2 OpenTelemetry 自动埋点

npm install @opentelemetry/sdk-node \
  @opentelemetry/auto-instrumentations-node \
  @opentelemetry/exporter-trace-otlp-http
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');

const sdk = new NodeSDK({
  traceExporter: new OTLPTraceExporter({ url: 'http://jaeger:4318/v1/traces' }),
  instrumentations: [getNodeAutoInstrumentations()],
});

sdk.start();

启用后,http、express、mongodb、redis 等模块会自动生成 span,错误请求在 Jaeger 或 Grafana Tempo 上一眼就能看到是哪一步红了。

九、错误码设计与客户端协作

好的错误码能让前端或下游服务快速知道该重试、降级还是提示用户。建议三段式:服务域_模块_具体错误

AUTH_TOKEN_EXPIRED    // 认证令牌过期,客户端应跳转登录
ORDER_STOCK_SHORTAGE  // 库存不足,前端提示用户
PAY_GATEWAY_TIMEOUT   // 支付网关超时,后端应重试或补偿
DB_CONNECTION_LOST    // 数据库连接丢失,直接 500

同时给每个错误响应加上 requestId,用户截图反馈时,能在日志里一秒定位。

十、实战:电商下单链路的容错设计

假设用户点击“提交订单”,后端要扣库存、创建订单、调用支付。任何一步都可能失败。

async function submitOrder(ctx, params) {
  const traceId = ctx.traceId;
  const orderId = generateOrderId();

  // 1. 预扣库存:失败直接返回,保证一致性
  const stockOk = await inventoryService.tryDeduct(params.skuId, params.qty, traceId);
  if (!stockOk) throw new AppError('ORDER_STOCK_SHORTAGE', 409, '库存不足');

  try {
    // 2. 创建订单
    const order = await orderRepo.create({ id: orderId, ...params });

    // 3. 调用支付:带重试和熔断
    const payResult = await paymentBreaker.call(() =>
      payService.create(order, traceId)
    );

    return { orderId, payUrl: payResult.url };
  } catch (err) {
    // 补偿回滚库存
    await inventoryService.rollback(params.skuId, params.qty, traceId);
    throw err;
  }
}

这个例子同时用到了参数校验、库存预扣、熔断、重试、补偿回滚五种手段。真正线上跑的时候,还要给支付创建接口配一个定时对账任务,兜底掉单。

十一、Sentry 集成:自动收集与告警

自己写错误上报容易漏,Sentry 这类服务能帮我们把异常聚合、去重、告警一站式解决。

npm install @sentry/node @sentry/profiling-node
const Sentry = require('@sentry/node');

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  environment: process.env.NODE_ENV,
  tracesSampleRate: 0.1,
  profilesSampleRate: 0.1,
});

// Express 里放在最前面
app.use(Sentry.Handlers.requestHandler());
app.use(Sentry.Handlers.tracingHandler());

// 路由之后
app.use(Sentry.Handlers.errorHandler());

Sentry 会自动把相同堆栈的异常归为一类,并给出影响用户数、首次出现时间、趋势图,省得我们翻日志翻到手软。

十二、错误预算与监控告警

Google SRE 里有个概念叫“错误预算”:服务允许在一定周期内出现少量错误,超出预算才触发告警。比如每月 99.9% 可用性,对应错误预算约为 43 分钟。

# Prometheus 告警规则示例
groups:
  - name: node-service
    rules:
      - alert: HighErrorRate
        expr: |
          sum(rate(http_requests_total{status=~"5.."}[5m]))
          /
          sum(rate(http_requests_total[5m])) > 0.05
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "服务 {{ $labels.service }} 5xx 比例超过 5%"

告警阈值别设太低,否则很快就会被淹没。错误预算的好处是让我们对稳定性有量化认知,而不是一拍脑袋定指标。

十三、测试错误路径:让容错代码真正跑过

很多人只测试 happy path,结果熔断代码一次都没触发过,真到线上反而不敢信。建议用下面几种方式主动制造故障。

  • 端口阻断:用 iptables 或 toxiproxy 模拟下游超时。
  • 依赖故障注入:在测试里把数据库连接串故意改错。
  • Chaos Monkey:随机杀 Pod,验证服务能否自愈。
  • 契约测试:保证下游接口变更时,本方错误处理逻辑还能匹配。
// 用 nock 模拟下游 500
test('should fallback when inventory service returns 500', async () => {
  nock('http://inventory')
    .post('/api/deduct')
    .reply(500, { error: 'db timeout' });

  const res = await request(app)
    .post('/api/orders')
    .send({ skuId: 1001, qty: 1 });

  expect(res.status).toBe(503);
  expect(res.body.code).toBe('INVENTORY_UNAVAILABLE');
});

十四、常见陷阱与生产 Checklist

最后列一些我踩过的坑。

  • 不要吞掉错误:catch 里什么都不做,等于埋雷。至少要记日志。
  • 区分可恢复与不可恢复:网络超时可以重试,磁盘满、代码 bug 要直接报错退出。
  • 错误信息不要暴露给客户端:500 错误返回“服务器繁忙”,具体堆栈只留在日志里。
  • 测试你的错误路径:用 chaos-monkey 或简单的端口阻断,验证熔断和降级是否生效。
  • 监控告警要覆盖错误率:P99 延迟重要,但 5xx 比例突然飙升更要第一时间知道。
  • 别让兜底变成常态:降级只是保命,故障恢复后要有补偿和修复机制。
  • 留意内存泄漏:错误处理里如果持有大量上下文,长期运行会拖慢服务。
检查项建议做法
全局异常兜底Express/Koa 错误中间件 + uncaughtException/unhandledRejection
错误码规范统一 { code, message, requestId } 格式
日志脱敏pino redact 或 winston filter 处理敏感字段
降级演练每月人为关闭一个依赖,观察服务表现
链路追踪OpenTelemetry + Jaeger/Tempo,覆盖率 > 90%
异常聚合Sentry 或自建异常平台,按堆栈去重
错误预算按 SLO 设定告警阈值,避免狼来了

结语

Node.js 的错误处理不是写几个 try/catch 就能交差的。从同步到异步、从框架中间件到进程保活、从日志关联到分布式追踪,每一层都要各尽其责。把这套体系搭好,服务才能在真实世界的风吹雨打里站稳脚跟。

容错设计没有银弹,核心思路是“快速失败、控制半径、留有退路”。下次你写接口时,不妨多问一句:如果这个依赖挂了,我的服务会变成什么样?答案越清晰,系统就越稳,运维也能睡个好觉。