Главная / Статьи / Производительность API Node.js: фреймворк оптимизации с учетом приоритетов

Производительность API Node.js: фреймворк оптимизации с учетом приоритетов

Узнайте, как классифицировать решения проблем с производительностью API Node.js по уровням затрат и влияния, чтобы сначала устранить проблемы с пулом соединений и запросами типа N+1, прежде чем заниматься более сложными оптимизациями.

1300 слов

Большинство статей о ускорении API Node.js просто предлагают список из пятнадцати советов, где рядом с такими пунктами, как использование более быстрого сериализатора JSON или пулов подключений к базе данных, стоят и другие рекомендации, словно каждый из них заслуживает одинакового внимания. Это вводит в заблуждение. Некоторые решения требуют всего десяти минут и значительно сокращают время отклика. Другие же требуют месяцев усилий для крошечной прибавки производительности. Понимание разницы между ними гораздо важнее, чем запоминание каждого пункта списка.

Уровень 1: Сделайте это в первую очередь (высокий эффект, низкие усилия)

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

Включите режим keep-alive для всех исходящих HTTP-запросов. По умолчанию HTTP-клиент Node открывает новое соединение для каждого запроса, поэтому каждый запрос к внешнему сервису снова требует выполнения полной процедуры установления TCP-соединения и согласования параметров TLS. Использование одного и того же соединения для нескольких запросов устраняет эту дополнительную нагрузку при последующих вызовах к тому же хосту.

const agent = new https.Agent({ keepAlive: true, maxSockets: 50 });

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

Позвольте независимым асинхронным вызовам выполняться параллельно, а не по очереди. Когда два оператора await не зависят друг от друга, нет причин заставлять их ждать в очереди.

// costs the sum of both calls
const user = await getUser(id);
const orders = await getOrders(id);

// costs roughly the slower of the two
const [user, orders] = await Promise.all([getUser(id), getOrders(id)]);

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

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

Для каждого запроса, который в противном случае мог бы вернуть неограниченный набор результатов, используйте LIMIT. Эндпоинт, возвращающий «все заказы, которые когда-либо оформлял этот клиент», без каких-либо ограничений, хорошо работает для совершенно нового аккаунта, но терпит неудачу у клиента с многолетней историей заказов.

Уровень 2: Стоит реальных инвестиций (высокий эффект, значительные усилия)

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

Внедрите слой кэширования для дорогостоящих и часто выполняемых операций чтения. Размещение Redis перед медленным агрегатным запросом или дорогостоящим вызовом API стороннего поставщика позволяет сократить время ответа с 200 мс до примерно 2 мс. Самая сложная часть — не создание кэша, а разработка надежной стратегии аннулирования данных в кэше, чтобы он никогда не выдавал устаревшие результаты.

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

Переходите от пагинации на основе смещения к пагинации с использованием курсора для больших или глубоких наборов результатов. Пагинация, основанная на OFFSET, становится всё медленнее по мере углубления в страницы, поскольку база данных всё равно должна просканировать все предыдущие строки. Подход с использованием курсора требует примерно одинаковых ресурсов независимо от того, находитесь ли вы на 5-й или на 5000-й странице.

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

Профиль до дальнейшей оптимизации. Как только устранены очевидные проблемы, догадки о том, что работает медленно, перестают быть надежной стратегией. Использование настоящего инструмента профилирования или APM-инструмента, такого как clinic.js — хостинговый продукт APM, или даже выполнение команды EXPLAIN ANALYZE для подозрительного запроса, позволяет увидеть, где на самом деле тратится время, а не там, где вы предполагаете.

Уровень 3: Обычно не стоит уделять ему приоритет (незначительное влияние, часто преувеличиваемое)

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

Тонкая настройка сериализации JSON. Действительно существуют более быстрые библиотеки для работы с JSON, которые могут помочь, но только в масштабах, к которым подавляющее большинство API никогда не достигает. Если ваша настоящая проблема — запрос к базе данных, занимающий 300 мс, то сокращение времени сериализации на несколько миллисекунд решает неверную проблему.

Замена фреймворков исключительно ради снижения нагрузки от них. Разница в производительности между Express и предположительно более быстрым альтернативным фреймворком действительно существует, но она незначительна по сравнению с потерями, вызванными отсутствием индексов в таблицах или использованием запросов типа N+1. Выбор фреймворка может иметь значение по многим другим причинам; чистая скорость обычно не является одной из них.

Сначала пытаться реализовать кластеризацию, прежде чем заниматься всем остальным. Запуск нескольких процессов Node для использования дополнительных ядер CPU действительно помогает в задачах, ограниченных вычислительными мощностями. Однако это бесполезно для конечных точек, работающих медленно из-за ожидания незиндексированного запроса, поскольку ожидание остается ожиданием независимо от того, сколько процессов простаивает в ожидании.

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

Как действительно использовать это

Сначала устраните все проблемы уровня 1, поскольку они недороги, имеют низкий риск и покрывают большинство проблем производительности в реальных условиях. Переходите к уровню 2 только в тех конкретных случаях, когда анализ показывает, что решений уровня 1 недостаточно, а не для того, чтобы применять их повсеместно. Не трогайте уровень 3, пока у вас не будет конкретных доказательств от инструмента анализа, а не просто догадок, что именно один из этих методов является настоящим узким местом. Большинство API, которые кажутся медленными, работают так из-за нерешенных проблем уровня 1, а не из-за отсутствия какой-то редкой оптимизации, взятой из поста в блоге.

Связанные материалы

  • Edge Isolates и Wasm против Node.js: компромиссы в работе среды выполнения и практики использования в производстве — Рассматривается, как механизмы изоляции V8 и технология WebAssembly превосходят Node.js, основанный на контейнерах, в средах работы на краю сети, а затем описываются практики управления, делающие Node.js готовым к использованию в производстве.
  • Как функция process.nextTick() тайно блокирует цикл событий Node.js — Объясняется, почему рекурсивные вызовы process.nextTick() полностью блокируют фазу опроса libuv, и как исправить ситуацию с блокировкой цикла событий с помощью функции setImmediate().
  • 20 шаблонов Node.js, предотвращающих простои производственных серверов — Узнайте о 20 практических шаблонах Node.js — от обработки ошибок до плавного выключения и пуллинга соединений — которые предотвращают сбои до того, как станет необходимо перезагрузка.