Nodejs 做后端有问题吗?——单线程

1,386 阅读6分钟

背景

最近公司在考虑使用 Nodejs 做 BFF 服务,以承载一些非业务相关(单账号登录、个性化 UI 配置存储等)的功能。

用 Nodejs 做后端,在公司还未有过先例。虽然这在业内已经被证实是可行的,甚至「淘宝天猫双十一」都已经大规模的应用了 Nodejs 服务(详见 案例)。但是别人是别人,这些理由对于说服公司里的传统后端同学来说,还是不够「以理服人」的,大家心里多少会有一些「不踏实」。

经过一些非正式调研,笔者将大家对 Nodejs 做后端的担心主要归为以下几类:

  1. 性能
  2. 开发与维护效率
  3. 单线程引起的问题
  4. 生态

篇幅有限,再叠加上其它原因,笔者选择先深入研究下第 3 个问题,也就是「单线程」问题。目的是为了让 Nodejs 零基础的前后端同学,看过之后也能够达成统一认知 —— Nodejs 单线程就不是个问题

正文

本文的结构大概是:说明问题 - 解决思路 - 业内实践 - 其它扩展。

问题模拟

第一个,也是最大的疑惑:单线程,一个请求上来,如果把服务打崩了,那就彻底 BBQ 了?

那么我们就先尝试模拟一下问题发生的场景:

先用原生 nodejs 的 http 模块写一个最简单的服务,要求能通过 URL 中 query 制造异常,使服务崩溃。我们利用 JSON.parse 来制造异常,代码如下:

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

http
  .createServer(function (request, response) {
    const urlParms = new URL(request.url, `http://${request.headers.host}`);
    response.writeHead(200, { "Content-Type": "text/plain" });
    switch (urlParms.pathname) {
      case "/":
        const query = urlParms.searchParams.get("q");
        // 利用此处 JSON.parse 制造异常
        const data = JSON.parse(query);
        response.end(JSON.stringify(data));
        break;
      default:
        response.end("Hello World\n");
    }
  })
  .listen(8888);

运行 node demo.js 启动之后,我们就可以通过浏览器输入 localhost:8888/?q=xxx 来访问服务了。当 q=1 时,不会产生异常,这时候我们可以看到页面可以正常返回个 1;q=x 时,服务果然 crash 了,报了如下错误:

undefined:1
x
^

SyntaxError: Unexpected token x in JSON at position 0
    at JSON.parse (<anonymous>)
    at Server.<anonymous> (/home/tusimple/projects/express-demo/demo.js:10:27)

浏览器也会显示如下信息:

image.png

然后即使把参数修改正确后再访问,还是得到的这个画面(显然的,服务都崩了)。

所以,这证明了如果是单线程,一个接口就可以很轻易地把整个服务打崩。那么怎么办呢?

解决思路有两个:要么崩了之后能自动重启,要么想办法不崩,我们一个一个得看。

自动重启服务

说实话,这个方法不算是治本,但是可以一定程度减少损失,尤其是可以争取时间。有时候可能会因为操作系统调度等外部原因,造成服务进程的崩溃(比如内存打满了,CPU 打满了之类的),这种情况在服务代码层面是无能为力的。这时候我们就需要 pm2 这种进程管理工具来启动和管理服务了。

接下来我们试一下用 pm2 启动,pm2 start demo.js。图就不截了,直接说测试的结论:

  1. 用正确的 query 访问,看到正常的网页;
  2. 用错误的 query 访问,浏览器展示错误页面;
  3. 再用正确的 query 访问,看到正常的网页;

这时候我们运行 pm2 log demo 看下,log 记录里面的确有 crash 的日志。 image.png

所以使用 pm2 可以临时性的、一定程度的解决服务崩溃的问题

不过既然是重启,那肯定还是需要时间的。再追求一下极致,追问一下,pm2 重启服务需要多久呢?

pm2 重启需要多久

我们写一个脚本来测试一下。脚本的逻辑大概是:

循环多次请求,其中第 2 次请求故意让服务 crash,其余的请求都是正常请求。看在第几次请求后能够正常返回,同时记录下时间戳,方便计算时间。脚本如下:

#!/bin/bash
for ((c = 1; c <= 99; c++)); do
  echo "$c:" >> a.txt
  if [ $c -eq 2 ]; then
    curl "http://localhost:8888/?q=x" >> a.txt
    # echo "2"
  else
    curl "http://localhost:8888/?q=1" >> a.txt
  fi
  echo $(($(date '+%s') * 1000 + $(date '+%N') / 1000000)) >> a.txt 
done

经过多次运行,基本上都是在第 14 或 15 次请求时恢复,时间差大概在 150~250ms 之间。这个实验虽然不太严谨,但是基本可以有一个初步的认知:

pm2 重启一个简单的 node 服务,大概需要 200ms 左右的时间

这个粒度的结论基本上就够了,大概知道一个数量级就可以了。后面才是更彻底的解决方案。

防止服务崩溃

解决办法其实非常简单,在请求逻辑的最外层套一个 try catch 就可以了:

const http = require("http");

http
  .createServer(function (request, response) {
    try {
      // ...code above
    } catch {
      response.end("CRASH!!!\n");
    }
  })
  .listen(8888);

这样,即使是故意造成 crash,浏览器也能收到兜底的 CRASH!!!,服务也不会崩掉。

有同学可能会问,你的 try 不是在最外层啊,只覆盖到了接口逻辑,要是最外层崩溃了怎么办?

笔者认为,这样的代码,应该根本跑不起来,实际情况中应该不会允许这种情况发生。

而且如果真发生了,其问题本质应该不是 Nodejs 单线程的问题了,比如「生产环境读取了不存在的全局变量」之类的问题,这种问题就不在本文的讨论范围之内了。

接下来我们来看下框架的表现,来看下比较有代表性的 Express。

Express 实验

var express = require("express");
var router = express.Router();

router.get("/test", function (req, res, next) {
  const { query } = req;
  console.log(JSON.parse(query.q))
  res.send(JSON.stringify(query));
});

module.exports = router;

我们还是将 query 按照 1、x、1 的顺序请求一下,得到如下 log 和错误时的页面:

image.png

image.png

所以其实框架都给你解决好了,log 和异常处理的更加合理,放心的用吧。

如何利用多线程

先说一下最佳实践吧,非常简单,利用 pm2 就可以实现:

pm2 start demo.js -i max # or 2,3,4

然后你就会看到:

image.png

没错,笔者的电脑的 CPU 是 8 核的(Intel® Core™ i7-10510U CPU @ 1.80GHz × 8 ),它起了 8 个实例,就问你怕不怕……

说完了实践,我们还是去深入原理层面看看。在 pm2 - cluster-mode 的文档中做了如下解释:

Under the hood, this uses the Node.js cluster module such that the scaled application’s child processes can automatically share server ports.

这个 cluster module 是 Nodejs 的原生模块,看名字就能猜到八九不离十了,就是像集群一样,起多个实例嘛。至于原理,我们先来看看 Nodejs 官网 给出的示例代码:

import cluster from 'node:cluster';
import http from 'node:http';
import { cpus } from 'node:os';
import process from 'node:process';

const numCPUs = cpus().length;

if (cluster.isPrimary) {
  console.log(`Primary ${process.pid} is running`);

  // Fork workers.
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.on('exit', (worker, code, signal) => {
    console.log(`worker ${worker.process.pid} died`);
  });
} else {
  // Workers can share any TCP connection
  // In this case it is an HTTP server
  http.createServer((req, res) => {
    res.writeHead(200);
    res.end('hello world\n');
  }).listen(8000);

  console.log(`Worker ${process.pid} started`);
}

大概的逻辑就是:

  1. 获取机器 CPU 的核数;
  2. 判断是否是主进程(第一个):
    • 如果是,就开始 fork cluster(确切的说是子进程),直到等于 CPU 核数;
    • 如果不是,就起一个 server 的实例;

其底层利用的其实是 Nodejs 的 Child processes 功能。更多的原理就不在这里介绍了,可以查看 Nodejs 的文档。另外,关于为什么多进程可以监听同一个端口,可以看一下这篇文章:《通过源码解析 Node.js 中 cluster 模块的主要功能实现》。

总之,Nodejs 还是保持单进程配单线程的设定,用多个子进程来实现 CPU 的充分利用,更多的内容,可自行查看文末的参考文献。

题外话
有的比较老的文档里用的是 cluster.isMaster,而新文档里就都是 cluster.isPrimary 了。据说这是因为原来的 Master/Slave 具有种族主义色彩,所以大家就都改名了。什么 Master/replica、Master/standby、Primary/secondary……
更多命名见 Master/slave (technology)Replacement terms 部分。

总结

综上,Nodejs 单线程的问题,反而是这些问题当中最不需要关心(解决最好)的问题。所以如果你只是因为担心 Nodejs 单线程不稳定,而拒绝使用它实现服务端,那么大可不必。可以再关心一些其它方面的问题。

实际上,Nodejs 的 Event Loop 机制,让回调和异步执行深刻的印在了 JSer 的认知里,这与其他后端语言有着非常大的差异。这让 Nodejs 在处理高 I/O 并发方面,有着得天独厚的优势。

诚然其他语言也有这种异步的方案和框架,但是这种思想对于普通的后端开发者来说,并不算熟悉,没有 JSer 接受起来那么自然。而且代码量也具有明显的优势,这就意味着在开发效率上也是有优势的。

所以,总体来说,笔者目前最担心的反而是 Nodejs 服务与各种运维工具的结合,也就是其后端生态。这也是接下来笔者主要研究的方向。相信这次研究完成之后,如果再碰到对 Nodejs 做后端有异议的情况,直接把这几篇文档丢出,去就能解决绝大多数的疑问了。当然,自己心里也更有底了。

最后要说一句:pm2 真香~~

Design is there to enable you to keep changing the software easily in the long term. -- Kent Beck

软件设计是为了「长期」更加容易地适应未来的变化。

参考文献