【翻】Resource optimization in Node.js

为了提高英语水平和保持技术成长,开始按计划翻译一些短篇和博客,有问题欢迎讨论👻
原文:Resource optimization in Node.js
原作者:Nelson Gomes

正文

在这篇文章中,我们将探索Node.js最大优化的可能性,并了解资源共享的好处,反证每个请求都必须隔离的假设。加入我们,发掘Node.js的全部潜力,了解资源优化如何提高应用程序的性能和效率。

我们都知道Node.js速度快,单线程,无阻塞,但我们在开发中是否充分利用了呢?大多数情况下的答案是”没有”

因为它是单线程的,我们往往会忘记我们仍然有几个类似线程的执行方式!所以我们可以改进代码的执行方式,将线程获得的资源提供给其他线程使用,从而减少这些宝贵资源的负载。

假设我们有一个端点调用一个API,并且该端点同时被许多客户端调用。因此,当第一个请求到达并需要调用该API时,第二个请求也到达并调用与第一个请求完全相同的API,为什么不共享它呢?

开发人员经常有个错误的假设,认为在Node.js服务器上工作时,每个请求都必须与其他请求完全隔离,每个请求都需要在与其他请求隔离的情况下进行调用和数据库请求。

事实并非如此。

如果满足某些情况,请求可以共享同一个资源:

  • 首先这个请求不是客户特定数据,或者没有使用 authentication token (我们绝不能混合这些请求,这意味着我们需要知道谁提出了请求)。
  • 请求数据确保是完全相同。
  • 我们要确保如果发生错误,不会将信息泄露给其他客户端(导致GDPR问题),可以通过记录原始错误并向所有客户端发送一个通用错误来避免。
  • 最后,它需要是一个频繁调用的请求,最好是需要一些时间来执行,这样资源共享对多个执行请求来说才有好处,否则好处几乎不会察觉

关于请求流程,请查看下图,每一行代表一个请求,每个颜色条代表使用资源所花费的时间。因为每个请求都是完全独立的,我们并没有共享资源,而当应用程序进行数千个并发请求时这通常很重要,并且不容易解决。

当我们拥有非常专门化的服务时,我们可能会有多个请求请求完全相同的资源,这是改进我们应用程序的机会。

请看,一些调用被Promise替代了。这是因为同样的资源已经被获取,所以我们决定共享它而不是再次调用,从而减轻资源的负载。

在Java等语言中,开发者使用同步方法来控制资源访问。而对于Node.js有个好处,由于Node.js的架构,不需要对 mutexes 或 semaphores 进行昂贵的系统调用,这使得Node.js的速度更快。

当然,在这个例子中,我说的是服务的单个实例。在多个服务实例上进行此操作稍微复杂一下。尽管概念是相似的(我正在研究更高级的分布式模式)

这个优化点的有趣之处不仅在于节省资源,实际上,它还能让你的应用程序变得更快。你可能会问。怎么可能?让我们假设一个操作耗时200ms,并且同一操作的任何后续请求都会重复使用该操作。这意味着,在这200ms内任何传入的请求都将重用该结果,即使它是在初始操作开始1ms后或200ms后开始的,因此重用操作平均耗时200ms/2=100ms。

通过重复这些操作,你将平均节省一半的时间,这是一个极大的优化。

除非你在类似 transaction 的操作范围内运行,或者在API调用时使用了特定的 用户token(在这种情况下,你不应该共享由此产生的数据),除此之外你可以毫无顾虑地共享大部分常见操作数据。

那我们如何实现这个目标,Promise 就是答案

当我们检测到对资源的调用已经开始时,我们不启动对它的另一个调用,而是返回一个Promise来表示其结果(or failure)。这样,就可以避免对API、数据库查询或任何需要调用的并发请求,从而降低资源负载。

我们来实现一个简单的调用,它需要一些时间并返回一个结果。为此,我们要将几个值乘以200ms的延迟,这将代表我们的API调用或数据库查询:

import { delay } from 'ts-timeframe';

async function simulatedCall(a: number, b: number) {
  // these 3 lines represent our call
  await delay(200);
  console.log(`calculated ${a} * ${b}`);
  return a * b;
}

// our data fetching function, could be an API call, db query, whatever slow promise is needed
async function costlyFunction(a: number, b: number): Promise<number> {
  return simulatedCall(a, b);
}

async function main() {
  const values = await Promise.all([
    costlyFunction(4, 5),
    costlyFunction(4, 5),
    costlyFunction(4, 5),
    costlyFunction(50, 2),
    costlyFunction(50, 2),
    costlyFunction(50, 2),
  ]);

  console.log(values);
}

main();

当我们执行这段代码时,得到了预期的结果,没有任何意外。我们调用该函数6次,每次等待200ms

现在,让我们改变这些代码,利用之前的模式来改进我们的应用程序。这次我们将使用 OperationRegistry 来管理我们的调用,更重要的是,我们将创建一个唯一的key来标识我们在注册表中的操作。

完成后,我们会调用 isExecuting 函数查看它是否返回一个Promise。如果是,这意味着另一个执行正在进行中,我们只需返回等待结果的Promise。否则,我们执行调用,将结果传播给所有待执行的Promise,并返回我们的值,根据操作是否成功,可以通过 triggerAwaitingResolvestriggerAwaitingRejects 将结果传递给特定 Promise

import { delay } from 'ts-timeframe';
import { OperationRegistry } from 'reliable-caching';

const operationRegistry = new OperationRegistry('costlyFunction');

async function simulatedCall(a: number, b: number) {
  // these 3 lines represent our call
  await delay(200);
  console.log(`calculated ${a} * ${b}`);
  return a * b;
}

async function costlyFunction(a: number, b: number): Promise<number> {
  const uniqueOperationKey = `${a}:${b}`;
  const promiseForResult = operationRegistry.isExecuting<number>(uniqueOperationKey);

  // a promise means execution for the same key is ongoing, we just need to await for it
  if (promiseForResult) {
    return promiseForResult;
  }

  try {
    // otherwise we call it
    const value = await simulatedCall(a, b);

    // pass value to awaiting promises (in next event loop, to avoid delaying current execution)
    operationRegistry.triggerAwaitingResolves(uniqueOperationKey, value);

    // return value to current execution
    return value;
  } catch (e) {
    // pass error to awaiting rejects (in next event loop, to avoid delaying current execution)
    operationRegistry.triggerAwaitingRejects(uniqueOperationKey, e);

    // throw exception to current execution
    throw e;
  }
}

async function main() {
  const values = await Promise.all([
    costlyFunction(4, 5),
    costlyFunction(4, 5),
    costlyFunction(4, 5),
    costlyFunction(50, 2),
    costlyFunction(50, 2),
    costlyFunction(50, 2),
  ]);

  console.log(values);
}

main();

让我们看看当第二次执行这段代码时会发生什么:

结果完全相同,但我们的函数只调用了2次,而不是之前的6次,每个key被调用一次。当然,只有当你多次执行相同的操作,或者因为操作耗时过长,或者因为操作频繁发生时,这种模式才是有益的。

但是有一个问题:错误和结果是所有执行共享的,所以要非常注意不要污染共享的结果,否则可能会出现意想不到的错误。如果需要更改对象,不要忘记克隆它。

Conclusions:

  • 虽然这并非易事,但对于并发应用程序来说,它能带来很大的好处,因为资源非常稀缺。此外,正如我们在本文中所解释的,这不仅可以释放这些资源,还可以提高应用程序的运行时间。
  • 这个改变会对频繁调用的操作产生巨大影响,更重要的是,它可以节省资源并提高系统稳定性。即使是微不足道的收益,也能帮助我们缩短P99的响应时间,这一点非常重要。
  • 如果在此基础上添加缓冲,效果更好!想象一下,如果不只是在单个实例上节省资源,而是在服务的所有实例之间节省这些资源,会有什么好处,因为这样就更有可能在多个实例中共享常用资源。
  • 这些小细节是区分优秀的微服务架构和一般的微服务架构的关键所在,因为CPU功率和内存并不能解决所有问题,而拥有优化的服务才是区分胜者和败者的关键所在。