Node.js протык Go для JSON API: аднаковая працэздатнасць, на 2,6 раза меней памяці
Паралельныя тэсты на навантажэнне ідэентычных служб HTTP на базе Node.js і Go паказваюць, дзе перапісва прыносіць парадоксальныя выгоды (выкарыстоўванне памяці), а дзе — ні (пропускная здатнасць і затрымка).
«Перапісацыя на 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 з’язнаў, пры чым кожны вимер заканчваўся пасля запуску новага процэсу, каб залишкі з пярэднега запуску не спакалілі рызык. Як важна, падчас тэстаў з вимерамі латэнсу прыстрой для вимеры памяці не быў увёрнуты; як пакажуць наступныя раздзелы, гэты факт змеў рэзультаты.
Перш чым аналізаваць рэзультаты, трэба серйозна падыходзіць да аспекта масштабавання: гэта просто адна ноутбуковая комп’ютерная система, а не аўтанонімны прыстрой для тэставах, і генератор навантажэння дзеліць CPU з серверамі. Цыфры є спецыфічныя для гэтага типу навантажэння, а не загальным вывадам пра перавагу Node адносна Go. Калі вы адмахваеце такія порэвананні, зберагачыце код сервера, скрыпт для тэставах і первісныы выход (тут — файл benchmark.json) разам з вашымі вывадамі, а таксама задапісваце, якія цыфры былі вжываны, а якія — адхілены.
Пропускная здатнасць: няма пераможца
У жаднай з працоў не было значныя перавагы Go ў часткі колькасці запитоў. Пры 50 з’яносах на маршруце GET перамогу здобыў Node — прыблізна на 5 000 запитоў за секунду. На маршруце POST і пры 300 з’яносах аба раштрымы заставаліся на аднаковай адстані — каля калькі сотых запитоў за секунду. Звычны тверджэнне, што Node „задушваецца пад навантажэнням“, не падтвердзілася.
Уявленне прыладзей адказвае часткай гэтага. Калі генератор навантажэння і оба серверы дзеўятаюць 16 ядрамі через loopback, жадны процэс не страждае ад нехапакі CPU, што зменшае разлікі у часе выконання, якія можна пазначыць на перапоўненай працоўнай машыне. На маленькай спецыяльной інстанцыі гэты разлік, верагодна, будзе растаць. Гэта справжняя меры працы лептапаў і яе трэба адзначыць, а не хаваць.
Затрымка: таксама равна, калі былі адключаны паслядствы
Пры 300 з’яўленнях медыана, p99 і нават максимальная затрымкі былі на адныя калькі мілісекундаў адзіна ад другога.
У пакульнай праце была зафіксавана максимальная затрымка 230 мс для Node, чыяе значэнне было настолькі значным, што дазволіла зробіць цэлы вывод. Перапрацаванне на тыхамнай машыне, без канкурэнціі з боку інструмента для абліковання памяці за CPU, прывела да зникнення гэтага падышча. Гэта было вызвана самымі інструментамі змерэння, а не проблемай з пазнейшай затрымкай Node.
Это урок, які пасуе для будзь-яга тэставання на спакульнай машыне. Адзіны показначык у найгоршым разе — гэта самы нестабільны показначык, які можна зафіксаваць, адколі ў яго прычыну можа стаць будзь-яка памылка кэшувальніка або фонавы процес. Перш чым падзецца на страшны максімальны показначык, запусціце тэст знову на машыне без іншых заданняў, паглядзіце на показначык p99 і пераканайцеся, чы результаты збігаюцца.
Памяць: едыная разліка, якая засталася
Розмер памяці, якая выкорыстоўваецца, быў ўжо едынай разлікой, якая была адночасна великай і паўтароўная:
- Калі машына без заданняў: Go — 13 MB, Node — 49 MB.
- Сразу пасля трывалага навантажэння 300 з’ѐеднаннямі: Go — 32 MB, Node — 84 MB.
Замеры былі паўтараны тры разы, кожны раз з новага процесу, і результаты былі ідэнтычныя. Пад такім навантажэнням у Node памяць выкорыстоўваецца пра 2,6 раза больш для функцыянальна ідэнтычных заданняў.
Адказчына большасцю ў структурнам сэнсе. Процес Node несе ў сабе двыжак V8, його JIT-кампіляр і купу для збору сметлі, паскладзеную для дынамічнага языка, тады калі бінарны файл Go компілюецца заздалегідь з менш важкім рантаймам. Якщо вы плаціце за ліміты памяці контейнера, разлік удвойнаўся ў кожным репліке: канцэнтры, размешчаныя ў багацькох падах, плаціце базавую суму для Node ў кожным з іх.
Што не паказвае гэты эксперымент
Хочацца прачытаць гэта як «Go перамагае Node», але дадзеныя гэтага не падтрымляюць. Пропускна здатнасць і затрымка связаны, а Node выграў у тестах з нізкай канкурэнцыяй для запитаў типу GET. Якщо ваша абмежэння — запыты на секунду чы затрымка ў канцы лініі на хосте з вільнымі ядрамі, гэты тэст паказвае, што перапісва коду майже нічога не дае.
У яго таксама няма нічога працы, зв’язанай з базой дадзеных. Большая частка часу адпрацоўкі на сервісах у прымэтнай сфере прабывае на чаканне запытаў, а не на серіялізацыі JSON, і гэты час аднаковы як у однай, так і ў другой мове. Якщо ваш сервіс залежыць ад I/O у Postgres, гэтыя паказнікі для вас пераважна немаюць значэння.
У падзеце застаёцца асамбалентна разліка: Go па змове выкорыстоўвае кожны ядро, а Node з аднам процэсам — толькі адна. Частка таго, што выглядае як прынтэльсцтва Go, на самай працо ёст тое, што „Go автаматычна паралелізуе“. Справядлівы першы крок — это сэрбскі модуль cluster у самога Node, які стварае аднаго працоўніка на кожна ядро, чым Node отримвае той жа апаратны ресурс, які Go выкарыстоўвае без дапамогі дадатковых налашчэнняў. Гэта не было працавана тут, і гэта значна менш агрэсыўна, чым введэнне другага языка. Памятайце, што кожны працоўнік у кластэре ёст адным окремым процэсам з сае власной памяцю, таму кластэрызацыя можа паспрабаваць падняць выкарыстоўванне CPU, але ў той жытак збільшае загальную колькасць памяці, а не зменшае яе.
Ператварэнне цифраў у рашэнне
З урачунакам гэтых рынакоў, цэлы перапіс не падлягае прыняццю, калі толькі гэта. Болей цялеспрямаваны падход будзе эфектываў: перанесці толькі той элемент, який сасвоюе многа памяці і працуе на большай колькасці реплікаў, дзе падвоўканне колькасці выкарыстоўванай памяці стане значным фактарам, а ўсё інша застаць у Node.
Такі стрымлівасць мае значэнне, таму што перанесенне коду ніколі не ўздарожваецца безкоштовна. Новы язык прыносіць новую суэтку інструментаў, новы процес развяртання, дадатковыя інструкцыі для інжынераў, якія працуюць у режыме нарадчасці, а таксама разлік у экспертызе калектыва. Гэтыя выкарыстанні маюць сенс там, дзе памяць ёст канстантны ліміт, але не маюць сенсу там, дзе гэта не так.
Калі прыйдзе наступны практэктык перанесення з 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, калі патрэбны рабочыя ниткі, і як разлічаюцяся горутыны.