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 并触发告警,超时错误走重试。混在一起处理,要么用户体验差,要么运维被淹没。
四、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 就能交差的。从同步到异步、从框架中间件到进程保活、从日志关联到分布式追踪,每一层都要各尽其责。把这套体系搭好,服务才能在真实世界的风吹雨打里站稳脚跟。
容错设计没有银弹,核心思路是“快速失败、控制半径、留有退路”。下次你写接口时,不妨多问一句:如果这个依赖挂了,我的服务会变成什么样?答案越清晰,系统就越稳,运维也能睡个好觉。