Оптимизация ресурсов в Node.js

В этой статье мы исследуем способы максимального использования возможностей Node.js. В частности, раскроем преимущества совместного применения ресурсов, опровергнув предположение о том, что каждый запрос должен быть изолирован.

Мы знаем, что Node.js  —  быстрый, однопоточный и неблокирующий фреймворк. Но используем ли мы его по максимуму? В большинстве случаев  —  нет.

Поскольку он однопоточный, мы часто упускаем из виду тот факт, что все же имеем нескольких строк выполнения, которые напоминают потоки! Поэтому можно улучшить способ выполнения кода, сделав ресурс, принимающий один поток, доступным для других потоков, тем самым снизив нагрузку на эти драгоценные ресурсы.

Совместное использование потоков и процесс разделения потоков

Допустим, у нас есть конечная точка, которая вызывает API, и к этой конечной точке одновременно обращаются многие клиенты. Когда поступает один запрос, требующий данных и вызывающий этот API, затем второй запрос, вызывающий тот самый API, которого уже ждет первый запрос, почему бы этим запросам не использовать совместно один и тот же API?

Разработчики часто делают неверное предположение о том, что при работе на серверах Node.js каждый запрос должен быть полностью изолирован от других, а также выполнять вызовы и запросы к базе данных, будучи изолированным от остальных. Но это не так.

Запросы могут совместно использовать один и тот же ресурс, когда выполняются следующие условия.

  • Это не данные клиента: токен аутентификации клиента не используется (никогда не надо смешивать эти запросы из-за отслеживания, то есть мы должны знать, кто сделал запрос).
  • Данные запросов идентичны.
  • Обеспечен контроль за тем, чтобы возникающие ошибки не передавали информацию другим клиентам (что приводит к проблемам GDPR), чего можно избежать, регистрируя исходную ошибку и выдавая общую ошибку всем ожидающим промисам.
  • Это должен быть часто выполняемый вызов, желательно такой, который требует некоторого времени на выполнение. В таком случае имеет смысл совместно использовать ресурсы для запросов нескольких исполнителей  —  иначе польза будет почти незаметна.

Что касается потока запросов, посмотрите на изображение ниже, где каждая строка представляет собой запрос, а каждый цветной столбик  —  время, затраченное на использование ресурса. Поскольку каждый запрос полностью независим, ресурсы не разделяются, что обычно критично, когда приложения делают тысячи одновременных запросов, и эту проблему не так-то просто решить. 

Многочисленные изолированные запросы

При работе с особо специализированными сервисами также можно получать несколько запросов на один и тот же ресурс  —  и повысить эффективность приложения.

Обратите внимание: некоторые вызовы были заменены на промисы. Дело в том, что один и тот же ресурс уже был получен, поэтому мы решили совместно его использовать вместо того, чтобы вызывать снова, что снижает нагрузку на такие ресурсы.

Совместное использование ресурсов несколькими запросами

В таких языках, как Java, разработчики используют синхронизированные методы для контроля доступа к ресурсам. Преимущество Node.js в том, что нет необходимости выполнять системные вызовы для мьютексов или семафоров, которые являются дорогостоящими, учитывая архитектуру Node.js, и эта особенность делает его еще быстрее.

Конечно, в этом примере использован один экземпляр сервиса. Сделать это с несколькими экземплярами немного сложнее, хотя концепция та же (я работаю над более продвинутыми распределенными паттернами).

Самое интересное в этой теме то, что речь идет не только об экономии ресурсов. В действительности оптимизация ресурсов делает приложение еще быстрее. Каким образом? Предположим, операция занимает 200 мс, и все последующие запросы на эту же операцию используют ее повторно. Это означает, что любой входящий запрос в течение указанных 200 мс будет повторно использовать этот результат, даже если он начнется через 1 мс после запуска начальной операции или через 200 мс. Так что в среднем повторно используемые операции занимают 200 мс/2=100 мс.

Повторно используя текущие операции, вы сэкономите в среднем половину времени первоначальной операции.

Время, затраченное на 3 текущие операции, использующие один и тот же ресурс

Если только вы не работаете в рамках операции, подобной транзакции, или не выполняете вызов API, который использует определенный пользовательский токен (в этом случае вам не следует делиться полученными данными), вы можете распределять данные из значительной части общих операций без каких-либо опасений.

Как можно достичь этого? С помощью промисов.

Когда вы обнаруживаете, что вызов конкретного ресурса уже начался, вместо того чтобы начинать другой вызов, просто возвращаете промис для его результата (или неудачи). Таким образом, можно избежать одновременных запросов к API, запросов к базе данных (или чего бы то ни было, что вам нужно вызвать), снижая нагрузку на ресурсы.

Попробуем реализовать простой вызов, который занимает некоторое время и возвращает результат. Для этого умножим пару значений с задержкой в 200 мс, которые будут представлять вызов API или запрос к базе данных:

import { delay } from 'ts-timeframe';

async function simulatedCall(a: number, b: number) {
// эти 3 строки представляют вызов
await delay(200);
console.log(`calculated ${a} * ${b}`);
return a * b;
}

// функция получения данных; это может быть вызов API, запрос к базе данных, любой медленный необходимый промис
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 раз и подождали 200 мс для выполнения каждого вызова:

Каждый вызов был выполнен и каждый занял 200 мс

Теперь изменим точно такой же код, чтобы воспользоваться преимуществами этого паттерна для повышения эффективности приложения. На этот раз будем применять класс OperationRegistry для управления вызовами и, что более важно, создадим уникальный ключ для идентификации операции в реестре.

Как только это будет сделано, вызываем функцию isExecuting и смотрим, возвращает ли она промис. Если да, то это означает, что другое выполнение уже идет, и просто нужно вернуть промис, ожидая его результата. В противном случае выполняем вызов, передаем результат всем ожидающим промисам и возвращаем значение. Чтобы передать результат ожидающим промисам, используем функцию triggerAwaitingResolves или triggerAwaitingRejects в зависимости от того, была ли операция успешной или нет.

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

const operationRegistry = new OperationRegistry('costlyFunction');

async function simulatedCall(a: number, b: number) {
// эти 3 строки представляют вызов
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);

// промис означает, что выполнение для одного и того же ключа продолжается, нам просто нужно его дождаться
if (promiseForResult) {
return promiseForResult;
}

try {
// в противном случае мы вызываем это
const value = await simulatedCall(a, b);

// передача значения ожидающим промисам (в следующем цикле событий, чтобы не задерживать текущее выполнение)
operationRegistry.triggerAwaitingResolves(uniqueOperationKey, value);

// возврат значения в текущее исполнение
return value;
} catch (e) {
// передача ошибки ожидающим отказам (в следующем цикле событий, чтобы не задерживать текущее выполнение)
operationRegistry.triggerAwaitingRejects(uniqueOperationKey, e);

// выброс исключения в текущее выполнение
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  —  по одному разу на каждый уникальный ключ. Конечно, этот паттерн будет полезен только в тех случаях, когда выполняется одна и та же операция несколько раз, либо операции занимают слишком много времени, либо они происходят часто.

Но есть одна загвоздка: ошибка и результат являются общими для всех выполнений, поэтому будьте очень внимательны, чтобы не испортить общий результат, иначе могут возникнуть неожиданные ошибки. Не забудьте клонировать объект, если нужно его изменить.

Выводы

  • Хотя такой подход не всегда применим, для параллельных приложений он имеет большое значение, поскольку ресурсы  —  очень дефицитная вещь. Более того, как было доказано в этой статье, он не только освобождает ресурсы, но и улучшает время работы приложения.
  • Описанный подход будет иметь огромное влияние на операции, которые вызываются часто, но что более важно, он экономит ресурсы и повышает стабильность системы. Даже незначительный выигрыш помогает сократить время отклика P99, а это очень важно.
  • Если добавить кэш к совместному использованию ресурсов, будет еще лучше! Только представьте, что можно сделать, если вместо экономии ресурсов на одном экземпляре, сэкономить их на всех экземплярах сервиса. Тогда будет больше шансов попасть на общий ресурс для нескольких экземпляров.
  • Эти мелкие детали отличают хорошо разработанные микросервисные архитектуры от плохо реализованных, потому что мощность процессора и память не решают всех проблем, и наличие оптимизированного сервиса  —  это то, что отличает победителя от проигравшего.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Nelson Gomes: Resource optimization in Node.js

Предыдущая статьяКак зарабатывать с помощью Python в 2023 году
Следующая статьяRaspberry Pi + Pushover + Puppeteer = автоматизация повседневных задач