充分利用 Node – Node.JS 假日季,第二部分

Mozilla 身份团队的《Node.JS 假日季系列》的第二集,探索了针对计算密集型工作负载的最佳服务器应用程序架构。

这是 Lloyd Hilaiel 在 2012 年 Node Philly 上发表的同名简短演讲的文字版本,请点击此处观看视频

由于 Node.JS 进程几乎完全运行在单个处理核心上,因此构建可扩展的服务器需要特别注意。
借助编写原生扩展的能力以及一套强大的进程管理 API,有多种方法可以设计并行执行代码的 Node.JS 应用程序:在本篇文章中,我们将评估这些可能的设计。

本文还介绍了 compute-cluster 模块:一个小型 Node.JS 库,它可以轻松管理一系列进程以分发计算。

问题

我们为 Mozilla Persona 选择了 Node.JS,并在其中构建了一个可以处理大量具有混合特征的请求的服务器。
我们的“交互式”请求执行的计算成本低,需要快速完成以保持 UI 的响应能力,而“批处理”操作需要大约半秒的处理器时间,并且可以在不损害用户体验的情况下延迟一段时间。

为了找到一个优秀的应用程序设计,我们查看了应用程序必须处理的请求类型,仔细思考了可用性和扩展成本,并提出了四个关键需求

  • 饱和度:解决方案能够使用所有可用的处理器。
  • 响应能力:我们的应用程序 UI 应始终保持响应能力。
  • 优雅:当处理的流量超过我们的处理能力时,我们应该为尽可能多的用户提供服务,并立即向其余用户显示清晰的错误。
  • 简单性:解决方案应该易于增量集成到现有的服务器中。

有了这些需求,我们就可以有意义地对比这些方法了

方法 1:直接在主线程上执行。

当计算在主线程上执行时,结果很糟糕
您无法饱和多个计算核心,并且反复半秒的交互式请求饥饿会导致您无法响应优雅
此方法唯一的好处是简单性

function myRequestHandler(request, response) {
  // Let's bring everything to a grinding halt for half a second.
  var results = doComputationWorkSync(request.somesuch);
}

在预期一次服务多个请求的 Node.JS 程序中进行同步计算是一个糟糕的想法。

方法 2:异步执行。

使用在后台运行的异步函数可以改善情况,对吧?
好吧,这取决于后台的确切含义
如果计算函数以在主线程上实际执行 JavaScript 或原生代码中的计算的方式实现,则性能与同步方法相比没有改善。
请看

function doComputationWork(input, callback) {
  // Because the internal implementation of this asynchronous
  // function is itself synchronously run on the main thread,
  // you still starve the entire process.
  var output = doComputationWorkSync(input);
  process.nextTick(function() {
    callback(null, output);
  });
}

function myRequestHandler(request, response) {
  // Even though this *looks* better, we're still bringing everything
  // to a grinding halt.
  doComputationWork(request.somesuch, function(err, results) {
    // ... do something with results ...
  });
}

关键点是,在 NodeJS 中使用异步 API 并不一定能产生能够使用多个处理器的应用程序。

方法 3:使用带线程库的异步执行!

如果使用以原生代码编写并巧妙实现的库,则可以在 NodeJS 中的不同线程中执行工作。
有很多例子,其中一个是 Nick Campbell 编写的优秀的 bcrypt 库请点击此处访问他的 GitHub 主页

如果您在四核机器上测试它,您会看到效果非常棒!吞吐量提高了四倍,充分利用了所有计算资源!如果您在 24 核处理器上执行相同的测试,您就不会那么高兴了:您会看到四个核心被充分利用,而其余核心处于空闲状态。

这里的问题是,该库正在使用 NodeJS 的内部线程池来解决它并非设计用于解决的问题,并且此线程池具有 硬编码的上限为 4

除了这些硬编码限制之外,此方法还存在更深层次的问题

  • 用计算工作淹没 NodeJS 的内部线程池会导致网络或文件操作饥饿,从而损害响应能力
  • 没有好的方法来控制积压——如果您的队列中已经有 5 分钟的计算工作,您真的想再添加更多工作吗?

以这种方式“内部线程化”的库无法饱和多个核心,会对响应能力产生不利影响,并限制应用程序在负载下优雅降级的能力。

方法 4:使用 node 的 cluster 模块!

NodeJS 0.6.x 及更高版本提供了一个 cluster 模块,它允许创建“共享侦听套接字”的进程,以在一些子进程之间平衡负载。
如果您将 cluster 与上述方法之一结合使用会怎样?

由此产生的设计将继承同步或内部线程化解决方案的缺点:它们不响应且缺乏优雅

简单地旋转新的应用程序实例并不总是正确的答案。

方法 5:介绍 compute-cluster

我们在 Persona 中解决此问题的当前方案是管理一个用于计算的单用途进程集群。
我们在 compute-cluster 库中将此解决方案进行了概括。

compute-cluster 为您生成并管理进程,为您提供了一种以编程方式在本地子进程集群上运行工作的方法。
用法如下

const computecluster = require('compute-cluster');

// allocate a compute cluster
var cc = new computecluster({ module: './worker.js' });

// run work in parallel
cc.enqueue({ input: "foo" }, function (error, result) {
  console.log("foo done", result);
});
cc.enqueue({ input: "bar" }, function (error, result) {
  console.log("bar done", result);
});

文件 worker.js 应响应 message 事件以处理传入的工作

process.on('message', function(m) {
  var output;
  // do lots of work here, and we don't care that we're blocking the
  // main thread because this process is intended to do one thing at a time.
  var output = doComputationWorkSync(m.input);
  process.send(output);
});

可以在不修改调用者的情况下,将 compute-cluster 集成到现有的异步 API 后面,并通过最少的代码更改开始在多个处理器上真正并行执行工作。

那么这种方法如何实现这四个标准呢?

饱和度:多个工作进程使用所有可用的处理核心。

响应能力:因为管理进程只做进程生成和消息传递,所以它保持空闲状态,并且可以将大部分时间用于处理交互式请求。
即使机器负载很高,操作系统调度程序也可以帮助优先处理管理进程。

简单性:集成到现有项目中很容易:通过将 compute-cluster 的细节隐藏在一个简单的异步 API 后面,调用代码可以愉快地忽略这些细节。

现在,在流量激增期间如何优雅降级呢?
同样,目标是在流量激增期间以最大效率运行,并为尽可能多的请求提供服务。

Compute cluster 通过管理的不仅仅是进程生成和消息传递来实现优雅的设计。
它跟踪正在运行的工作量以及工作平均完成所需的时间。
有了这种状态,就可以可靠地预测工作在排队之前需要多长时间才能完成。

将此知识与客户端提供的参数 max_request_time 相结合,可以预先拒绝可能需要比允许时间更长的时间才能完成的请求。

此功能使您可以轻松地将用户体验需求映射到您的代码中:“用户不应该等待超过 10 秒才能登录”,转换为大约 7 秒的 max_request_time(加上网络时间填充)。

在 Persona 服务的负载测试中,到目前为止结果很有希望。
在极端负载下,我们可以允许已认证的用户继续使用服务,并在最开始就阻止一部分未认证的用户,并显示清晰的错误消息。

后续步骤

使用进程进行应用程序级并行化适用于单层部署架构——在这种架构中,您只有一种类型的节点,并且只需添加更多节点即可支持扩展。
但是,随着应用程序变得越来越复杂,部署架构可能会发展为拥有不同的应用程序层以支持性能或安全目标。

除了多个部署层之外,高可用性和扩展通常还需要在多个托管设施中部署应用程序。最后,可以通过利用按需云计算资源来实现计算绑定应用程序的经济高效的扩展。

在多个托管设施中拥有多个层和按需生成的云服务器会大大改变扩展问题的参数,而目标保持不变。

compute-cluster 的未来可能涉及能够在多个不同的层上分配工作,以便在负载期间最大程度地利用可用的计算资源。
这可能跨托管设施工作以支持地理位置不对称的流量激增。
这可能涉及能够利用按需生成的新的硬件…

或者我们可能会以不同的方式解决问题!如果您对一种优雅的方式有任何想法,可以让 compute-cluster 在网络上分配工作,同时保留其迄今为止具有的特性,我非常乐意听取您的意见!

感谢您的阅读,您可以加入讨论,并从 我们的邮件列表 中了解更多关于 Persona 中当前扩展挑战和方法的信息。


16 条评论

  1. Johan Sundström

    关于提高读者友好性的建议:让每篇文章的导语链接都突出显示到该系列中的第一篇、上一篇和下一篇。

    除此之外,工作很棒!

    2012 年 11 月 20 日 11:18

    1. Lloyd Hilaiel

      非常棒的想法,这将保持系列的连贯性。我将更新这篇文章。

      2012 年 11 月 20 日 13:00

  2. Ryan Doherty

    尽管我非常非常喜欢 Node.js,但当我阅读类似这样的文章时,我不禁怀疑 Node.js 的架构是否出了问题,或者我们是否试图将方形木桩打入圆形孔中。

    将所有内容保留在 Node.js 中以执行这些计算密集型操作有什么好处?其他平台/环境能否更好地实现您的四个需求?我不是后端专家,但当您创建类似这样的解决方法时,它应该会让您质疑您的决定。

    我没有参与您的决策过程,因此您可能已经分析了其他环境和/或对该问题进行了长时间的讨论。我很乐意了解这些信息!:)

    我之所以提出这些问题,是因为我担心新的 Node.js 开发人员在寻找解决此类问题的方案时可能会遇到困难。如果他们没有意识到您考虑过所有复杂性和细微差别,他们可能无法做出最佳决策。

    话虽如此,这篇文章写得很好,我喜欢您将用户需求与技术需求联系起来的方式。我期待更多更深入的 Node.js 文章!

    2012 年 11 月 20 日 12:38

    1. Lloyd Hilaiel

      Yo Ryan,

      感谢您的想法。反思我们的语言选择并询问我们是否真的给自己找了麻烦是值得的。我也很难对我对 node.js 的评价保持公正,但还是说一下吧。

      我们这里特别讨论的计算是 bcrypt 密码——使用一个故意设计得很复杂的函数对密码进行散列,以便我们永远不会以明文形式存储它们,即使服务器受到攻击,攻击者也需要付出(通常难以处理的)大量工作才能将散列转换为明文密码。执行此操作的代码是用 C 编写的。

      现在,无论您的服务器平台是什么,如果您需要运行大量需要在您的硬件上花费半秒的并行计算操作,您都*必须*认真考虑一下。我对 Node.JS 感到满意的地方在于,将原生代码绑定到 JavaScript 运行时非常容易(V8 的嵌入器 API 非常棒,与 Firefox 中的 jsctypes 或 Ruby 的绑定接口相当)。再加上一个非常简单且健壮的进程管理 API,以及快速的进程启动(大约 30 毫秒),以及最小的进程内存开销(大约 12 MB),使得实现该解决方案变得容易,因此所有工作都用于设计它——在我看来,这并不像一种解决方法。在我看来,这就像您必须对任何应用程序进行的性能调整。

      与我使用过的其他服务器平台相比,我想不出哪个平台可以更轻松地解决这个特定问题。

      所以我不确定——我们还没有真正后悔选择 node.js。但与任何服务器平台一样,您确实需要投入时间来了解和应用您选择的工具。

      关于您的最后一点,在本博客文章系列中的主要目标之一是提高人们对在 Node.js 上构建世界级服务时出现的一些更棘手的问题的认识——我们希望通过提供和推广像 compute-cluster 这样的库来为社区做出贡献,并推广“正常工作”的应用程序设计模式。

      简而言之,我同意 node.js 并不是一个银弹,它不会始终快速运行而无需您考虑。但我认为没有其他选择在这方面明显更好。

      更深入的 Node.JS 文章?没问题!我们还有至少十几篇 Node.js 文章即将推出,每隔几周发布一篇!

      感谢您的阅读!

      2012 年 11 月 20 日 13:46

      1. Ryan Doherty

        太棒了,谢谢 Lloyd!这正是我寻找的细节,我发现它很有教育意义。进程启动时间和内存开销是我以前没有考虑过的事情。

        关于任何 CPU 密集型任务都难以围绕其构建架构的观点也很正确 :)

        2012 年 11 月 20 日 15:20

  3. NN

    括号错误

    > function myRequestHandler(request, response) [

    应该是 {

    2012 年 11 月 20 日 17:23

    1. Lloyd Hilaiel

      哎呀!捕捉到了。

      2013 年 1 月 31 日 10:09

  4. Andrew Chilton

    您好,Lloyd,

    我认为还需要在列表中添加另一项,即启动一堆“工作”服务器,这些服务器可以接收工作片段并返回结果。这样做的好处是,工作者可以是本地或远程的,因此您也可以水平扩展。

    不太清楚为什么,但我喜欢很多小服务器/工作器互相通信的想法。我想就像你的解决方案需要池化并跟踪工作器一样,这也需要跟踪工作器在哪里以及它们做什么。

    我打算试用一下 dnode 看看是否有效。

    * https://github.com/substack/dnode

    感谢您开启了一系列精彩帖子的序幕。 :)

    干杯,
    Andy

    2012年11月20日 18:54

    1. Lloyd Hilaiel

      我对这个非常感兴趣。我一直在寻找一种零配置的方式来构建一个进程网格,该网格能够抵御故障并集成到计算集群公开的 API 后面。到目前为止,我还没有找到真正非常适合的方案。

      一个有趣的项目是构建(或贡献)一个小型的库,使构建这个网格变得非常简单。

      在我最初查看 dnode 时,发现它并不是一个*完美*的匹配 – 但也许我应该再看看?

      2013年1月31日 09:45

  5. alessioalex

    很棒的文章,继续努力。

    2012年11月23日 07:58

  6. dotnetcarpenter

    这听起来像是预分叉模型,比如 SilkJS。
    我一直认为我会使用 SilkJS 来处理耗时的任务,而使用 node.js 来处理其他任务。但后来我变成了应用程序开发人员.. 好奇你们如何看待这两种环境共存,如果有的話。

    2012年11月28日 15:09

  7. Ralf Mueller

    看来你使应用程序的垂直扩展成为可能。
    我想知道你是如何处理水平扩展的。
    如果要将繁重的计算卸载到另一台机器,你会怎么做?

    2013年1月29日 11:55

    1. Lloyd Hilaiel

      对于我们来说,我们在单台机器上运行多个“请求处理”进程,这些进程处理子进程中的所有计算。然后我们将这种模式复制到负载均衡器后面的多台机器上。

      因此,针对我们的应用程序,我们希望随着负载变得过大,事情按以下顺序中断

      1. 计算请求开始失败(对我们来说,这意味着阻止用户登录 – 这是我们能做的最好的事情)
      2. 所有其他请求以某种比例开始,以便剩余的请求在合理的时间内完成(得到服务的用户体验服务响应迅速)。

      但直接回答你的问题,我们网络上确实有其他机器不是 CPU 绑定的,并且是否将网络上的分布构建到计算集群中,或者只是重新配置我们的部署以按合理的比例扩展各个层,这是一个悬而未决的问题。前者很吸引人,最终可能会使应用程序更灵活并且需要更少的配置 – 后者很容易。 :)

      2013年1月31日 09:50

  8. gggeek

    作为一个完全不懂 node 的人,这看起来很像重新发明轮子。

    Apache 自古以来就采用了多进程执行模型,其中应用程序开发人员不需要关心管理“工作器”,因为 Web 服务器会为他完成。

    使用 FCGI,(php) 开发人员再次获得了经过广泛测试的工作器进程管理解决方案的好处。

    我认为在 Java 领域,有数百种针对此问题的知名解决方案,其中包含大量可调整的旋钮和可调优的线程池。

    因此,我的问题是:node.js 作为超快和超可扩展的魔力承诺是否确实附带了一些讨厌的条件?有多少真实的 Web 应用程序可以使用纯异步库开发?有多少异步库真正具有可扩展性?有多少 node.js 爱好者甚至意识到手头的问题?

    2013年1月30日 13:04

    1. Lloyd Hilaiel

      你提出了一些难题。我提供个人偏见作为回应,并希望它们有用

      Apache:node 每个请求所需的资源大大减少。

      FCGI/php:加剧了每个请求的资源需求。

      Java 和线程池:异步代码与线程代码的争论很深。在我广泛使用前者之后,我个人更喜欢后者 – 我喜欢 javascript 的一点是,在实现异步代码方面,它是我使用过的最易读和最自然的语言。我可能不想说出来,这是一个充满意见的雷区!

      “有多少异步库真正具有可扩展性?” 和 “有多少 node.js 爱好者甚至意识到手头的问题?”

      请参阅帖子中的方法 3 – 我认为一些知名的库作者在公开使用 libuv 的线程池的异步实现方面存在误导。此外,在我参加的几次 node.js 会议中,并非所有与会者都了解在 node.js 中大规模处理计算的具体挑战 – 也无法提出可行的解决方法。但我们都是从某个地方开始,变得兴奋,然后学习更多。不是吗?

      我在这里没有看到任何根本问题。目前,Node.JS 是一个有趣、稳定、快速的软件构建环境 – 它拥有一个很棒的社区,并且可以像我应用过的任何其他技术一样解决我正在处理的问题。所以。让我们玩吧!

      2013年1月31日 10:08

  9. Jorge

    你需要的是线程多多

    2013年3月31日 21:52

本文评论已关闭。