Головна / Статті / Node.js проти Go для JSON API: однакова продуктивність, на 2,6 рази менше пам’яті

Node.js проти Go для JSON API: однакова продуктивність, на 2,6 рази менше пам’яті

Порівняльний тест навантаження ідентичних HTTP-сервісів на Node.js та Go показує, де оптимізація коду приносить користь (використання оперативної пам’яті), а де — ні (пропускна здатність та затримка).

1859 слів

«Перепишіть це на Go» — це пропозиція, яку більшість команд Node.js рано чи пізно чує, і зазвичай вона супроводжується твердженнями про швидкість, насиченість циклу подій та нижчі витрати на хмарні послуги, але без конкретних даних. Щоб вирішити це питання, потрібно створити однаковий невеликий сервіс обома мовами, завантажити його однаково та подивитися, які відмінності залишаються після багаторазових запусків. У цій статті описується такий експеримент, що він насправді показав, які з отриманих цифр були спотвореннями, та як перетворити результати на обґрунтоване рішення щодо міграції ваших власних сервісів.

Час поставлення цього запитання не є випадковим. Перехід команди TypeScript на нативний компілятор на основі Go зробив фразу „перенести його на Go“ стандартною відповіддю на проблеми з продуктивністю (див. що насправді означає переписування TypeScript 7 на Go). Однак компілятор — це програма, обмежена можливостями CPU та працює пакетно; веб-API має зовсім іншу структуру, саме тому йому потрібні власні показники.

Навмисно нудний сервіс

Об’єктом тестування є мінімальний JSON-API, який використовує внутрішній сховище даних користувачів з 10 000 записами. Він надає два маршрути:

  • GET /users/:id знаходить користувача та повертає його або помилку 404 у форматі JSON.
  • POST /users аналізує тіло запиту у форматі JSON, перевіряє його та додає нового користувача.

Немає бази даних та фреймворку. Обидві ці рішення є навмисними: багаторазове звернення до бази даних значно впливало б на час виконання та приховувало будь-які відмінності у продуктивності, а фреймворк додав би власні накладні витрати, які не мають стосунку до мови програмування. Маршрути, правила валідації та початкові дані є ідентичними у обох реалізаціях.

Середовище та його вбудована асиметрія

Усе працювало на одному ноутбуку з процесором Apple Silicon на 16 ядер з операційною системою macOS 26.6, Node v22.15.0 та Go 1.24.4. Node виконувався як один процес без модуля cluster чи worker_threads, тому обробка запитів відбувалася за допомогою одного ядра. Модуль net/http у Go працював із значенням GOMAXPROCS за замовчуванням, що дозволяє схематору розподіляти горутини між усіма ядрами.

Пам’ятайте про цю асиметрію протягом усього статті. Це найважливіша умова в усьому порівнянні, адже генератор навантажень конкурував із обома серверами за однакові 16 ядер.

Дві реалізації

Версія для Node використовує лише вбудований модуль http. Зауважте, що маршрутизація здійснюється шляхом ручної перевірки значення req.method та функції startsWith у URL, база даних — це звичайний об’єкт Map, а кожна відповідь серіалізується за допомогою JSON.stringify та завершується явно. Частина коду для операцій POST описана у примітці; вона використовує ті самі перевірки, що й код на Go нижче.

const server = http.createServer((req, res) => {
  if (req.method === "GET" && req.url.startsWith("/users/")) {
    const id = req.url.split("/")[2];
    const user = users.get(id);
    if (!user) {
      res.writeHead(404, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ error: "not found" }));
      return;
    }
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify(user));
    return;
  }
  // POST /users: parse body, validate name + email, insert. Same checks as Go below.
  res.writeHead(404, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ error: "not found" }));
});

У версії на Go обробник записується у об’єкт ServeMux, а префікс шляху видаляється для отримання ідентифікатора. Сховище є мапою, захищеною мьютексом, що є необхідним оскільки Go обробляє запити паралельно у кількох горутинах, тоді як однопотоковий цикл подій Node ніколи не торкається Map з двох місць одночасно. Відповіді формуються за допомогою json.NewEncoder, який безпосередньо записує дані у ResponseWriter.

mux.HandleFunc("/users/", func(w http.ResponseWriter, r *http.Request) {
  id := strings.TrimPrefix(r.URL.Path, "/users/")
  user, ok := store.get(id)
  w.Header().Set("Content-Type", "application/json")
  if !ok {
    w.WriteHeader(http.StatusNotFound)
    json.NewEncoder(w).Encode(map[string]string{"error": "not found"})
    return
  }
  w.WriteHeader(http.StatusOK)
  json.NewEncoder(w).Encode(user)
})

Перевірка даних на маршруті POST є однаковою у обох мовах: name має бути непорожньою рядком, а email — містити символ @. Ось функція на Go; еквівалент у Node використовує typeof та String.prototype.includes для цих двох перевірок, тому жодна з версій не має простішого шляху кодування.

func validate(u User) string {
  if u.Name == "" {
    return "name required"
  }
  if !strings.Contains(u.Email, "@") {
    return "invalid email"
  }
  return ""
}

Як було застосовано навантаження

Навантаження надходило з autocannon у інтервалах по 15 секунд на двох рівнях одночасності: 50 з’єднань для помірного навантаження та 300 з’єднань для імітації завантаженого кінцевого пункту.

autocannon -c 50  -d 15 http://localhost:PORT/users/500
autocannon -c 300 -d 15 http://localhost:PORT/users/500

Шлях POST також тестувався аналогічним чином із JSON-тілом, оскільки декодування та перевірка вхідних даних ближчі до реальної роботи з запитами, ніж простий пошук у мапі.

Пам’ять вимірювалась окремо. Розмір постійної пам’яті кожного сервера (ps -o rss) фіксувався у стані спокою та знову відразу після запуску з 300 з’єднаннями, причому кожне вимірювання починалося з новозапущеного процесу, щоб залишки попереднього запуску не спотворили результатів. Важливо також те, що інструмент вимірювання пам’яті було вимкнено під час тестів на затримку; як покажуть наступні розділи, цей факт змінив результати.

Перш ніж дивитися на результати, серйозно ставтеся до обмежень: це один ноутбук, а не окрема система для тестування, і генератор навантажень ділить процесор із серверами. Ці цифри є специфічними для цього типу навантаження та не є загальним висновком щодо порівняння Node та Go. Коли ви проводите таке порівняння, збережіть код сервера, скрипт тестування та необроблені результати (тут — файл benchmark.json) поруч із своїми висновками, а також зафіксуйте, які цифри ви використали, а які відкинули.

Пропускна здатність: переможця немає

У жодному з тестів Go не досяг значної переваги у швидкості обробки запитів. При 50 з’єднаннях у режимі GET перевагу мав Node — приблизно на 5 000 запитів на секунду. У режимі POST при 300 з’єднаннях обидві технології залишалися на рівні кількох сотень запитів на секунду одна від одної. Відоме твердження про те, що Node „задихається під навантаженням“, не підтвердилося.

Це пояснення частково розкриває суть проблеми. Завдяки генератору навантажень та двом серверам, які ділять 16 ядер через лупбек, жоден процес не стикався з нестачею CPU, що усуває різницю в часі виконання, яку можна спостерігати на перевантаженому продакшн-хостингу. На невеликій спеціалізованій машині ця різниця, ймовірно, збільшуватиметься. Це справжня обмеженість тестування на ноутбуках, і її слід вказувати, а не приховувати.

Затримка: також однакова після усунення впливу зовнішніх факторів

При 300 з’єднаннях медіанна, p99 та навіть максимальна затримки відрізнялися лише на кілька мілісекунд.

У попередньому тестуванні було зафіксовано максимальну затримку 230 мс для Node, що є достатньо значною цифрою, щоб на її основі робити загальні висновки. Повторне тестування на спокійній машині, без конкуренції з боку інструменту збору даних про пам’ять за CPU, призвело до зникнення цього піку. Він походив від самого інструменту вимірювання, а не від проблем зі затримкою виконання коду Node.

Це урок, який стосується будь-якого тестування на спільному комп’ютері. Одне значення у найгіршому випадку є найбільш нестабільним показником, оскільки воно може виникнути через проблеми з планувальником або фоновим процесом. Перш ніж довіряти цьому страшному максимальному значенню, перезапустіть тест на комп’ютері без інших завдань, подивіться також показник p99 та перевірте, чи повторюється ситуація.

Пам’ять: єдина різниця, яка залишилась

Кількість пам’яті, яка використовувалась, була єдиним показником, який водночас був значним та повторюваним:

  • У стані спокою: Go — 13 МБ, Node — 49 МБ.
  • Відразу після тривалого навантаження 300 з’єднань: Go — 32 МБ, Node — 84 МБ.

Вимірювання було повторено тричі, кожного разу з нового процесу, і результати були ідентичними. При приблизно в 2,6 рази більшому навантаженні Node використовує більше пам’яті для функціонально ідентичних завдань.

Пояснення переважно структурне. Процес Node містить двигун V8, його компілятор JIT та купу для збирання сміття, розмір якої підібраний для динамічної мови, тоді як бінарний файл Go компілюється заздалегідь із більш легким середовищем виконання. Якщо ви оплачуєте обмеження пам’яті контейнера, різниця посилюється у кожному репліке: ендпоїнт, розгорнутий у кількох подах, оплачує базові витрати Node у кожному з них.

Що не показує цей експеримент

Хочеться вважати, що це означає „Go кращий за Node“, але дані цього не підтверджують. Пропускна здатність та затримка пов’язані між собою, а у тестах з низькою конкуренцією переміг Node. Якщо вашим обмеженням є кількість запитів на секунду або затримка у кінцевій частині потоку на хості з вільними ядрами, цей тест свідчить про те, що переписування коду майже нічого не дасть.

У ньому також нічого не сказано про роботу, пов’язану з базою даних. Більшість сервісів у продакшені витрачають більшу частину свого часу очікуванням на запити, а не на серіалізацію JSON, і цей час однаковий у будь-якій мові. Якщо ваш сервіс залежить від операцій введення/виведення у Postgres, ці показники для вас майже не мають значення.

Нарешті, основна асиметрія залишається: Go за замовчуванням використовує кожен ядро, тоді як Node з одним процесом — лише одне. Частина того, що здається перевагою Go, насправді полягає у тому, що „Go автоматично паралелізує“. Справедливим першим кроком є використання власного модуля cluster у Node з одним працівником на кожне ядро, що дає Node такий самий обсяг апаратних ресурсів, який Go отримує безкоштовно. Це не тестувалося тут, і цей підхід значно менш інвазивний, ніж впровадження другої мови. Пам’ятайте, що кожен працівник у клустері — це окремий процес із власним буфером пам’яті, тож клустеризація може покращити використання CPU, але збільшує загальний обсяг пам’яті, а не зменшує його.

Перетворення цифр на рішення

З огляду на ці результати повне переписування не дозволяє досягти необхідних стандартів. Більш цілеспрямований підхід дасть ефект: потрібно перенести лише той ендпоїнт, який сильно споживає пам’ять та працює на багатьох репліках, де скорочення кількості використаної пам’яті стає значущим фактором, а все інше залишити в Node.

Ця обережність має значення, адже перенесення коду ніколи не є безкоштовним. Інша мова означає наявність іншого набору інструментів, іншої схеми розгортання, додаткових інструкцій для інженерів, які працюють у режимі 24/7, та розподіл експертизи в команді. Ці витрати варто понести там, де обмеженням є кількість пам’яті, але не варто — там, де це не є проблемою.

Коли з’явиться наступна пропозиція щодо переходу з Node на Go, ви можете оцінити її так само:

  • Створіть обидві версії конкретного сервісу чи ендпоїнта, про які йдеться.
  • Завантажте їх при рівні одночасних запитів, який характерний для вашого реального трафіку, включаючи шлях запису даних.
  • Дайте Node справедливий шанс із кластеруванням, перш ніж приписувати перевагу Go.
  • Вимірюйте обсяг пам’яті окремо від затримки, щоб засіб вибірки не спотворював часові показники.
  • Перезапустіть кожен несподіваний показник на безшумному комп’ютері, перш ніж приймати рішення щодо нього.
  • Основні висновки

    • Для простого JSON API в оперативній пам’яті Node та Go на цьому обладнанні досягли практично однакової продуктивності та затримки.
    • Найочевиднішою та повторюваною перевагою Go було приблизно у 2,6 рази менше використаної пам’яті під навантаженням, що має велике значення для сервісів, які широко реплікуються.
    • Стандартне багатопроцесорне планування в Go на відміну від однопроцесорного підходу Node є фактором, що ускладнює порівняння; спробуйте кластерування перед переписуванням коду.
    • Артефакти тестів, особливо поодинокі піки максимальної затримки, є поширеними на спільних комп’ютерах; перевірте їх повторно, перш ніж робити висновки.
    • Мігруйте вибірково там, де виміряна користь є реальною та перевищує витрати на другу мову.

    Пов’язана література

    • Node.js Streams Explained: Fixing Out-of-Memory File Crashes — Дізнайтеся, чому завантаження всіх файлів у пам’ять спричиняє збої серверів Node.js, та як потоки для читання, запису, двостороннього зв’язку та перетворень вирішують цю проблему за допомогою механізму backpressure.
    • One Weather CLI, Five Toolchains: Rust, Go, Zig, Bun and Node.js — Як виглядає один і той самий невеликий CLI на основі HTTP-plus-JSON у Rust, Go, Zig, Bun та Node.js, та що означають розмір бінару, час компіляції та складність налаштування для вибору інструменту.
    • Чекання проти обробки даних: конкурентність та паралелізм у Node.js та Go — Примірники коду на Node.js та Go, які демонструють різницю між конкурентністю та паралелізмом, показують, коли достатньо обіцянок (promises), коли потрібні робочі потоки, та як відрізняються горутини.