Node.js 进程生命周期钩子:启动、退出时挂清理逻辑的正确位置
Node 进程的优雅退出依赖生命周期钩子。本文讲解 SIGINT、SIGTERM、uncaughtException、unhandledRejection 等信号的作用,演示启动与退出的正确顺序:先停新连接、等在途请求、再关资源、最后退出,并配一个强制超时兜底,避免重启时丢数据、占端口、留孤儿连接。

问题背景
常见的反面教材是:数据库连接池开了从不关,定时器随手 setInterval 挂着不管,热更新或重启时老进程还占着端口、残留一堆孤儿连接。等一上线遇到底层升级、发布重启,就会莫名丢失数据或端口冲突。其实 Node 提供了进程生命周期钩子,只要放在正确的位置做清理,就能干净地收尾。
原理说明
Node 进程有明确的启动和退出路径。启动时我们在 index.js 里顺序初始化(读配置、连数据库、起 HTTP server)。退出时则有几种信号要处理:
SIGINT:Ctrl+C 触发SIGTERM:kill默认信号,也是进程管理器优雅停机时发的uncaughtException:兜住未捕获的同步异常unhandledRejection:兜住未被处理的 Promise 拒绝
正确的做法是: 在退出信号里先停掉 接收新请求(关闭 HTTP server 的监听),再给在途请求一点时间处理完,最后再关闭数据库连接池、清定时器等重资源,然后才退出 。框架如 Express 本身也提供了 server.close(callback) ,它会等当前连接处理完再回调。
示例代码
index.js 里的启动与优雅退出:
// index.js
const app = require('./app');
const config = require('./config');
const db = require('./models/db');
const TWO_MIN = 2 * 60 * 1000;
let server = null;
async function bootstrap() {
await db.init(); // 先连数据库
server = app.listen(config.port, () => {
console.log(`server on ${config.port}`);
});
startJobs(); // 启动定时任务
}
async function shutdown(signal) {
console.log(`${signal} 收到,开始优雅停机...`);
// 1. 停止接受新连接
server.close(async () => {
// 2. 等完在途请求后再关资源
await db.close(); // 关闭连接池
await clearJobs(); // 清掉定时器
console.log('清理完成,退出');
process.exit(0);
});
// 3. 兜底:超过时限强制退出,防止一直挂着
const force = setTimeout(() => {
console.error('停机超时,强制退出');
process.exit(1);
}, TWO_MIN);
force.unref();
}
// 绑定退出信号
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// 兜底未捕获异常,避免进程悄悄挂掉
process.on('uncaughtException', (err) => {
console.error('未捕获异常:', err);
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
console.error('未处理的 Promise 拒绝:', reason);
});
bootstrap();定时器管理的小技巧,让它可以被取消:
// services/jobs.js
let timers = [];
function startJobs() {
const t = setInterval(() => { /* 定时清理逻辑 */ }, 60 * 1000);
timers.push(t);
}
async function clearJobs() {
timers.forEach(clearInterval);
timers = [];
}
module.exports = { startJobs, clearJobs };常见坑与解决方案
- 坑一:在关闭回调里没写退出代码,进程一直不结束,挂在那里。解法:清理完成后显式
process.exit。 - 坑二:
server.close()只停新连接,在途请求可能会卡很久。解法:给停机一个超时,超时后强制退出(.unref()让超时定时器不阻塞退出的前提是别依赖它保活)。 - 坑三:定时器没被取消,进程要退时它还在跑。解法:集中管理定时器句柄,shutdown 里逐一
clearInterval。 - 坑四:把优雅停机逻辑写在
module.exports = app里,导致每次引入都被注册一遍信号监听。解法:信号绑定只在进程入口index.js做一次,不要写在可被 require 的模块里反复注册。
小结
把清理逻辑挂在正确的退出时机,顺序是:停新连接 → 等在途请求 → 关资源 → 退出,并配一个强制超时兜底。这样每次重启都能干净收尾,连接和数据都不会莫名其妙丢失。再往后,就是更高层面的服务治理了,比如接口防刷与限流。
FAQ 常见问题
Q:为什么优雅停机结束后进程还是挂着不退出?
A:多半是在关闭回调里漏写了 process.exit 。清理完成后要显式调用退出,否则事件循环仍可能被某个句柄(如定时器、连接)撑住,进程一直不结束。
Q: server.close() 会等所有在途请求都处理完吗?
A:它会停止接受新连接,并等当前已连请求处理完再回调,但个别长请求可能卡很久。所以要在外部套一个停机超时,超时后强制退出,防止无限等待。
Q:优雅停机逻辑写在 module.exports = app 里会有什么问题?
A:每次 require 这个模块都会重新注册一遍信号监听,造成重复注册。信号绑定只在进程入口 index.js 做一次即可,别放在可被 require 的模块里反复注册。






