На определённом этапе своей карьеры я задался вопросом: может ли 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.
Надеюсь, мой эксперимент оказался для вас наглядным.
evkochurov
Если бы Вы почитали документацию на использованный Вами модуль cluster, то увидели бы, что это не что иное, как простенький балансировщик нагрузки, который запускает несколько экземпляров изолированных процессов, распределяя между ними входящие HTTP-соединения. Т.е. это не "многопоточный" (подразумевающий общее адресное пространство для потоков) режим, а полшага к тому самому "горизонтальному" масштабированию, которое предлагается в конце статьи.
Далее, вообще-то говоря, приложения на nodejs являются многопоточными из коробки. Это js в одном экземпляре процесса выполняется в один поток, а системное API вызывается асинхронно и даже синхронные вызовы системного API прекрасно параллелятся, и степенью этого параллелизма можно даже управлять. И если по какой-то сильно специфической причине для вашего веб-сервера все же не хватило однопоточного js, есть Worker threads.
Тесты критиковать не буду: когда Вы учтете вышесказанное, то поймете, почему 16 ядер выглядят как 2,5, и проведете тесты совсем иначе.