Node.js 进程生命周期钩子:启动、退出时挂清理逻辑的正确位置

2026-09-28 71 浏览 0 评论 0 字

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 的模块里反复注册。


发布评论

发布评论前请先 登录。
0 评论
点赞
收藏

评论列表 0

暂无评论