Skip to main content

异常捕获与崩溃恢复

在 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 或连接重置。

优雅退出的核心步骤

  1. 停止接收新请求(关闭 HTTP Server)。
  2. 等待存量请求处理完毕
  3. 清理底层资源(关闭数据库连接、清理定时器、断开 Redis 等)。
  4. 退出进程

完整实现代码示例

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 后端应用不仅要在正常状态下跑得快,更要在面临异常时“死得体面”。

  1. 不要相信 try...catch 能捕获所有问题,理清异步调用链是关键。
  2. 永远要挂载 uncaughtExceptionunhandledRejection,记录致命现场。
  3. 必须实现响应 SIGTERM 的优雅退出机制,避免业务数据的中间态损坏。