Nodejs深入浅出核心点笔记

136 阅读15分钟
  1. Nodejs模块解析顺序,核心模块 > 路径形式的文件模块 > 自定义模块
  2. Nodejs模块加载后会将结果缓存到Module._cache上以方便二次加载,每次文件加载优先加载缓存
  3. 如果没有扩展名,node会根据该顺序来补充扩展名 .js > .node > .json
  4. 可以通过require.extensions[.json]的方式加载自定义扩展名,不推荐。与Module._extensions相同
  5. js模块编译的时候会被node包装成如下形式,由于exports是形参所以不能直接使用exports = function() {}的形式来赋值,应该使用module.exports
(function(exports, require, module, __filename, __dirname) {
	 // ocde
})
  1. c/c++模块执行,node会调用process.dlopen()来加载执行,dlopen在windows和linux上有不同的实现,通过libuv兼容层进行了封装
  2. json文件解析通过fs模块同步读取json文件然后通过JSON.parse解析
  3. 核心模块由c/c++编写的和javascript编写的两部分
  4. javascript编写的核心模块会通过v8附带的js2c.py的工具,将所有内置的js代码转为c++数组,生成node_natives.h头文件,这个过程中js以字符串形式存储在node命名空间中,不可直接执行。node启动过程中,这些代码被加入内存。编译过程它也与文件模块一样经历包装过程,与文件模块不同地方在于获取源码的方式和缓存执行结果的位置。源文件通过process.binding('natives')取出,编译成功存储到NativeModule._cache上
  5. c/c++编写的模块分为由纯c++编写和核心由c/c++编写,js主外实现封装的模式。这些由纯c/c++编写的部分我们成为内置模块。
  6. 内置模块通过NODE_MODULE宏将模块定义到node命名空间中,node_extensions.h文件将这些散列的内置模块统一放置到node_module_list的数组。取出内置模块可以通过node提供的get_buildin_module()方法取出。
  7. 核心模块引入流程
require('os')
NativeModule.require('os')
process.bind('os')
get_builtin_module('node_os')
NODE_MODULEE(node_os, reg_func)
  1. 阻塞I/O是在调用之后等到系统内核层面完成所有操作后,调用才结束
  2. 非阻塞I/O在调用之后会立即返回,提升了CPU利用率。但是由于获取的仅仅是状态,为了获取完整数据,应用程序需要重复调用I/O操作来确认是否完成。这种重复调用判断操作是否完成的技术叫轮询。
  3. 轮询有read,select,poll,epoll,keueue等方式
  4. 现实的I/O通过线程池模拟的方式实现AIO异步I/O
  5. 事件循环,一个循环称为一个Tick,判断是否有事件处理则需要向“观察者”询问
  6. 事件循环是一个典型的生产者/消费者模型,异步I/O、网络请求是时间生产者,事件循环从观察者那里取出事件并处理。
  7. windows异步I/O过程
发起异步请求
组装请求对象
送入I/O线程池等待执行
执行请求对象的I/O操作
将请求执行的结果放入请求对象
通知IOCP调用完成&归还线程
Tick执行检查线程池是否有执行完的请求
放入I/O观察者队列
去除result作为参数传入回调进行执行
  1. Linux使用epoll实现异步I/O过程。FreeBSD通过kqueue实现。
  2. 非I/O的异步API。setTimeout和setInterval他们的实现原理与I/O类似,只不过不需要I/O参与,调用时会创建一个定时器,并将定时器放入定时器观察者的一个红黑树中。每次Tick执行,都从红黑树中迭代去除定时器对象,检查时间是否超时。如果超时就形成一个事件,它的回调函数会立刻执行。process.nextTick会将回调翻入队列中,在下一列Tick时取出,该方法更高效。setImmediate与process.nextTick实现类似,区别在于setImmediate保存在链表中,process.nextTick保存在数组中,每次循环process.nextTick会清空而setImmediate只会执行一个。并且process.nextTick会先于setImmediate执行,这是由于观察者不同,idle观察者先于I/O观察者,I/O观察者先于check观察者。
  3. Nginx也与node相似使用事件驱动的方式作为服务器。Nginx使用纯c编写性能更高,但Nginx仅适合作为web服务器。
  4. 在一般的后端开发语言中,在基本的内存使用上没有什么限制,然而在Node中通过JavaScript使用内存时就会发现只能使用部分内存(64位系统下约1.4GB,32位系统下约0.7GB),这是由于v8的限制所导致的
  5. 可以使用process.memoryUsage()申请内存使用情况
  6. v8采用分代式垃圾回收算法,没有一种垃圾回收算法可以适用所有场景,不同的算法只能针对特定情况具有最好的效果。v8主要根据对象存活时间,将内存分为新生代和老生代,新生代中的对象存活时间较短,老生代的存活时间较长。分别使用--max-new-space-size和--max-old-space-size来启动的时候指定新老的内存大小。v8没办法自动扩充。
  7. 新生代垃圾回收采用Scavenge算法,它采用复制的方式完成垃圾回收。将新生代内存一分为两半,称为semispace,只有一个处于使用状态,另一个处于闲置状态。处于使用状态的semispace称为From空间,空闲的semispace称为To空间。内存分配会在From中进行,当开始垃圾回收时,会检查From空间的存活对象并复制到To空间中,当复制完毕后,会将From空间清除,并将From空间和To空间的角色进行调换。该算法的效率很高,缺点是空间利用率不高,但是很适合生命周期短的新生代对象。当一个对象经过多次复制仍然存活,则被认为是生命周期较长的对象,该对象就会被移动到老生代中,采用新的算法进行管理,该移动过程称为晋升。
  8. 晋升的条件有两个,一是对象是否经历过Scavenge回收,二是To空间使用占比是否超过25%,这是由于To空间之后要作为From空间进行内存的分配,所以要留下足够的内存空间。晋级后的对象将接受新的回收算法来处理。
  9. 当对象多的时候,继续采用Scavenge算法复制效率会很低,并且浪费一半空间。所以老生代主要采用Mark-Sweep和Mark-Compact结合的方式。
  10. Mark-Sweep标记清除分为标记和清除两个阶段,在标记遍历阶段,会对存活的对象做标记,标记完成后对没有标记的对象进行清除。该方法会造成内存不连续存在内存碎片。这个时候就需要用到Mark-Compact标记整理,标记整理会将所有的存活对象移动到一边,然后将另一边没存活的对象进行清除。由于标记整理需要移动对象比较耗时,所以v8主要采用Mark-Sweep,当内存不足才使用Mark-Compact
  11. 为了避免js应用逻辑与垃圾回收器看到的不一致的情况,垃圾回收的3种基本方法都需要将应用逻辑暂停下来,待执行完垃圾回收后再恢复执行应用逻辑,这种行为被称为全停顿(stop-the-world)。为了避免全停顿时间过程,v8引入增量标记、增量整理、延迟清理等,即拆分为很多小步,垃圾回收和应用逻辑交替执行,直到任务完成。
  12. 在Web服务器中的会话一般会通过内存来处理,但当访问量大的时候会导致老生代中的存活对象骤增,不仅造成清理/整理过程费时,还会造成内存紧张甚至溢出的情况。
  13. 可以添加--trace_gc来打印垃圾回收日志,也可以通过添加--prof得到v8的性能分析数据,其中包括了垃圾回收时占用的时间,并可以通过node源码中提供的linux-tick-processor工具来增强可读性。
  14. 全局变量会增加引用,如果不释放会一直保持到程序结束。一般情况下在js中可以通过作用链,内部作用域访问到外部变量。实现外部作用域访问内部作用域中变量的方法称为闭包。这得益于高级函数的特性:函数可以作为参数或者返回值。闭包可以产生很多巧妙的效果,但是它的问题在于,一旦有变量引用这个中间函数,这个中间函数就不会被释放,则原始作用域也不会释放,作用域产生的内存占用也不会释放,除非不再引用,才会逐步释放。所以实际使用中要注意全局变量和闭包的情况,这会引起内存增加。
  15. Buffer不经过v8的内存分配机制,所以也不会有堆内存的大小限制。
  16. 内存泄露的原因有如下几个:
  • 缓存(使用对象键值对进行缓存,解决缓存问题做好的方式是使用Redis等外部缓存,既可以让垃圾回收更高效,也可以共享缓存)
  • 队列消费不及时(消费速度小于生产速度,可以通过堆积报警机制)
  • 作用域未释放
  1. 内存泄露排查方案。node-profiler,node_hepdump,node_mtrace,dtrace,node_memwatch
  2. Buffer对象,类数组对象,Buffer对象的元素是16进制的两位数,即0-255的数值。
  3. 当Buffer分配内存的大小小于8KB的时候,会生成一个8KB大小的slab并指向该局部变量。该slab可以被之后多个buffer共用,并记录位置和状态,当内存不够时,会重新生成新的slab。但是当需要分配的内存大小大于8KB则会直接进行分配slab,该slab的长度为需要分配的长度并由该Buffer独占。
  4. Buffer可以通过buf.write实现多种编码。可以通过buf.toString实现字符串的转换。Buffer本身支持的编码类型有限,可以使用Buffer.isEncoding来判断。对于GBK等不支持的编码可以使用node生态iconv、iconv-lite来做。
  5. Buffer拼接使用 + 来拼接会默认调用 toString 来转成字符串拼接,这个大部分场景没什么问题,但是如果遇到宽字符串可能会出现乱码的问题,这个时候就应该用数组存储buffer,最后用Buffer.concat来合并buffer对象。
  6. Buffer的在http请求中比直接传输文本的传输效率更高,可以有效减少cpu的重复使用,节省服务器资源。
  7. fs.createReadStream 的工作方式是在内存中准备一段Buffer,然后在fs.read读取时逐步从磁盘中将字节复制到Buffer中。完成一次读取,则从这个Buffer中通过slice的方式取出部分数据作为一个小的buffer,再通过data事件传递给调用方。理想情况下,每次读取的长度就是highWaterMark那么大,如果读到了文件末尾或者文件本身没有那么大,则预先指定的buffer会有部分剩余。该buffer pool常驻内存,当内存不够,会重新分配一个新的buffer对象。highWaterMark对性能有影响,highWaterMarker设置过小,可能导致系统调用次数过多。
  8. http请求和响应
// server.js
var http = require('http');

http.createServer((req, res) => {
    res.writeHead(200, {
        'Content-Type': 'text/plain'
    });
    res.end("hello world\n");
}).listen(1337, '127.0.0.1');

// client.js
var http = require('http');

var req = http.request({
    host: '127.0.0.1',
    port: 1337,
    path: '/',
    method: 'GET'
    agent: false // 默认为当前请求创建一个独立agent
}, res => {
    console.log('STATUS: ' + res.statusCode);
    console.log('HEADERS: ' + JSON.stringify(res.headers));
    res.setEncoding('utf8');
    res.on("data", chunk => {
        console.log(chunk);
    })
})

req.end();
  1. 默认情况下,通过ClientRequest对象对同一个服务器发起的HTTP请求最多可以创建5个连接。它实质是一个连接池由http.globalAgent管理,其默认最大连接数为无穷大,可以通过maxSockets来设置。agent.sockets表示当前连接池中使用的连接数,agent.requests表示当前处于等待状态的连接数
const agent = new http.Agent({
	maxSockets: 3
})
  1. websocket和node的配合堪称完美,websocket客户端基于事件的编程模型与node中自定义事件相差无几。websocket协议是直接基于tcp来实现的而不是http,但websocket的握手过程却是使用的http。websocket协议主要分为两个部分:握手和数据传输。websocket握手请求与普通http请求的区别在于多了Upgrade: websocketConnection: Upgrade,这两个字段表示请求服务器端升级协议为websocket。同时还包括了一些校验的头部。
  2. cookie可以用来保存状态。但是一些敏感信息如果在客户端直接保存会出现安全问题。所以有session的方案,cookie保存口令,服务端保存session每次都用cookie口令查询session。当要利用多核cpu,由于请求可能被分到很多进程程中,同时多进程无法共享。而且由于session储存在内存中,所以可能会有内存溢出的风险。为了解决这个问题,我们可以使用redis和memcached这些高效缓存来解决问题。由于cookie中的口令也可能别枚举出来,所以可以通过对口令进行签名来增大攻击者枚举的难度。但签名后的口令也有可能被攻击者盗取,解决这个方法可以使用用户的独有信息如用户ip和用户代理进行签名,这样只要攻击者不再原始的客户端进行访问就会失败。
  3. nodejs执行js是单进程单线程的,为了利用多核cpu可以通过child_process启动多个进程。进程间通信可以使用message事件监听和使用send来发送。
  4. 进程间可以发送句柄,句柄可以用来标识一个socket对象,一个UDP套接字,一个管道等。我们知道如果多个子进程同时监听同一个接口会有问题,但是如果我们让主进程监听一个端口然后通过句柄传递的方式将句柄下发到子进程,那么子进程就可以监听同一个端口了。进程间实质只能通过IPC传递消息,并不会真正的传递对象,这是由于传递了句柄后,进行了序列化,子进程接受后重新构造的结果。
// parent.js
const cp = require('child_process')
const child1 = cp.fork('child.js');
const child2 = cp.fork('child.js');

const server = require('net').createServer();

server.on('connection', (socket) => {
    socket.end('handled by parent\n');
})

server.listen(1337, () => {
    child1.send('server', server);
    child2.send('server', server);
    server.close();
})


// child.js
const http = require("http");

const server = http.createServer((req, res) => {
    res.writeHead(200, { 'Content-Type': 'text/plain'});
    res.end('handled by child, pid is ' + process.pid + '\n');
})

process.on("message", (m, tcp) => {
    if (m === 'server') {
        tcp.on('connection', (socket) => {
            server.emit('connection', socket)
        })
    }
})

  1. 进程间可以共同监听的原理是句柄还原出来的文件描述符是相同的。文件描述符同一时间只能被同一个进程所用。所以多进程服务是抢占式的。
  2. 父进程可以给子进程发送kill等指令,每个进程可以监听这些信号事件,做出相应的处理。我们可以通过这种方式监听,当子进程退出的时候复制新的子进程。同时处理异常情况,当自进程异常,告诉主进程将要关闭不接受新的请求,主进程会复制一个新的子进程来处理请求,然后该异常进程平滑退出。这样我们应用的稳定性和健壮性大大提高。最后我们可以进一步健壮,设置短时间内的最大重启次数,如果超出这个值就放弃重启,并做好相应的报警和监控。
// master.js
const net = require('net');
const cp = require('child_process');

const server = net.createServer();
server.listen(1337);

const workers = {};
const createWorker = () => {
    const worker = cp.fork('./worker.js');

    worker.on('message', message => {
        if (message.act === 'suicide') {
            createWorker();
        }
    })

    worker.on('exit', () => {
        console.log('worker ' + worker.pid + ' exited.');
        delete worker[worker.pid];
        createWorker();
    })

    worker.send('server', server);
    workers[worker.pid] = worker;
    console.log('create worker, pid: ' + worker.pid);
}

for (let i = 0; i < 4; i++) {
    createWorker();
}

process.on("exit", () => {
    for(let pid in workers) {
        workers[pid].kill();
    }
})


// worker.js
const http = require("http");

const server = http.createServer((req, res) => {
    res.writeHead(200, { 'Content-Type': 'text/plain'});
    res.end('handled by child, pid is ' + process.pid + '\n');
    throw new Error('throw exception');
})

let worker;
process.on("message", (m, tcp) => {
    if (m === 'server') {
        worker = tcp;
        worker.on('connection', (socket) => {
            server.emit('connection', socket)
        })
    }
})

process.on("uncaughtException", function(err) {
	// 记录日志,方便排查问题
    loggeer.error(err);
    // 向主进程发送自杀信号
    process.send({act: 'suicide'});
    // 停止接受新的连接
    worker.close(() => {
        // 待所有连接断开后,退出进程
        process.exit(1);
    })
    
    // 如果是ws长连接的情况,可能需要很久,这里我们设置超时时间
    setTimeout(() => {
    	process.exit(1);
    }, 5000)
})

  1. 负载均衡可以使用node提供的round-robin机制,又叫轮叫调度。启用方式如下所示
cluster.schedulingPolicy = cluster.SCHED_RR
cluster.schedulingPolicy = clusster.SCHED_NONE

或者在环境变量中设置NODE_CLUSTER_SCHED_POLICY的值。

export NODE_CLUSTER_SCHED_POLICY=rr
export NODE_CLUSTER_SCHED_POLICY=none
  1. 多进程间如何进行状态共享,最简单世界的方式是使用第三方来数据存储,所有工作进程启动时将其读取进内存中。但这种方式存在的问题是如果数据发生改变,还需要一种机制去通知各个子进程,使得他澳门的内部状态也得更新。如果所有子进程不断轮询来查询数据更新,如果过多会增加查询开销。另一种是主动通知的方式,这种方式也是轮询不过是使用一种单独的通知进程来查询并且通知各个工作进程。如果用信号传递那么在多服务器的时候会无效,是故可以考虑采用TCP或者UDP的方案。