欢迎回到事件循环系列。目前在事件循环系列中,我们讨论了事件循环和它的不同的阶段,setImmediate,nextTick,timers 和 I/O。我相信你现在对 NodeJS 有个很好的理解了。因此我打算在本系列中结束这一系列,并且谈论一些重要的经验教训。让我们谈论一些最佳实践,当你书写 Node 应用时,注意得到最好的结果和性能。同时,你可以查看本系列的前面的文章。
大多数人刚开始的都写不好 NodeJS 应用,仅仅因为对一些概念如 事件循环,错误处理,异步 等缺乏理解。现在你理解了事件循环,我相信你已经理解了我在本文涵盖的大多数实践。让我们看看。
在同步代码块避免同步 I/O
平时试着在重复触发的代码块(如循环,常调用的函数)避免同步 I/O 函数。否则会在相当大的范围降低你应用的性能,因为每次同步 I/O 执行,事件循环会阻塞直到完成。同步函数最安全的用例是在程序启动时间读取配置文件。
函数应该异步完成还是同步完成
你的应用有很多小的被称为函数的组件组成。在 NodeJS 应用中,这里会有两个类型的函数。
- 同步函数 - 大多数使用 return 返回输出(比如 Math 函数,fs.readFileSync 等)或者使用 CPS 风格(函数式编程中的)返回结果或者执行操作(Array prototype 函数如 map,filter,reduce 等)。
- 异步函数 - 返回结果延迟使用回调或者一个 promise(fs.readFile,dns.resolve 等)
经验法则是,你书写的函数应该是,
- 完全同步的 - 所有的输入和条件同步行为
- 完全异步的 - 所有的输入和条件异步行为
如果你的函数是一个上面两种不同行为的混合。可能会导致你应用不可预见的输出。让我们看下示例,
const cache = {}
function readFile(fileName, callback) {
if(cache[fileName]) {
return callback(null, cache[fileName])
}
fs.readFile(fileName, (err, fileContent) => {
if(err) return callback(err);
cache[fileName] = fileContent;
callback(null, fileContent);
})
}
现在让我们使用上面不一致的函数写一个小的应用。为了容易阅读,省略了错误处理。
function letsRead() {
readFile('myfile.txt', (err, result) => {
// error handler redacted
console.log('文件读取完成');
});
console.log('文件读取开始');
}
现在如果你使用 letsRead 函数两次,你会得到下面的输出。
file read initiated
file read complete
file read complete
file read initiated
看到了吧,两次的输出并不一样。当你的应用变得复杂,这些不一致的同步-异步混合函数可能导致很多问题,异常的难修复和调试。因此强烈建议遵循上面的同步、异步原则。
那么,我们如何修复上面的 readFile 函数。有两个方法:
方法1:使用 readFileSync 函数把上面的 readFile 函数完全变成同步的
方法2:通过触发异步回调把 readFile 函数变成异步的
正如我们看到的,我们知道在多次调用函数内部调用函数的异步形式一直都是提倡的。因此,我们不应该使用方法1,它会有性能问题。然后我们如何实现方法 2呢,我们怎么异步调用函数,简单,使用 process.nextTick
const cache = {};
function readFile(filename, callback) {
if(cache[filename]) {
return process.nextTick(() => callback(null, cache[filename]));
}
fs.readFile(filename, (err, fileContent) => {
if(err) return callback(err);
cache[filename] = fileContent;
callback(null, fileContent);
})
}
process.nextTick 将会延迟回调到事件循环的下一个阶段执行。现在,如果你执行 letsRead 函数两次,你将会得到输出:
file read initiated
file read complete
file read initiated
file read complete
你也可以使用 setImmediate 达到同样的目的,但是我愿意使用 process.nextTick,因为 nextTick 队列比 immediate 队列处理的要频繁。
太多的 nextTicks
尽管 process.nextTick 在很多情形下很有用,递归使用 process.nextTick 可能会导致 I/O 饿死。这会强制 Node 递归执行 nextTick,不会移动到 I/O 阶段。
之前的版本(<0.10)提供了一个方式,使用 process.maxTickDepth 设置 nextTick 回调调用的最大的次数。但是被移除了,目前没有办法限制 nextTick 无限期地调用。
dns.lookup() vs dns.resolve*()
如果你看过 NodeJS 文档的 dns 模块,你可能会发现,有两种方式查询一个 域名对应的 IP 地址。使用 dns.lookup 或者使用 dns resolve 函数比如 dns.resolve4,dns.resolve6 等。尽管这两个方法看起来一样,但是内部的工作原理有明显的区别。
dns.lookup 函数和 ping 命令的工作方式一样。它会调用 操作系统的网络 API 的 getaddrinfo。不行的是,这个调用不是异步的。因此为了模拟异步行为,这个调用用过使用 uv_getaddinfo 在 llibuv 的线程池里面。这可能会增加其他任务争夺在线程池上面运行,并对应用程序的性能导致负面影响。修改默认的默认数量是 4 的线程池同样重要。因此,四个平行的 dns.lookup 调用会完全占据线程池饿死其他的请求(文件 I/O,特定的加密函数,可能更多地 dns 查询)。
相反,dns.resolve 和其他的 dns.reslove() 以不同的方式方式执行。下面摘抄一段在官方文档中描述 dns.resolve* 的。
这些函数的实习方式和 dns.lookup() 不一样。它们不适用 getaddrinfo(3) 它们经常在网络上执行 dns 查询。这个网络沟通一直是异步完成的,不使用 libuv 的进程池。
NodeJS 使用一个被称为 c-ares 的依赖提供了 DNS 解决能力。这个库不依赖于 libuv 的线程池,并且完全在网络上运行的。
dns.resolve 没有负载 libuv 的线程池。因此,值得使用 dns.resolve 代替 dns.lookup。除非有需求坚持配置文件,例如,/etc/nsswitch.conf,/etc/hosts 被认为是在运行 getaddrinfo 时用到的。
但是这里有个更大的问题。
假设你使用 NodeJS 向 http://www.example.com 发出一个请求。首先,会将 www.example.com 转换为 IP 地址。然后它会使用 IP 地址异步地设置 TCP 连接。因此,发起一个请求有两个过程。
目前,Node http 和 https 模块内部使用 dns.lookup 解决 域名解析。由于 DNS 提供商失败或者更快的网络/dns 延迟,多个 http 请求可以很容易地使线程池不能再为其他请求服务。这是一个关于 http 和 https 的担忧,为了坚持本地操作系统的行为,但在撰写本文时任然保持不变。一些 http 客户端的模块比如 request,在底层也是用的 http,和 https。也被这个问题影响着。
如果你注意到你的应用在处理文件 I/O,加密,或者其他的线程池依赖的任务时性能大幅下滑,你可以这样做提高性能:
- 你可以增加线程池到 128 通过设置 UV_THREADPOOL_SIZE 环境变量
- 通过使用 dns.resolve* 解决将域名装换为 IP 地址或者直接使用 IP 地址。下面是一个和 request 模块一样的示例。
请注意,下面的脚本没有优化过,考虑到一个更健壮的实现有很多其他因素,因此下面的脚本仅仅是一个入门参考。下面的代码可以在 Node v8.0.0 之前的版本,因为 loopup 选项没有用在在 tls.connect 早期的实现。
const dns = require('dns');
const http = require('http');
const https = require('https');
const tls = require('tls');
const net = require('net');
const request = require('request');
const httpAgent = new http.Agent();
const httpsAgent = new https.Agent();
const createConnection = ({ isHttps = false } = {}) => {
const connect = isHttps ? tls.connect : net.connect;
return function(args, cb) {
return connect({
port : args.port,
host : args.host,
lookup : function(hostname, args, cb) {
dns.resolve(hostname, function(err, ips) {
if (err) { return cb(err); }
return cb(null, ips[0], 4);
});
}
}, cb);
}
};
httpAgent.createConnection = createConnection();
httpsAgent.createConnection = createConnection({isHttps: true});
function getRequest(reqUrl) {
request({
method: 'get',
url: reqUrl,
agent: httpsAgent
}, (err, res) => {
if (err) throw err;
console.log(res.body);
})
}
getRequest('https://example.com');
关于线程池的担忧
正如我们在整个系列中看到的,libuv 的线程池用于不同目的的文件 I/O,并且可能会成为应用程序的瓶颈。如果你认为你的应用在处理文件 I/O,加密操作时比平时要慢,考虑下通过设置 UV_THREADPOOL_SIZE 环境变量增加线程池的数量。
事件循环监视
监控事件循环的延迟,防止中断是至关重要的。这也可以用户生成警报,执行强制重启和扩展服务。
识别事件循环延迟的最容易的方式是检查额外的时间计时器执行它的回调的情况。简单来说,我们调度了一个延迟为 500ms 的计时器,如果它花了 550ms 执行计时器的回调,我们可以推导出事件循环延迟大约 50ms。这个额外的 50ms 应该是在事件循环的其他阶段执行事件了。你没必要从头开始写,你可以使用 loopbench 模块,它实现了相同的逻辑完成事件循环的模拟。让我们看下如何做。
一旦安装后,你可以在你的应用中使用 loopbench ,如:
const LoopBench = require('loopbench');
const loopBench = LoopBench();
console.log(`loop delay: ${loopBench.delay}`);
console.log(`loop delay limit: ${loopBench.limit}`);
console.log(`is loop overloaded: ${loopBench.overlimit}`);
一个有趣的用例是,你可以暴露一个健康的检查点,以便于你可以将你的应用与外部的报警、模拟工具集成。
一个上面 API 端点的响应示例可以和下面的相似:
{
"message": "application is running",
"data": {
"loop_delay": "1.2913 ms",
"loop_delay_limit": "42 ms",
"is_loop_overloaded": false
}
}
用这个实现,你可以在循环超载时,你的健康检查 API 可以返回 503 服务暂不可用,以防止循环进一步超载。 加入你有高可用性的实现的话,这也将有助于负载均衡器将请求路由到其他你应用的其他实例。