javascript

Многопоточность в NextJS: как запустить и нужно ли?

  • воскресенье, 6 октября 2024 г. в 00:00:03
https://habr.com/ru/companies/productradar/articles/848052/

На определённом этапе своей карьеры я задался вопросом: может ли Next.js работать в многопоточном режиме? Оказалось, что нет. Это побудило меня разобраться, как можно организовать многопоточную работу Next.js и насколько это оправдано для сайтов с высокой нагрузкой.

Содержание

Какую страницу будем нагружать?

Для тестирования я использую страницу своего проекта по мониторингу падений сайтов с уведомлениями в Telegram. Основная страница сайта генерируется в статичную страницу (SSR), имеет картинки визуализации работы, на ней нет JS-интерактива. Одна из ключевых задач этой страницы — корректный рендеринг для SEO-оптимизации.

В статичном виде вес страницы составляет 71,2 КБ, а в полностью отрендеренном виде — 4,2 МБ.

Выглядит страница вот так:

Код страницы включает метаданные, Open Graph и HTML, написанный с использованием JSX (внутри используются такие же React-компоненты без интерактива, структурированные по секциям):

import { Metadata } from "next";
import HeaderComponent from "../pages-components/header/HeaderComponent";
import MainComponent from "../pages-components/main/MainComponent";
import FeaturesComponent from "../pages-components/features/FeaturesComponent";
import FooterComponent from "../pages-components/footer/FooterComponent";
import AdvantagesComponent from "../pages-components/advantages/AdvantagesComponent";
import Price from "../pages-components/price/Price";
import HowItWorksComponent from "../pages-components/how-it-works/HowItWorksComponent";
import { MessageUsComponent } from "@/util/components/MessageUsComponent";

export const metadata: Metadata = {
  title: "Мониторинг сайтов | Проверятор",
  description:
    "Бесплатный мониторинг доступности сайтов 24\\7. Уведомим о сбоях в работе сайта в Telegram, по почте или SMS. Проверка сайта раз в минуту (включая Nginx, WordPress и другие сайты)",
  keywords: "мониторинг сайтов, проверка доступности, проверка сбоев сайта",
  icons: {
    icon: "/favicon.ico",
  },
  alternates: {
    canonical: `https://proverator.ru`,
  },
  openGraph: {
    title: "Мониторинг сайтов | Проверятор",
    description:
      "Бесплатный мониторинг доступности сайтов 24\\7. Уведомим о сбоях в работе сайта в Telegram, по почте или SMS. Проверка сайта раз в минуту (включая Nginx, WordPress и другие сайты)",
    url: "https://proverator.ru",
    type: "website",
    images: ["https://proverator.ru/banner.png"],
  },
};

export default function Home() {
  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{
          __html: JSON.stringify({
            "@context": "https://schema.org/",
            "@type": "WebPage",
            name: "Мониторинг сайтов | Проверятор",
            description:
              "Бесплатный мониторинг доступности сайтов 24\\7. Уведомим о сбоях в работе сайта в Telegram, по почте или SMS. Проверка сайта раз в минуту (включая Nginx, WordPress и другие сайты)",
            url: "https://proverator.ru",
          }),
        }}
      />

      <HeaderComponent />

      <div
        style={{
          width: "100%",
          maxWidth: "100vw",
          overflowX: "hidden",
          position: "relative",
        }}
      >
        <main>
          <MainComponent />
        </main>

        <HowItWorksComponent />
        <FeaturesComponent />
        <AdvantagesComponent />
        <Price />
        <FooterComponent />

        <MessageUsComponent />
      </div>
    </>
  );
}

Скрипт для стресс-теста

Скрипт для стресс-тестирования я написал на Python с помощью ChatGPT o1. Запросы к сайту выполняются в 10 параллельных процессах (а не потоках, так как GIL не поддерживает "полноценную" многопоточность).

В течение минуты мы отправляем асинхронные запросы, а затем подсчитываем количество успешных ответов:

import asyncio
import aiohttp
import time
import multiprocessing

async def fetch(session, url, success_counter, end_time):
    while time.time() < end_time:
        try:
            async with session.get(url) as response:
                if response.status == 200:
                    with success_counter.get_lock():
                        success_counter.value += 1
        except Exception:
            pass  # Ignore exceptions to continue the stress test

async def runner(url, success_counter, end_time):
    async with aiohttp.ClientSession() as session:
        await fetch(session, url, success_counter, end_time)

def process_function(url, success_counter, end_time):
    asyncio.run(runner(url, success_counter, end_time))

def main(url):
    success_counter = multiprocessing.Value('i', 0)
    end_time = time.time() + 60  # Run for 1 minute
    processes = []

    for _ in range(10):  # Up to 10 processes
        p = multiprocessing.Process(target=process_function, args=(url, success_counter, end_time))
        p.start()
        processes.append(p)

    for p in processes:
        p.join()

    print(f"Number of successful requests: {success_counter.value}")

if __name__ == "__main__":
    import sys
    
    if len(sys.argv) != 2:
        print("Usage: python stress_test.py <URL>")
        sys.exit(1)
        
    url = sys.argv[1]
    main(url)

Конфигурация компьютера

Стресс-тест я запускаю на своём рабочем компьютере с процессором AMD Ryzen 9 7950X (16 ядер, 32 потока), 64 ГБ оперативной памяти и NVMe-диском. Операционная система — Windows 11.

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

Стресс-тест в однопотоке

Собираю и запускаю сайт:

> npm run build
> npm run start

Запускаю тест в три итерации:

> python .\stresstest.py http://localhost:3000
> Number of successful requests: 94482

> python .\stresstest.py http://localhost:3000
> Number of successful requests: 92523

> python .\stresstest.py http://localhost:3000
> Number of successful requests: 93764

Загрузка компьютера во время теста (в среднем ~24% CPU, в простое ~2-7%):

Как запустить многопоточность?

Запуск многопоточного режима в Next.js оказался нетривиальной задачей. Я пробовал разные варианты с PM2, но безуспешно. После нескольких часов изучения нашёл статью на dev.to для старой версии Next.js 12.

Конечно, с первой попытки ничего не заработало, но после некоторых манипуляций удалось нащупать рабочий скрипт:

// start-multicore.js

const cluster = require("node:cluster");
const process = require("node:process");

const CPU_COUNT = require("os").cpus().length;

if (cluster.isPrimary) {
  cluster.setupPrimary({
    exec: require.resolve("next/dist/bin/next"),
    args: ["start", ...process.argv.slice(2), "-p", "3000"],
    stdio: "inherit",
    shell: true,
  });

  for (let i = 0; i < CPU_COUNT; i++) {
    cluster.fork();
  }

  cluster.on("exit", (worker, code, signal) => {
    console.log(`worker ${worker.process.pid} died`, { code, signal });
  });
}

Далее, запускаю NextJS на все ядра компьютера (получается 32 потока):

> node .\start-multicore.js

P.S. Тут учитываем, что и скрипт теста, и фоновые задачи будут всё на тех же физических ядрах.

Стресс-тест в многопотоке

Собираю и запускаю сайт:

> npm run build
> node .\start-multicore.js

Запускаю тест в многопотоке в три итерации:

> python .\stresstest.py http://localhost:3000
> Number of successful requests: 231428

> python .\stresstest.py http://localhost:3000
> Number of successful requests: 235704

> python .\stresstest.py http://localhost:3000
> Number of successful requests: 239138

Загрузка CPU во время теста (~44%):

Результаты

Результаты теста показали, что многопоточная версия обрабатывает примерно в 2.5 раза больше запросов, чем однопоточная. График для наглядности:

Однако стоит учесть несколько факторов:

  • что в фоне запущены другие программы;

  • во время теста скрипт и сайт работают на одном и том же компьютере;

  • несмотря на рост мощности в 32 раза (по количеству ядер), число обработанных запросов увеличилось всего в 2.5 раза.

Заключение

Стоит ли использовать многопоточность для Next.js? Вопрос неоднозначный. Я считаю, что это всё же не лучший подход.

В синтетическом тесте удалось увеличить количество обрабатываемых страниц в локальной сети. Но в реальных условиях серверы обычно менее мощные, и их пропускная способность варьируется от 100 Мбит/с до 1 Гбит/с.

Максимум, которого я достиг — около 250 000 запросов в минуту для статичной страницы без рендеринга. При этом в отрендеренном виде сайт весит 4.2 МБ.

Предположим, сайт идеально оптимизирован: вся статика вынесена в CDN, а сервер отдаёт 1 МБ данных на запрос. Даже допустим, что половина пользователей уже имеет закэшированную версию сайта, и фактически с сервера передаётся только 0.5 МБ на каждый запрос.

При пропускной способности 1 Гбит/с (125 МБ/с) сервер сможет обработать максимум 15 000 запросов в минуту (в сферически идеальных условиях). Мы всё ещё не приближаемся к пределам однопоточного Next.js. Да и большой вопрос, насколько многопоточный режим будет хорошо работать в связке с другими процессами, Nginx'ом и т.д.

Поэтому, если сайт растёт, разумнее начинать горизонтально масштабироваться. Всё-таки мы не хотим единую точку отказа, а хотим много серверов с load balancing'ом. В этой ситуации однопоточной версии Next.js будет достаточно. Так будет и надёжнее, и более рентабельно используем ресурсы.

Тем более, использование нестандартных скриптов - усложняет поддержку. С новой версией NextJS такой способ "распараллеливания работы" может перестать работать. Лучше отдать задачу оптимизации NextJS команде разработки NextJS.

Надеюсь, мой эксперимент оказался для вас наглядным.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Задавались ли вопросом, как распараллелить NextJS?
0% Да, но не пытался параллелить0
0% Да, и даже внедрил в работу0
66.67% Нет2
33.33% Оптимизирую сайт по-другому1
Проголосовали 3 пользователя. Воздержались 2 пользователя.