Главная / Статьи / 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 и даже максимальная задержки отличались друг от друга всего на несколько миллисекунд.

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

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

Память: единственная разница, которая сохранялась

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

  • При отсутствии нагрузки: Go — 13 МБ, Node — 49 МБ.
  • Сразу после нагрузки из 300 подключений: Go — 32 МБ, Node — 84 МБ.

Измерения повторялись три раза, каждый раз с нового процесса, при этом результаты оставались идентичными. При нагрузке объем памяти в Node превышает объем в Go примерно в 2,6 раза для функционально идентичных задач.

Объяснение в основном структурного характера. Процесс Node включает движок V8, его JIT-компилятор и кучу с механизмом сбора мусора, размер которой соответствует требованиям динамического языка, тогда как бинарник Go компилируется заранее с более легкой системой выполнения. Если вы платите за ограничения по объему памяти контейнера, разница увеличивается на каждом реплике: конечная точка, развернутая в нескольких подах, оплачивает базовые затраты Node в каждом из них.

Чего не показывает это эксперимент

Хочется интерпретировать это как «Go превосходит Node», но данные этого не подтверждают. Пропускная способность и задержка связаны между собой, а в тестах на операции GET с низкой конкуренцией победил Node. Если вашим ограничением являются запросы в секунду или задержка при обработке последних запросов на хосте с свободными ядрами, то этот тест показывает, что переписывание кода почти ничего не даст.

В нём также не говорится ничего о работе, связанной с базой данных. Большая часть времени ожидания в производственных сервисах тратится на обработку запросов, а не на сериализацию JSON, и это время одинаково в любом языке. Если ваш сервис зависит от операций ввода-вывода в Postgres, эти показатели для вас в основном неактуальны.

В конечном итоге основная асимметрия сохраняется: Go по умолчанию использует каждый ядро процессора, тогда как однопроцессный Node использует лишь одно. Часть того, что кажется преимуществом Go, на самом деле сводится к тому, что «Go автоматически параллелизует работу». Разумным первым шагом является использование собственного модуля cluster в Node, предусматривающего одного рабочего процесса на каждое ядро, что позволяет Node воспользоваться тем же оборудованием, которое Go получает бесплатно. Это не тестировалось здесь, но такой подход гораздо менее инвазивен, чем внедрение второго языка. Следует помнить, что каждый рабочий процесс в кластере — это отдельный процесс с собственной памятью, поэтому кластеризация может повысить использование CPU, но при этом увеличит общее потребление памяти, а не сократит его.

Превращение цифр в решение

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

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

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

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

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

    Связанные статьи

    • Node.js Streams Explained: Fixing Out-of-Memory File Crashes — Узнайте, почему загрузка целых файлов в память приводит к сбоям серверов Node.js, и как потоки для чтения, записи, двунаправленного обмена и преобразования решают эту проблему с помощью механизма обратного давления.
    • 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), когда необходимы рабочие потоки, и в чём заключаются отличия горутин.