[TOC]
# Node 出现 uncaughtException 之后的优雅退出方案
Node 的异步特性是它最大的魅力,但是在带来便利的同时也带来了不少麻烦和坑,错误捕获就是一个。由于 Node 的异步特性,导致我们无法使用 try/catch 来捕获回调函数中的异常,例如:
~~~
try {
console.log('进入 try/catch');
require('fs').stat('SOME_FILE_DOES_NOT_EXIST',
function readCallback(err, content) {
if (err) {
throw err; // 抛出异常
}
});
} catch (e) {
// 这里捕获不到 readCallback 函数中抛出的异常
} finally {
console.log('离开 try/catch');
}
~~~
运行结果是:
~~~
进入 try/catch
离开 try/catch
test.js:7
throw err; // 抛出异常
^
Error: ENOENT, stat 'SOME_FILE_DOES_NOT_EXIST'
~~~
上面代码中由于 `fs.stat` 去查询一个不存在的文件的状态,导致 `readCallback` 抛出了一个异常。由于 `fs.read` 的异步特性,`readCallback` 函数的调用发生在 `try/catch` 块结束之后,所以该异常不会被 try/catch 捕获。之后 Node 会触发 `uncaughtException` 事件,如果这个事件依然没有得到响应,整个进程(`process`)就会 crash。
程序员永远无法保证代码中不出现 `uncaughtException`,即便是自己代码写的足够小心,也不能保证用的第三方模块没有 bug,例如:
~~~
var deserialize = require('deserialize');
// 假设 deserialize 是一个带有 bug 的第三方模块
// app 是一个 express 服务对象
app.get('/users', function (req, res) {
mysql.query('SELECT * FROM user WHERE id=1', function (err, user) {
var config = deserialize(user.config);
// 假如这里触发了 deserialize 的 bug
res.send(config);
});
});
~~~
如果不幸触发了 `deserialize` 模块的 bug,这里就会抛出一个异常,最终结果是整个服务 crash。
当这种情况发生在 Web 服务上时结果是灾难性的。`uncaughtException` 错误会导致当前的所有的用户连接都被中断,甚至不能返回一个正常的 HTTP 错误码,用户只能等到浏览器超时才能看到一个`no data received` 错误。
这是一种非常野蛮粗暴的异常处理机制,任何线上服务都不应该因为 `uncaughtException` 导致服务器崩溃。一个友好的错误处理机制应该满足三个条件:
1. 对于引发异常的用户,返回 500 页面
2. 其他用户不受影响,可以正常访问
3. 不影响整个进程的正常运行
很遗憾的是,保证 `uncaughtException` 不影响整个进程的健康运转是不可能的。当 Node 抛出`uncaughtException` 异常时就会丢失当前环境的堆栈,导致 Node 不能正常进行内存回收。也就是说,每一次 `uncaughtException` 都有可能导致内存泄露。
既然如此,退而求其次,我们可以在满足前两个条件的情况下退出进程以便重启服务。
## 用 domain 来捕获异步异常
普遍的思路是,如果可以通过某种方式来捕获回调函数中的异常,那么就不会有`uncaughtException` 错误导致的崩溃。为了解决这个问题,Node 0.8 之后的版本新增了 `domain` 模块,它可以用来捕获回调函数中抛出的异常。
`domain` 主要的 API 有 `domain.run` 和 `error` 事件。简单的说,通过 `domain.run` 执行的函数中引发的异常都可以通过 `domain` 的 `error` 事件捕获,例如:
~~~
var domain = require('domain');
var d = domain.create();
d.run(function () {
setTimeout(function () {
throw new Error('async error'); // 抛出一个异步异常
}, 1000);
});
d.on('error', function (err) {
console.log('catch err:', err); // 这里可以捕获异步异常
});
~~~
通过 `domain` 模块,以及 JavaScript 的词法作用域特性,可以很轻易的为引发异常的用户返回 500 页面。以 express 为例:
~~~
var app = express();
var server = require('http').createServer(app);
var domain = require('domain');
app.use(function (req, res, next) {
var reqDomain = domain.create();
reqDomain.on('error', function (err) { // 下面抛出的异常在这里被捕获
res.send(500, err.stack); // 成功给用户返回了 500
});
reqDomain.run(next);
});
app.get('/', function () {
setTimeout(function () {
throw new Error('async exception'); // 抛出一个异步异常
}, 1000);
});
~~~
上面的代码将 domain 作为一个中间件来使用,保证之后 express 所有的中间件都在 `domain.run`函数内部执行。这些中间件内的异常都可以通过 `error` 事件来捕获。
尽管借助于闭包,我们可以正常的给用户返回 500 错误,但是 `domain` 捕获到错误时依然会丢失堆栈信息,此时已经无法保证程序的健康运行,必须退出。Node http server 提供了 `close` 方法,该方法在调用时会停止 server 接收新的请求,但不会断开当前已经建立的连接。
~~~
reqDomain.on('error', function () {
try {
// 强制退出机制
var killTimer = setTimeout(function () {
process.exit(1);
}, 30000);
killTimer.unref(); // 非常重要
// 自动退出机制,停止接收新链接,等待当前已建立连接的关闭
server.close(function () {
// 此时所有连接均已关闭,此时 Node 会自动退出,不需要再调用
process.exit(1) 来结束进程
});
} catch(e) {
console.log('err', e.stack);
}
});
~~~
这个例子来自 Node 的文档。其中有几个关键点:
* Node 有个非常好的特性,所有连接都被释放后进程会自动结束,所以不需要再 `server.close`方法的回调函数中退出进程
* 强制退出机制: 因为用户连接有可能因为某些原因无法释放,在这种情况下应该强制退出整个进程。
* `killTimer.unref()`: 如果不使用 `unref` 方法,那么即使 server 的所有连接都关闭,Node 也会保持运行直到 `killTimer` 的回调函数被调用。`unref` 可以创建一个"不保持程序运行"的计时器。
* 处理异常时要小心的把异常处理逻辑用 try/catch 包住,避免处理异常时抛出新的异常
通过 `domain` 似乎就已经解决了我们的需求: 给触发异常的用户一个 500,停止接收新请求,提供正常的服务给已经建立连接的用户,直到所有请求都已结束,退出进程。但是,理想很丰满,现实很骨感,`domain` 有个最大的问题,它[不能捕获所有的异步异常](http://cnodejs.org/topic/516b64596d38277306407936)!。也就是说,即使用了 `domain`,程序依然有因为 `uncaughtException` crash 的可能。
所幸的是我们可以监听 `uncaughtException` 事件。
## `uncaughtException` 事件
`uncaughtException` 是一个非常古老的事件。当 Node 发现一个未捕获的异常时,会触发这个事件。并且如果这个事件存在回调函数,Node 就不会强制结束进程。这个特性,可以用来弥补`domain` 的不足:
~~~
process.on('uncaughtException', function (err) {
console.log(err);
try {
var killTimer = setTimeout(function () {
process.exit(1);
}, 30000);
killTimer.unref();
server.close();
} catch (e) {
console.log('error when exit', e.stack);
}
});
~~~
`uncaughtException` 事件的缺点在于无法为抛出异常的用户请求返回一个 500 错误,这是由于`uncaughtException` 丢失了当前环境的上下文,比如下面的例子就是它做不到的:
~~~
javascript
app.get('/', function (req, res) {
setTimeout(function () {
throw new Error('async error');
// uncaughtException, 导致 req 的引用丢失
res.send(200);
}, 1000);
});
process.on('uncaughtException', function (err) {
res.send(500); // 做不到,拿不到当前请求的 res 对象
});
~~~
最终出错的用户只能等待浏览器超时。
## `domain` + `uncaughtException`
所以,我们可以结合两种异常捕获机制,用 `domain` 来捕获大部分的异常,并且提供友好的 500 页面以及优雅退出。对于剩下的异常,通过 `uncaughtException` 事件来避免服务器直接 crash。
~~~
var app = express();
var server = require('http').create(app);
var domain = require('domain');
// 使用 domain 来捕获大部分异常
app.use(function (req, res, next) {
var reqDomain = domain.create();
reqDomain.on('error', function () {
try {
var killTimer = setTimeout(function () {
process.exit(1);
}, 30000);
killTimer.unref();
server.close();
res.send(500);
} catch (e) {
console.log('error when exit', e.stack);
}
});
reqDomain.run(next);
});
// uncaughtException 避免程序崩溃
process.on('uncaughtException', function (err) {
console.log(err);
try {
var killTimer = setTimeout(function () {
process.exit(1);
}, 30000);
killTimer.unref();
server.close();
} catch (e) {
console.log('error when exit', e.stack);
}
});
~~~
## 其他的一些问题
### `express` 中异常的处理
使用 `express` 时记住一定不要在 controller 的异步回调中抛出异常,例如:
~~~
app.get('/', function (req, res, next) { // 总是接收 next 参数
mysql.query('SELECT * FROM users', function (err, results) {
// 不要这样做
if (err) throw err;
// 应该将 err 传递给 errorHandler 处理
if (err) return next(err);
});
});
app.use(function (err, req, res, next) {
// 带有四个参数的 middleware 专门用来处理异常
res.render(500, err.stack);
});
~~~
### 和 cluster 一起使用
cluster 是 node 自带的负载均衡模块,使用 cluster 模块可以方便的建立起一套 master/slave 服务。在使用 cluster 模块时,需要注意不仅需要调用 `server.close()` 来关闭连接,同时还需要调用`cluster.worker.disconnect()` 通知 master 进程已停止服务:
~~~
var cluster = require('cluster');
process.on('uncaughtException', function (err) {
console.log(err);
try {
var killTimer = setTimeout(function () {
process.exit(1);
}, 30000);
killTimer.unref();
server.close();
if (cluster.worker) {
cluster.worker.disconnect();
}
} catch (e) {
console.log('error when exit', e.stack);
}
});
~~~
### 不要通过 `uncaughtException` 来忽略错误
当 `uncaughtException` 事件有一个以上的 `listener` 时,会阻止 Node 结束进程。因此就有一个广泛流传的做法是监听 `process` 的 `uncaughtException` 事件来阻止进程退出,这种做法有内存泄露的风险,所以千万不要这么做:
~~~
javascript
process.on('uncaughtException', function (err) { // 不要这么做
console.log(err);
});
~~~
### pm2 对于 `uncaughtException` 的额外处理
如果你在用 pm2 0.7.1 之前的版本,那么要当心。pm2 有一个 bug,如果进程抛出了`uncaughtException`,无论代码中是否捕获了这个事件,进程都会被 pm2 杀死。0.7.2 之后的 pm2 解决了这个问题。
### 要小心 worker.disconnect()
如果你在退出进程时希望可以发消息给监控服务器,并且还使用了 cluster,那么这个时候要特别小心,比如下面的代码:
~~~
var udpLog = dgram.createSocket('udp4');
var cluster = require('cluster');
process.on('uncaughtException', function (err) {
udpLog.send('process ' + process.pid + ' down',
/* ... 一些发送 udp 消息的参数 ...*/);
server.close();
cluster.worker.disconnect();
});
~~~
这份代码就不能正常的将消息发送出去。因为 `udpLog.send` 是一个异步方法,真正发消息的操作发生在下一个事件循环中。而在真正的发送消息之前 `cluster.worker.disconnect()` 就已经执行了。`worker.disconnect()` 会在当前进程没有任何链接之后,杀掉整个进程,这种情况有可能发生在发送 log 数据之前,导致 log 数据发不出去。
一个解决方法是在 `udpLog.send` 方法发送完数据后再调用 `worker.disconnect`:
~~~
var udpLog = dgram.createSocket('udp4');
var cluster = require('cluster');
process.on('uncaughtException', function (err) {
udpLog.send('process ' + process.pid + ' down', /* ...
一些发送 udp 消息的参数 ...*/, function () {
cluster.worker.disconnect();
});
server.close();
// 保证 worker.disconnect 不会拖太久..
setTimeout(function () {
cluster.worker.disconnect();
}, 100).unref();
});
~~~
## 小结
说了这么多,结论是,目前为止(Node 0.10.25),依然没有一个完美的方案来解决任意异常的优雅退出问题。用 `domain` 来捕获大部分异常,并且通过 `uncaughtException` 避免程序 crash 是目前来说最理想的方案。回调异常的退出问题在遇到 cluster 以后会更加复杂,特别是对于连接关闭的处理要格外小心。
# 参考资料
[Node 出现 uncaughtException 之后的优雅退出方案](https://www.infoq.cn/article/quit-scheme-of-node-uncaughtexception-emergence/)
- 第一部分 HTML
- meta
- meta标签
- HTML5
- 2.1 语义
- 2.2 通信
- 2.3 离线&存储
- 2.4 多媒体
- 2.5 3D,图像&效果
- 2.6 性能&集成
- 2.7 设备访问
- SEO
- Canvas
- 压缩图片
- 制作圆角矩形
- 全局属性
- 第二部分 CSS
- CSS原理
- 层叠上下文(stacking context)
- 外边距合并
- 块状格式化上下文(BFC)
- 盒模型
- important
- 样式继承
- 层叠
- 属性值处理流程
- 分辨率
- 视口
- CSS API
- grid(未完成)
- flex
- 选择器
- 3D
- Matrix
- AT规则
- line-height 和 vertical-align
- CSS技术
- 居中
- 响应式布局
- 兼容性
- 移动端适配方案
- CSS应用
- CSS Modules(未完成)
- 分层
- 面向对象CSS(未完成)
- 布局
- 三列布局
- 单列等宽,其他多列自适应均匀
- 多列等高
- 圣杯布局
- 双飞翼布局
- 瀑布流
- 1px问题
- 适配iPhoneX
- 横屏适配
- 图片模糊问题
- stylelint
- 第三部分 JavaScript
- JavaScript原理
- 内存空间
- 作用域
- 执行上下文栈
- 变量对象
- 作用域链
- this
- 类型转换
- 闭包(未完成)
- 原型、面向对象
- class和extend
- 继承
- new
- DOM
- Event Loop
- 垃圾回收机制
- 内存泄漏
- 数值存储
- 连等赋值
- 基本类型
- 堆栈溢出
- JavaScriptAPI
- document.referrer
- Promise(未完成)
- Object.create
- 遍历对象属性
- 宽度、高度
- performance
- 位运算
- tostring( ) 与 valueOf( )方法
- JavaScript技术
- 错误
- 异常处理
- 存储
- Cookie与Session
- ES6(未完成)
- Babel转码
- let和const命令
- 变量的解构赋值
- 字符串的扩展
- 正则的扩展
- 数值的扩展
- 数组的扩展
- 函数的扩展
- 对象的扩展
- Symbol
- Set 和 Map 数据结构
- proxy
- Reflect
- module
- AJAX
- ES5
- 严格模式
- JSON
- 数组方法
- 对象方法
- 函数方法
- 服务端推送(未完成)
- JavaScript应用
- 复杂判断
- 3D 全景图
- 重载
- 上传(未完成)
- 上传方式
- 文件格式
- 渲染大量数据
- 图片裁剪
- 斐波那契数列
- 编码
- 数组去重
- 浅拷贝、深拷贝
- instanceof
- 模拟 new
- 防抖
- 节流
- 数组扁平化
- sleep函数
- 模拟bind
- 柯里化
- 零碎知识点
- 第四部分 进阶
- 计算机原理
- 数据结构(未完成)
- 算法(未完成)
- 排序算法
- 冒泡排序
- 选择排序
- 插入排序
- 快速排序
- 搜索算法
- 动态规划
- 二叉树
- 浏览器
- 浏览器结构
- 浏览器工作原理
- HTML解析
- CSS解析
- 渲染树构建
- 布局(Layout)
- 渲染
- 浏览器输入 URL 后发生了什么
- 跨域
- 缓存机制
- reflow(回流)和repaint(重绘)
- 渲染层合并
- 编译(未完成)
- Babel
- 设计模式(未完成)
- 函数式编程(未完成)
- 正则表达式(未完成)
- 性能
- 性能分析
- 性能指标
- 首屏加载
- 优化
- 浏览器层面
- HTTP层面
- 代码层面
- 构建层面
- 移动端首屏优化
- 服务器层面
- bigpipe
- 构建工具
- Gulp
- webpack
- Webpack概念
- Webpack工具
- Webpack优化
- Webpack原理
- 实现loader
- 实现plugin
- tapable
- Webpack打包后代码
- rollup.js
- parcel
- 模块化
- ESM
- 安全
- XSS
- CSRF
- 点击劫持
- 中间人攻击
- 密码存储
- 测试(未完成)
- 单元测试
- E2E测试
- 框架测试
- 样式回归测试
- 异步测试
- 自动化测试
- PWA
- PWA官网
- web app manifest
- service worker
- app install banners
- 调试PWA
- PWA教程
- 框架
- MVVM原理
- Vue
- Vue 饿了么整理
- 样式
- 技巧
- Vue音乐播放器
- Vue源码
- Virtual Dom
- computed原理
- 数组绑定原理
- 双向绑定
- nextTick
- keep-alive
- 导航守卫
- 组件通信
- React
- Diff 算法
- Fiber 原理
- batchUpdate
- React 生命周期
- Redux
- 动画(未完成)
- 异常监控、收集(未完成)
- 数据采集
- Sentry
- 贝塞尔曲线
- 视频
- 服务端渲染
- 服务端渲染的利与弊
- Vue SSR
- React SSR
- 客户端
- 离线包
- 第五部分 网络
- 五层协议
- TCP
- UDP
- HTTP
- 方法
- 首部
- 状态码
- 持久连接
- TLS
- content-type
- Redirect
- CSP
- 请求流程
- HTTP/2 及 HTTP/3
- CDN
- DNS
- HTTPDNS
- 第六部分 服务端
- Linux
- Linux命令
- 权限
- XAMPP
- Node.js
- 安装
- Node模块化
- 设置环境变量
- Node的event loop
- 进程
- 全局对象
- 异步IO与事件驱动
- 文件系统
- Node错误处理
- koa
- koa-compose
- koa-router
- Nginx
- Nginx配置文件
- 代理服务
- 负载均衡
- 获取用户IP
- 解决跨域
- 适配PC与移动环境
- 简单的访问限制
- 页面内容修改
- 图片处理
- 合并请求
- PM2
- MongoDB
- MySQL
- 常用MySql命令
- 自动化(未完成)
- docker
- 创建CLI
- 持续集成
- 持续交付
- 持续部署
- Jenkins
- 部署与发布
- 远程登录服务器
- 增强服务器安全等级
- 搭建 Nodejs 生产环境
- 配置 Nginx 实现反向代理
- 管理域名解析
- 配置 PM2 一键部署
- 发布上线
- 部署HTTPS
- Node 应用
- 爬虫(未完成)
- 例子
- 反爬虫
- 中间件
- body-parser
- connect-redis
- cookie-parser
- cors
- csurf
- express-session
- helmet
- ioredis
- log4js(未完成)
- uuid
- errorhandler
- nodeclub源码
- app.js
- config.js
- 消息队列
- RPC
- 性能优化
- 第七部分 总结
- Web服务器
- 目录结构
- 依赖
- 功能
- 代码片段
- 整理
- 知识清单、博客
- 项目、组件、库
- Node代码
- 面试必考
- 91算法
- 第八部分 工作代码总结
- 样式代码
- 框架代码
- 组件代码
- 功能代码
- 通用代码