异常捕获与崩溃恢复
在 Node.js 中,一个未捕获的异常可能导致整个单线程进程崩溃,从而中断所有正在处理的并发请求。因此,完善的异常捕获机制与崩溃时的优雅退出策略,是保证后端服务高可用性的基石。
一、同步与异步异常的捕获陷阱
Node.js 初学者最容易踩的坑,就是误以为 try...catch 能捕获所有错误。
1. 同步代码的捕获
对于纯同步代码,try...catch 是完美的:
try {
JSON.parse('{ bad json }');
} catch (err) {
// 这里能稳稳接住 SyntaxError
logger.error('JSON parsing failed', err);
}
2. 回调风格(Callback)异步代码的陷阱
如果是早期的回调风格异步,try...catch 完全无效:
try {
fs.readFile('/not-exist', (err, data) => {
if (err) throw err; // 这里的 throw 会直接导致进程崩溃!
});
} catch (err) {
// 永远执行不到这里,因为执行回调时,原来的调用栈早就退出了
}
正确做法:在回调函数内部处理 err,或者遵循 Node.js 的 Error-First Callback 约定往上层抛。
3. Promise / Async Await 风格的陷阱
现代 Node.js 广泛使用 Promise,但如果没有加 await 或 .catch(),异常同样会漏掉:
async function doSomething() {
// 陷阱:这里没有加 await
Promise.reject(new Error('boom'));
}
try {
await doSomething();
} catch (err) {
// 接不到上述错误,因为 Promise 是悬空的 (dangling)
}
这会触发著名的 unhandledRejection 事件。
二、全局异常兜底(最后一道防线)
如果代码中漏写了 try...catch 或者 .catch(),异常就会冒泡到全局。Node.js 提供了两个全局事件来进行兜底捕获。
高级连环问:为什么 uncaughtException 发生后,最佳实践是强制退出进程(process.exit(1)),而不是当做没发生继续运行?
答:因为当抛出 uncaughtException 时,意味着应用已经处于不可预测的状态。例如:某个全局对象可能被污染、某个数据库连接可能没有释放、某个内存泄漏正在发生。如果不退出进程,接下来的请求可能会得到错误的数据,或者导致更严重的内存溢出。正确做法是:“让它崩溃” (Let it crash),记录日志后退出,依靠进程守护工具自动拉起一个干净的新实例。
// server.js 顶层
const logger = require('./logger');
// 捕获未处理的 Promise Rejection
process.on('unhandledRejection', (reason, promise) => {
logger.fatal({ err: reason }, 'Unhandled Rejection detected.');
// Node 15+ 之后,unhandledRejection 默认会导致进程崩溃
// 最佳实践:记录日志后主动退出进程,交由 PM2/K8s 重启
// process.exit(1);
});
// 捕获未捕获的同步/异步回调异常
process.on('uncaughtException', (err, origin) => {
logger.fatal({ err, origin }, 'Uncaught Exception detected.');
// 发生 uncaughtException 时,Node.js 的内部状态可能已经损坏
// 此时绝对不能强行继续运行,必须退出进程!
process.exit(1);
});
三、优雅退出(Graceful Shutdown)
无论是发生未捕获异常导致的主动退出,还是发布上线时 K8s/PM2 发送的停止信号,进程都不应该“瞬间暴毙”,否则会导致用户正在进行中的请求直接返回 502 Bad Gateway 或连接重置。
优雅退出的核心步骤:
- 停止接收新请求(关闭 HTTP Server)。
- 等待存量请求处理完毕。
- 清理底层资源(关闭数据库连接、清理定时器、断开 Redis 等)。
- 退出进程。
完整实现代码示例
const http = require('http');
const express = require('express');
const { closeDatabaseConnection } = require('./db');
const logger = require('./logger');
const app = express();
app.get('/api', (req, res) => res.send('OK'));
const server = http.createServer(app);
server.listen(3000, () => {
logger.info('Server is running on port 3000');
});
// === 优雅退出逻辑逻辑 ===
// 标记是否正在退出中,防止重复执行
let isShuttingDown = false;
function gracefulShutdown(signal) {
if (isShuttingDown) return;
isShuttingDown = true;
logger.info(`Received ${signal}, starting graceful shutdown...`);
// 1. 让 server 停止接收新请求
server.close(async (err) => {
if (err) {
logger.error({ err }, 'Error during server close');
process.exit(1);
}
logger.info('HTTP server closed, no longer accepting new connections.');
try {
// 2. 清理底层依赖资源
await closeDatabaseConnection();
logger.info('Database connections closed.');
// 3. 安全退出
logger.info('Graceful shutdown completed.');
process.exit(0);
} catch (dbErr) {
logger.error({ err: dbErr }, 'Error closing database connections');
process.exit(1);
}
});
// 兜底机制:如果 10 秒后存量请求还没处理完,强制退出
setTimeout(() => {
logger.fatal('Could not close connections in time, forcefully shutting down');
process.exit(1);
}, 10000).unref(); // unref() 确保这个定时器本身不会阻止进程自然退出
}
// 监听终止信号 (K8s / Docker 停止容器时发送 SIGTERM,Ctrl+C 发送 SIGINT)
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
四、进程守护与周边生态
在单线程模型下,Node.js 进程崩溃是常态,必须配合外部的进程守护工具来实现崩溃自动重启。
- 传统虚机/物理机部署:极度依赖 PM2。
- 崩溃自动重启:结合前面的
uncaughtException退出,PM2 发现进程死了会立刻拉起。 - Cluster 负载均衡:一行命令利用多核 CPU。
- 平滑重启 (0 秒停机):
pm2 reload。 - 日志接管:收集所有 stdout/stderr 输出。
- 崩溃自动重启:结合前面的
- 云原生/容器化部署:由 Kubernetes (K8s) 或 Docker Compose 负责进程的拉起。在容器内部,Node.js 进程应该作为 PID 1 运行,这样才能正确接收到
SIGTERM信号从而执行上面的优雅退出逻辑。
总结
一个健壮的 Node.js 后端应用不仅要在正常状态下跑得快,更要在面临异常时“死得体面”。
- 不要相信
try...catch能捕获所有问题,理清异步调用链是关键。 - 永远要挂载
uncaughtException和unhandledRejection,记录致命现场。 - 必须实现响应
SIGTERM的优雅退出机制,避免业务数据的中间态损坏。