在 Node.js 应用中构建生产级错误处理机制
了解如何对 Node.js 错误进行分类、设计自定义的错误层级结构、集中处理异步错误,以及如何保护堆栈跟踪信息以提高生产环境的稳定性。
在构建具有弹性的 Node.js 应用程序的早期阶段,你可能会将代码库组织成领域驱动型模块,并将传输相关问题与业务逻辑分开处理。这种结构上的规范固然重要,但若出现未被捕获的异常或被默默拒绝的 Promise,导致进程崩溃,它依然无法挽救局面。由于 Node.js 是在单线程事件循环上运行应用程序代码的,一个未被处理的故障就可能导致整个进程崩溃,或使其处于损坏且不可预测的状态。本部分将重点介绍适用于生产环境的错误处理及异步弹性机制。
1. 操作错误与程序错误:根本区别
在编写任何错误处理逻辑之前,你需要有一个明确的思维模型,将错误分为两类:
┌────────────────────────┐
│ Application │
│ Encountered Error │
└───────────┬────────────┘
│
┌────────────────┴────────────────┐
▼ ▼
┌───────────────────────┐ ┌───────────────────────┐
│ Operational Error │ │ Programmer Error │
├───────────────────────┤ ├───────────────────────┤
│ • Invalid Input │ │ • Syntax/Logic Bugs │
│ • Resource Not Found │ │ • Cannot read null │
│ • DB Connection Timeout│ │ • Out of memory │
│ • External API Down │ │ • Broken invariants │
└───────────┬───────────┘ └───────────┬───────────┘
│ │
▼ ▼
Handle Gracefully Log Stack Trace, Stop
(Return HTTP 4xx/5xx) Process & Let Orchestrator
(PM2/K8s) Restart Node
操作错误
操作错误是正常应用程序预期会不时遇到的故障。这类非致命事件通常由不良的用户输入、下游API断线或数据库中不存在相应记录等因素引发。
- 处理方法:捕获这些错误,将其转换为合适的状态码或域级响应,以适当的严重程度进行日志记录,同时让系统继续处理请求。
程序员错误
程序员错误则是真正的漏洞——比如尝试访问undefined对象的属性、向函数传递错误的数据类型,或是随着时间推移出现内存泄漏等问题。
- 应对措施:捕获完整的堆栈跟踪信息,通知监控系统(如 Sentry、Datadog 等),干净地终止相关进程,然后借助 Kubernetes、Docker Swarm 或 PM2 等编排工具启动新的实例。发生此类错误后绝不可继续处理请求,因为该进程的内存状态已不再可靠。
2. 构建标准化的自定义错误层次结构
普通的 JavaScript Error 对象不包含所需的元数据——没有 HTTP 状态码,没有运行状态标志,也没有特定领域的错误代码。直接抛出字符串或使用通用的 new Error('Something failed') 方式会导致错误处理机制脆弱且难以理解。
解决方案:定义基础 AppError 类及专用子类
相反,应定义一个可扩展的 AppError 基类,该类能够记录执行上下文,并通过 Error.captureStackTrace 保留原始的 V8 断点信息。
// shared/errors/AppError.js
/**
* Base Application Error
* All custom domain errors extend this class.
*/
class AppError extends Error {
constructor(message, statusCode = 500, errorCode = 'INTERNAL_ERROR', isOperational = true) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.errorCode = errorCode;
this.isOperational = isOperational;
// Retain clean stack trace in V8 engine (Node.js)
Error.captureStackTrace(this, this.constructor);
}
}
class ValidationError extends AppError {
constructor(message = 'Invalid request payload', details = []) {
super(message, 400, 'VALIDATION_ERROR', true);
this.details = details;
}
}
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} was not found`, 404, 'NOT_FOUND', true);
}
}
class UnauthorizedError extends AppError {
constructor(message = 'Authentication required') {
super(message, 401, 'UNAUTHORIZED', true);
}
}
class SystemBugError extends AppError {
constructor(message = 'Critical system error encountered') {
// Programmer errors are marked as non-operational (isOperational = false)
super(message, 500, 'CRITICAL_BUG', false);
}
}
module.exports = {
AppError,
ValidationError,
NotFoundError,
UnauthorizedError,
SystemBugError
};
这在领域逻辑中的重要性
有了这样的层次结构,您的业务服务就可以抛出清晰且具有语义意义的错误,而无需了解 HTTP 或 Web 框架:
// components/orders/orders.service.js
const { NotFoundError, ValidationError } = require('../../shared/errors/AppError');
async function cancelOrder({ orderId, userId }) {
const order = await orderRepo.findById(orderId);
if (!order) {
throw new NotFoundError('Order');
}
if (order.userId !== userId) {
throw new ValidationError('You do not have permission to cancel this order.');
}
if (order.status === 'SHIPPED') {
throw new ValidationError('Cannot cancel an order that has already shipped.');
}
return await orderRepo.updateStatus(orderId, 'CANCELLED');
}
3. 集中式的控制器错误处理与中间件
在每个路由处理器中都编写 try { ... } catch (err) { next(err); } 会导致大量重复代码,而且很容易忘记在某个地方添加捕获块。
错误做法:冗长的 Try-Catch 代码模板
// orders.controller.js
async function getOrder(req, res, next) {
try {
const order = await orderService.getOrder(req.params.id);
return res.json(order);
} catch (err) {
// Repeated in every single handler!
next(err);
}
}
正确做法:高阶异步处理器
相反,可以将控制器封装在小型异步工具函数中,或利用 Express 5 及更高版本内置的异步路由支持:
// shared/utils/asyncHandler.js
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
module.exports = asyncHandler;
// orders.controller.js
const asyncHandler = require('../../shared/utils/asyncHandler');
const orderService = require('./orders.service');
// Clean, zero try/catch boilerplate
const getOrder = asyncHandler(async (req, res) => {
const order = await orderService.getOrder(req.params.id);
res.status(200).json({ status: 'success', data: order });
});
module.exports = { getOrder };
集中式全局错误中间件
通过单一的、集中的错误处理中间件来传递所有未被捕获的操作错误,该中间件会将错误转换为统一的 JSON 响应格式,以便 API 使用。
// shared/middleware/errorHandler.js
const { logger } = require('../logger');
const { AppError } = require('../errors/AppError');
function globalErrorHandler(err, req, res, next) {
err.statusCode = err.statusCode || 500;
err.errorCode = err.errorCode || 'INTERNAL_SERVER_ERROR';
// Log all errors internally
if (err.isOperational) {
logger.warn(`[Operational Error] ${err.name} (${err.errorCode}): ${err.message}`);
} else {
logger.error(`[CRITICAL PROGRAMMER ERROR] ${err.stack}`);
}
// Response for Operational Errors
if (err.isOperational) {
return res.status(err.statusCode).json({
status: 'error',
code: err.errorCode,
message: err.message,
...(err.details && { details: err.details })
});
}
// Generic Response for Programmer/System Failures (Hide internal stacks in production)
return res.status(500).json({
status: 'error',
code: 'INTERNAL_SERVER_ERROR',
message: process.env.NODE_ENV === 'production'
? 'An unexpected error occurred on our server.'
: err.message
});
}
module.exports = globalErrorHandler;
4. 在异步边界之间保留堆栈跟踪
在 Node.js 中,会影响调试和性能的一个较为隐蔽的错误是:如果在 try/catch 块或异步函数内部直接返回 Promise 而未先等待其执行,就会导致堆栈跟踪丢失。
堆栈跟踪陷阱:return 与 return await
在普通的 JavaScript 中,如果在 async 函数内部直接返回一个承诺而不等待其结果,那么一旦该承诺在后续处理过程中被拒绝,就会跳过该函数的异步调用上下文。
危险:在 Try/Catch 中返回未等待的承诺
// order.repository.js
async function findOrderById(id) {
try {
// ❌ BAD: Returning promise directly inside try block.
// If db.query fails, the catch block in THIS function will NOT execute!
return db.query('SELECT * FROM orders WHERE id = $1', [id]);
} catch (err) {
logger.error('Failed to query order database', err);
throw new CustomDatabaseError(err.message);
}
}
为什么这会引发问题?因为 db.query 会返回一个尚未处理的承诺,而 findOrderById 会立即返回该未处理的承诺给调用它的代码。等到该承诺真正被拒绝时,findOrderById 内部定义的 catch 块已经不在调用栈中,因此永远不会被执行。
正确做法:在返回前显式等待
// order.repository.js
async function findOrderById(id) {
try {
// ✅ GOOD: Awaiting resolves or rejects WITHIN this async frame.
return await db.query('SELECT * FROM orders WHERE id = $1', [id]);
} catch (err) {
logger.error('Failed to query order database', err);
throw new CustomDatabaseError(err.message);
}
}
一个经验法则:在任何使用 try/catch 的 async 函数中,当调用嵌套的异步操作时,务必写入 return await。这样既能保持栈帧的完整性,又能确保在出现异常时本地清理或日志记录代码能够真正执行。
5. 运行时层面的优雅进程终止与安全机制
Node.js 提供了两个进程级钩子,可在其他所有防护措施都失效时让程序作出响应:uncaughtException 和 unhandledRejection。
处理进程级故障
// server.js
const app = require('./app');
const { logger } = require('./shared/logger');
const PORT = process.env.PORT || 3000;
const server = app.listen(PORT, () => {
logger.info(`Server running on port ${PORT}`);
});
// 1. Intercept Unhandled Promise Rejections
process.on('unhandledRejection', (reason, promise) => {
logger.error('UNHANDLED REJECTION! 💥 Shutting down...', reason);
// Trigger graceful shutdown
gracefulShutdown(1);
});
// 2. Intercept Uncaught Exceptions (Programmer Errors)
process.on('uncaughtException', (error) => {
logger.error('UNCAUGHT EXCEPTION! 💥 Shutting down...', error);
// Trigger graceful shutdown immediately
gracefulShutdown(1);
});
// 3. Graceful Shutdown Flow
function gracefulShutdown(exitCode = 0) {
logger.info('Closing HTTP server and cleaning up active connections...');
server.close(async () => {
try {
// Close Database Connections, Redis clients, Message Consumers
await db.disconnect();
await redis.quit();
logger.info('All database connections closed cleanly.');
process.exit(exitCode);
} catch (err) {
logger.error('Error during shutdown:', err);
process.exit(1);
}
});
// Force shutdown after 10 seconds if connections refuse to close
setTimeout(() => {
logger.error('Forced shutdown due to timeout.');
process.exit(1);
}, 10000);
}
弹性审计检查清单
在制定测试策略之前,请根据以下要点检查您的代码库:
- 错误分类:您是否明确区分了运行故障与程序员编写的错误?
AppError 实例,而非普通字符串?try/catch 块中使用 return await,以确保异步堆栈帧保持完整?uncaughtException 和 unhandledRejection,关闭数据库连接并干净地退出,以便进程管理器能够重新启动它?相关阅读
- 防止生产环境服务器宕机的20种Node.js模式 — 了解从错误处理到优雅关闭、连接池管理等方面的20种实用Node.js模式,这些模式能在不得不重启之前避免系统崩溃。
- process.nextTick()如何悄悄导致Node.js事件循环阻塞 — 解释为何递归调用process.nextTick()会完全阻塞libuv的轮询阶段,以及如何使用setImmediate()来解决事件循环阻塞问题。