Node.js 生产级错误处理与优雅降级机制设计

发布时间:2026/7/7 9:56:24

Node.js 生产级错误处理与优雅降级机制设计 Node.js 生产级错误处理与优雅降级机制设计一、Node.js 错误处理的常见误区服务端错误处理的质量直接决定系统的可靠性。在实际项目中常见以下误区误区一用 try-catch 包裹所有代码。过度使用 try-catch 会掩盖真正的逻辑错误让程序在异常状态下继续运行产生错上加错的结果。误区二依赖process.on(uncaughtException)兜底。这个处理器是在进程已经处于不确定状态时触发的。按照 Node.js 官方建议捕获后应立即执行优雅退出而不是尝试恢复。误区三Promise 链缺少 catch。未处理的 Promise rejection 在 Node.js 15 之后会导致进程退出。这在实际生产中已经引发过多起事故。误区四错误日志信息不足。仅记录error.message而忽略error.stack和上下文信息导致排查困难。flowchart TB A[请求进入] -- B{同步代码} B --|异常| C[try-catch 捕获] B --|正常| D{异步操作} C -- E[记录错误上下文] E -- F[返回标准化错误响应] D --|reject| G[.catch 捕获] D --|resolve| H{业务逻辑} G -- E H --|业务异常| I[自定义错误类] H --|成功| J[返回成功响应] I -- E二、错误分类与自定义错误体系生产环境中的错误需要分层分类。不同类别的错误需要不同的处理策略。// 错误基类 abstract class AppError extends Error { abstract readonly statusCode: number; abstract readonly errorCode: string; abstract readonly isOperational: boolean; constructor(message: string) { super(message); this.name this.constructor.name; Error.captureStackTrace(this, this.constructor); } } // 客户端错误 (4xx) class BadRequestError extends AppError { readonly statusCode 400; readonly errorCode BAD_REQUEST; readonly isOperational true; } class UnauthorizedError extends AppError { readonly statusCode 401; readonly errorCode UNAUTHORIZED; readonly isOperational true; } class NotFoundError extends AppError { readonly statusCode 404; readonly errorCode NOT_FOUND; readonly isOperational true; } class ValidationError extends AppError { readonly statusCode 422; readonly errorCode VALIDATION_ERROR; readonly isOperational true; readonly details: Recordstring, string[]; constructor(message: string, details: Recordstring, string[] {}) { super(message); this.details details; } } // 服务端错误 (5xx) class InternalError extends AppError { readonly statusCode 500; readonly errorCode INTERNAL_ERROR; readonly isOperational false; } // 外部依赖错误 class ExternalServiceError extends AppError { readonly statusCode 502; readonly errorCode EXTERNAL_SERVICE_ERROR; readonly isOperational true; readonly serviceName: string; constructor(serviceName: string, message: string) { super(${serviceName}: ${message}); this.serviceName serviceName; } }frame是关键标志。操作性错误如参数校验失败、外部服务超时是预期的异常可以返回给客户端。程序性错误如类型错误、空指针是代码 bug不应暴露内部细节给客户端。常见踩坑记录在实际项目中自定义错误体系最容易踩以下坑坑一忘记设置Error.captureStackTrace。在 TypeScript/ES6 中继承Error的子类如果不调用Error.captureStackTrace(this, this.constructor)堆栈信息会指向错误类本身而不是抛出错误的位置导致排查时无法定位源码行号。上述代码中已经在基类构造函数里正确处理了这一点。坑二instanceof在多模块场景下失效。如果错误类在errors.ts中定义但在不同子模块中分别import某些打包工具如 webpack 的模块隔离可能导致instanceof检查失败。解决方案是在错误对象上附加一个kind字符串标识用error.kind AppError做兜底判断。坑三错误序列化时丢失原型链信息。将错误对象通过JSON.stringify上报到日志系统后再反序列化时instanceof AppError会返回false。需要在上报前将错误的关键字段statusCode、errorCode、isOperational显式提取到普通对象中。在实际项目中使用自定义错误体系在业务逻辑层中使用自定义错误类可以让错误处理更加清晰。例如在用户服务中import { NotFoundError, ValidationError, ExternalServiceError } from ./errors; async function getUserProfile(userId: string): PromiseUserProfile { if (!userId || typeof userId ! string) { throw new ValidationError(用户ID格式错误, { userId: [用户ID必须是非空字符串], }); } const user await db.users.findById(userId); if (!user) { throw new NotFoundError(用户不存在: ${userId}); } try { const avatarUrl await storageService.getSignedUrl(user.avatarKey); return { ...user, avatarUrl }; } catch (error) { // 外部服务对象存储调用失败返回降级数据 throw new ExternalServiceError( storage-service, 获取用户头像失败: ${error instanceof Error ? error.message : 未知错误}, ); } }在控制器层不需要写 try-catch错误会沿着调用栈向上传递最终被统一错误处理中间件捕获。这避免了在每个路由中重复写错误处理代码。错误日志的结构化输出在生产环境中错误日志需要包含足够的上下文信息用于排查。推荐的结构化日志格式interface ErrorLogEntry { timestamp: string; level: error | fatal; errorCode: string; message: string; stack?: string; request?: { method: string; url: string; requestId: string; userId?: string; userAgent?: string; }; metadata?: Recordstring, unknown; } function logError(error: Error, req?: Request): void { const entry: ErrorLogEntry { timestamp: new Date().toISOString(), level: error instanceof AppError !error.isOperational ? fatal : error, errorCode: error instanceof AppError ? error.errorCode : UNKNOWN_ERROR, message: error.message, stack: error.stack, }; if (req) { entry.request { method: req.method, url: req.originalUrl, requestId: req.headers[x-request-id] as string || unknown, userId: (req as any).user?.id, userAgent: req.get(user-agent), }; } // 程序性错误立即触发告警 if (error instanceof AppError !error.isOperational) { triggerAlert(entry); } console.error(JSON.stringify(entry)); }这种结构化格式便于日志分析工具如 ELK、Datadog进行检索和聚合。可以按errorCode维度统计各类错误的发生频率快速定位最高频的错误类型。flowchart LR subgraph 错误类型 A[Operational Error] -- A1[参数校验失败] A -- A2[资源不存在] A -- A3[外部服务超时] A -- A4[认证/授权失败] B[Programmer Error] -- B1[类型错误] B -- B2[空指针] B -- B3[断言失败] end subgraph 处理策略 A1 -- C1[返回 4xx 错误详情] A2 -- C1 A3 -- C2[返回 502 服务名] A4 -- C3[返回 401/403] B1 -- D1[记录完整堆栈] B2 -- D1 B3 -- D1 D1 -- D2[返回 500 通用消息] end三、Express/Koa 中间件错误处理在 Web 框架层需要一个统一的错误处理中间件来接管所有未被局部捕获的错误。Express 版本的完整实践在实际项目中Express 错误处理中间件需要注意以下几个细节第一中间件必须注册在所有业务路由之后且参数签名必须是四个err, req, res, next少一个参数 Express 就不会将其识别为错误处理中间件。第二对于流式响应如文件下载、SSE错误信息无法通过 JSON 返回需要在响应头已发送的情况下做特殊处理function errorHandler(err: Error, req: Request, res: Response, _next: NextFunction): void { // 如果响应头已发送如流式场景只能记录日志并结束响应 if (res.headersSent) { console.error([Error] 响应头已发送无法返回错误 JSON:, { message: err.message, url: req.originalUrl, }); res.end(); return; } // ... 原有处理逻辑 }第三对于大文件上传场景需要在multer等中间件之前注册错误处理器否则文件大小超限的错误会被 Express 默认错误处理器捕获返回 HTML 格式的错误页。Koa 版本的核心逻辑类似但使用中间件的不同模式Koa 的错误处理有两种模式中间件try/catch模式和应用级error事件模式。推荐两者结合使用import { Context, Next } from koa; // 模式一中间件捕获处理请求级的错误 async function errorMiddleware(ctx: Context, next: Next): Promisevoid { try { await next(); } catch (err) { const error err as Error; ctx.status error instanceof AppError ? error.statusCode : 500; ctx.body { error: { code: error instanceof AppError ? error.errorCode : INTERNAL_ERROR, message: error instanceof AppError error.isOperational ? error.message : 服务器内部错误, requestId: ctx.get(x-request-id) || undefined, }, }; // 触发应用层面的错误事件用于日志上报 ctx.app.emit(error, error, ctx); } } // 模式二应用级错误监听处理未进入中间件的错误如 JSON 解析失败 app.on(error, (error: Error, ctx: Context) { const entry { timestamp: new Date().toISOString(), message: error.message, stack: error.stack, method: ctx.method, url: ctx.url, requestId: ctx.get(x-request-id), }; console.error(JSON.stringify(entry)); // 程序性错误触发告警 if (error instanceof AppError !error.isOperational) { triggerAlert(entry); } });踩坑记录Koa 与 koa/router 的错误传播Koa 的中间件链中如果某个中间件没有await next()后续中间件不会执行错误也不会传播到外围的错误处理中间件。常见的坑是在自定义中间件中忘记了await// ❌ 错误写法忘记 await错误无法被外层捕获 app.use(async (ctx, next) { next(); // 没有 await }); // ✅ 正确写法 app.use(async (ctx, next) { await next(); });此外Koa 的ctx.throw()方法抛出的错误是HttpError类型不是我们定义的AppError。需要在错误处理中间件中额外判断if (error instanceof HttpError) { ctx.status error.status; ctx.body { error: { code: HTTP_${error.status}, message: error.message } }; return; }四、优雅降级与容错设计优雅降级的核心理念是局部失败不影响整体。外部服务降级的三种策略实际项目中的外部服务调用降级通常需要组合以下三种策略策略一缓存兜底已展示的fetchWithFallback。适用于读取型接口如获取用户信息、配置数据等。关键是合理设置 TTL避免返回过期数据导致业务逻辑错误。策略二默认值降级。适用于非核心功能如相关推荐模块调用失败可以返回空列表而不影响主流程async function getRecommendations(userId: string): PromiseRecommendation[] { try { const result await recommendationService.fetch(userId); return result; } catch (error) { console.warn([降级] 推荐服务不可用返回空列表:, error.message); // 监控告警降级次数超过阈值需要人工介入 degradationCounter.inc({ service: recommendation }); return []; } }策略三熔断机制。当外部服务连续失败时主动快速失败避免资源耗尽。实现一个简单的熔断器class CircuitBreaker { private state: closed | open | half-open closed; private failureCount 0; private nextAttemptTime 0; constructor( private readonly failureThreshold: number 5, private readonly resetTimeoutMs: number 60_000, ) {} async executeT(fn: () PromiseT): PromiseT { if (this.state open) { if (Date.now() this.nextAttemptTime) { throw new ExternalServiceError(circuit-breaker, 熔断器开启快速失败); } this.state half-open; } try { const result await fn(); this.onSuccess(); return result; } catch (error) { this.onFailure(); throw error; } } private onSuccess(): void { this.failureCount 0; this.state closed; } private onFailure(): void { this.failureCount; if (this.failureCount this.failureThreshold) { this.state open; this.nextAttemptTime Date.now() this.resetTimeoutMs; } } }踩坑记录降级本身也可能出错降级逻辑中的cache.get或console.warn也可能抛出异常如 Redis 连接断开。如果降级代码没有额外保护会导致二次异常反而让错误更难排查。正确的做法是降级逻辑本身也要有 try-catchasync function fetchWithFallbackSafeT(fetcher: () PromiseT, cacheKey: string): PromiseT | null { try { return await fetcher(); } catch (error) { // 降级逻辑本身也要捕获异常 try { const cached cache.get(cacheKey); if (cached) return cached; } catch (cacheError) { console.error([降级] 缓存读取失败:, cacheError); } return null; } }外部服务降级interface CacheEntryT { data: T; timestamp: number; } async function fetchWithFallbackT( fetcher: () PromiseT, cacheKey: string, fallbackValue: T | null null, ttl: number 300_000, // 5 分钟 ): PromiseT | null { try { const data await fetcher(); // 成功后缓存数据 cache.set(cacheKey, { data, timestamp: Date.now(), }); return data; } catch (error) { console.warn([fallback] 外部服务调用失败: ${error}); // 尝试使用缓存数据 const cached cache.get(cacheKey) as CacheEntryT | undefined; if (cached Date.now() - cached.timestamp ttl) { console.info([fallback] 使用缓存数据(key${cacheKey})); return cached.data; } // 缓存也无可用数据返回指定的降级值 return fallbackValue; } }数据库连接池健康检查import { Pool } from pg; class DatabaseHealthCheck { private pool: Pool; private isHealthy true; private checkInterval: NodeJS.Timeout | null null; constructor(pool: Pool) { this.pool pool; } start(intervalMs: number 30_000): void { this.checkInterval setInterval(async () { try { await this.pool.query(SELECT 1); this.isHealthy true; } catch (error) { console.error([DB Health] 连接检查失败:, error); this.isHealthy false; } }, intervalMs); } async query(text: string, params?: unknown[]): Promiseunknown { if (!this.isHealthy) { throw new ExternalServiceError(database, 连接池不健康); } try { return await this.pool.query(text, params); } catch (error) { this.isHealthy false; throw new ExternalServiceError( database, error instanceof Error ? error.message : 查询执行失败, ); } } stop(): void { if (this.checkInterval) { clearInterval(this.checkInterval); this.checkInterval null; } } }进程级别的安全退出import { Server } from http; function setupGracefulShutdown(server: Server, pool: Pool): void { const shutdown async (signal: string) { console.info([Shutdown] 收到 ${signal} 信号开始优雅退出); // 步骤1停止接收新请求 server.close(() { console.info([Shutdown] HTTP 服务已关闭); }); // 步骤2等待现有请求处理完成最多 10 秒 const forceExit setTimeout(() { console.error([Shutdown] 强制退出超时); process.exit(1); }, 10_000); forceExit.unref(); // 步骤3关闭数据库连接池 try { await pool.end(); console.info([Shutdown] 数据库连接已关闭); } catch (error) { console.error([Shutdown] 数据库连接关闭失败:, error); } clearTimeout(forceExit); process.exit(0); }; process.on(SIGTERM, () shutdown(SIGTERM)); process.on(SIGINT, () shutdown(SIGINT)); // 兜底未捕获异常立即退出 process.on(uncaughtException, (error) { console.error([Fatal] 未捕获异常:, error); process.exit(1); }); process.on(unhandledRejection, (reason) { console.error([Fatal] 未处理 Promise rejection:, reason); process.exit(1); }); }五、总结Node.js 生产级错误处理的核心是分类处理、统一出口、优雅降级。通过自定义错误类体系区分操作性错误和程序性错误前者可安全返回给客户端后者只记录内部日志。统一错误处理中间件确保所有异常都有标准化的响应格式。外部依赖调用必须配备降级方案缓存兜底或返回默认值。进程级别的退出策略要保证先停止接收新请求再等待现有请求完成最后清理资源。

相关新闻