如何编写稳定的Node.js服务 原 荐

近一年没发文章了,因为事情很多。

之前用Golang写过一个计划工作任务的调度系统,当时的思路,所有任务以JSON发布(更新),然后要执行的程序(处理逻辑)包含在任务的URL中进行处理,可参考这个《GoTasks》。为何没考虑将任务的处理逻辑放在Golang中进行处理呢?主要有几个顾虑:

  1. Golang的Goroutine,在当时的版本是语言内部自己管理和调度的,当时版本没有明确的接口去进行管理。而根据服务运行监控的监控结果,服务实际运行中,Goroutine数量被实际调用和预留数量是非常庞大的。
  2. Golang属于静态编译语言,如果要将任务的处理逻辑发布在项目中,碰到更新、细节调整,每次都要build还是比较麻烦的,最好的做法是golang调用脚本,但这些已经超出当时做那个项目的设想范围了。

机缘巧合,2017年又一次碰到了类似的需求,但因为单个任务的数据量非常大,最好能在取得原始数据后,进行相应的数据分析和拆解,然后再进行存储,而且拆解完的数据,会成为另一个计算的基础,进而触发另一个任务的执行。这就要求,必须在工作任务(worker)中,可以直接编写处理逻辑。

最初还是想用golang再续前缘的,但因为日常工作(前端打包环境)已经经常接触到Node.js了,就寻思着,为何不用Node.js来实现一套呢?

ChildProcess模式

Node.js作为脚本运行环境,本身速度并不差。异步模式,也提供了非阻塞的I/O处理。不过,不过,异步并不代表不存在计算阻塞的问题。

什么是计算阻塞呢?我们写了一段程序,从上至下,中间不存在异步、多进程的执行,中间也许有一块操作,需要循环N次,进行一些操作,取得下一部分程序所需的数据。在这里,循环N次,只是计算阻塞的一种现象的描述,任何并发、顺序式、密集型计算,都可能造成计算阻塞。

在传统语言,特别是静态编译型语言,在计算阻塞方面所占用的计算时间消耗,小到几乎可以忽略(比如用Java循环10万次所需要的运行时间消耗,是PHP和Node.js这种脚本级语言直流口水的)。大家几乎不会察觉,也完全感受不到。

即使在PHP环境下,这种阻塞,也几乎可以略过,因为终归PHP中调用较多的core类库和函数,底层实现还是C和C++的(很多还是宏和Alias),当然,那些某某框架,另当别论(所以始终我对composer的机制还是有所保留)。

但在Node.js环境下,问题就很赤裸裸。首先,node.js中,JS调用的多数是另一个用户编写的函数,或者npm库中的另一个用户写的函数。其次,是npm繁殖的速度,以及各个类库相互调用之深。所以在Node.js中,计算阻塞,会成为一个很突出的问题。

特别当设计一个服务,需要精确的时间控制时,计算阻塞直接带来的问题是,造成每一个时间循环的延迟和超时,问题相当明显(最初用Golang做GoTasks之前,其实是先用Node.js做了一个原型,也碰到过这个问题,但当时立刻就放弃了Node.js的方案)。

经过多番尝试和总结,解决这种计算阻塞的最佳模式,就是ChildProcess模式(下简称CP模式)。

其实从Node.js最早公布时,就存在CP模块,CP提供同步和异步两种模式,主进程和子进程之间也有完整的通信机制。但使用思路,并不是如多数的分享文章,即一开始就直接创建多个CP,因为无论你初始化多少个Process,只要他们执行程序是一样的,计算阻塞的问题都依然存在,依然的得不到解决。

解决的思路应该是,主进程,只负责主调度,当遇有计算阻塞的可能时,以CP异步模式调度出一个新的进程,进行密集型运算,最终将异步处理结果方式取回到主程序。CP处理完毕,立刻退出释放掉。

是的,解决思路本质上其实还是Node.js的异步机制,但不是以主进程require某个module的方式进行,而是异步出一个子进程,只是调用Module的思路进行一种转换。

Node.js的CP模式,有以下的显著优点:

  1. 子进程是系统进程,可编程控制,比Goroutine的黑箱更透明(也许Golang现在已经提供API了吧)。
  2. 子进程,计算性能更灵活,你需要掌握的是调度任务的数量,就能充分控制CPU运算所占用的比例。
  3. 稳定性极佳,这个任务系统,从发布上线,丢在那里大半年没人管,从未间断过。
  4. 子进程可以几乎完全共享主进程的node_modules,也包括项目内的所有源代码。
  5. Node.js的CP接口更标准,完善,传统语言处理系统进程之间的通信,oh my god,你需要学习系统底层系统进程之间通信的内容。

use Promise but not foreach

虽然Node.js是提倡异步,诚然你的代码也的确是使用异步函数。但,当你面临需要处理大量的循环,每个循环都需要进行相关的异步I/O操作时,请使用Promise,而不是foreach。

这种场景很常见,比如:

let rows = [/**假设这里有很多记录**/];

rows.forEach(async (row, i) => {
    let query = await db.query('....', [row.id]); // 查询是否有重复的记录
    if (query.rows.count > 0) { // 如果有
       let isUpdate = await db.update('....', [row]); // 更新操作
    } else {
       let isInsert = await db.insert('....', [row]); // 插入操作
    }
});

这个代码表面上是没有问题的,但执行上是存在I/O的性能问题的。在实际执行的时候,forEach的循环,会在一个瞬间执行完毕,因为所要执行的操作都是异步的,循环并不会阻塞在每一行数据处理中。这就带来一个问题,如果rows数量上万,就会在一瞬间发起10000个I/O异步操作,执行的结果,你会看到靠前的操作先回来,可是越靠后的操作,返回越慢(也可能造成I/O产生阻塞,靠后的异步操作挂起或者操作失败)。因为虽然你调用的是异步方法,但你没有控制循环的过程。

这种情况下,最好的处理方式是,是使用Promise(或尾递归)来处理每一行,等该段处理完毕,再开始下一个(实际程序,你可以根据实际情况控制一次处理的数量)。

let rows = [ /**假设这里有很多记录**/ ];

let handleRow = (row) => {
	return new Promise(async (resolve) => {
		let isSuccess = false
		if (typeof row !== 'undefined' || row !== null) {
			let query = await db.query('....', [row.id]); // 查询是否有重复的记录
			if (query.rows.count > 0) { // 如果有
				isSuccess = await db.update('....', [row]); // 更新操作
			} else {
				isSuccess = await db.insert('....', [row]); // 插入操作
			}
		}
		resolve(isSuccess);
	});
};

// Promise 模式
let promiseStart = () => {
	if (row.length > 0) {
		const row = rows.shift();
		handleRow(row).then(isSuccess => {
			promiseStart();
		});
	} else {
		// complete
	}
};

promiseStart();

use async/await

使用async/await,能大大简化异步回调函数的嵌套问题。呃,特别是涉及到数据库事务提交,多段数据库查询,乃至包含其他的异步I/O操作,异步回调的嵌套简直就是噩梦。

不过使用async/await,记得用try ... catch 来捕获异步的异常,不然……

结尾

关于如何编写稳定的Node.js服务,其实还有很多细节,本文先分享一些易于总结的经验,以后再分享其他方面的经验。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章