РозробкаБудь-який рівень

Node.js

Node.js — це JavaScript на сервері й середовище, яке зробило звичним «один процес, тисячі з’єднань». Досягає воно цього тим, що ніколи ні на що не чекає само: ввід-вивід передано операційній системі або невеликому пулу потоків, а ваш код виконується між цим рівно в одному потоці. У цьому його сила для API — і його жорстка межа для будь-яких обчислень. На цій сторінці — модель вводу-виводу, масштабування, поділ модулів і помилки, що з’являються лише під навантаженням.

Ваш код
Один потік на процес
Ввід-вивід
Делеговано, не в потоках
Масштабування
Більше процесів

Що Node делегує і кому

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

Node тримає тисячі з’єднань в одному потоці, бо сам їх не чекає. Мережевий ввід-вивід віддано операційній системі, яка стежить за всіма сокетами одразу й повідомляє про готовність, тож з’єднання коштує пам’яті, а не потоку. Робота, під якою немає асинхронного системного виклику — файли, DNS, стиснення, гешування паролів, — іде в маленький пул, типово на чотири потоки. Ваш власний JavaScript не делеговано нікуди: він виконується в єдиному потоці циклу, тож код, що впирається в CPU, зупиняє всі з’єднання процесу одразу.

Проте не за всім стоїть неблокуючий системний виклик, і саме це людей дивує. Читання з файлової системи, DNS-запити, стиснення й гешування паролів не мають відповідника опитуванню сокетів, тож libuv виконує їх на пулі потоків — типово чотирьох. Ці потоки справжні, і їх ділять усі такі операції в процесі. Захешуйте bcrypt чотири паролі одночасно — пул заповнено; п’ятий запит, який хотів лише прочитати файл, тепер чекає за ними.

Ваш JavaScript не делеговано нікуди. Він виконується в єдиному потоці циклу, і поки він виконується, у цьому процесі не відбувається більше нічого: не розбирається інший запит, не спрацьовує колбек, не працює таймер. Функція, що двісті мілісекунд сортує масив, додає двісті мілісекунд затримки кожному запиту, який зараз у польоті, а не лише своєму. Це фундаментальна різниця між Node і багатопотоковим середовищем, і тому та сама повільна функція в Java — прикрість, а в Node — аварія.

Масштабування далі одного ядра

Один процес Node використовує для вашого коду одне ядро, тож шістнадцятиядерна машина з одним процесом використовує одну шістнадцяту того, за що ви заплатили. Виходів три, і вони не взаємозамінні: кожен пасує іншій формі задачі.

ПідхідЩо цеКоли брати
Кілька процесівЗапустити N копій за балансувальником або взяти модуль cluster, щоб ділити один порт.Майже завжди. Це типова відповідь для HTTP-сервісу без стану.
worker_threadsСправжні потоки в одному процесі, з власним циклом подій і без спільних змінних.Одна важка для CPU операція всередині сервісу, що загалом чекає на ввід-вивід.
ЧергаПокласти задачу до брокера й дати окремому процесу-споживачу її виконати.Робота, що може завершитися після відповіді: звіти, листи, медіа, імпорти.

На контейнерній платформі перший варіант зазвичай зводиться до «один процес на контейнер, а реплікуватиме оркестратор» — це простіше за модуль cluster і безкоштовно дає перезапуски й поступові розгортання. Що б ви не обрали, обмеження однакове: процеси не ділять нічого, тож кеш у пам’яті, лічильник обмеження частоти чи сесія в локальній змінній хибні тієї миті, коли з’явився другий процес. Їхнє місце — у Redis.

Модулі, залежності та стандартна бібліотека

У Node дві системи модулів, і ви зустрінете обидві. CommonJS — require і module.exports — старіша: розв’язується під час виконання й синхронна. ES-модулі — import і export — це стандарт мови, придатний до статичного аналізу й тепер повністю підтриманий. Новий код має бути на ES-модулях; тертя в тому, що файл CommonJS не може зробити require ES-модуля, тож проєкт посеред міграції впирається в незручну межу. Ставте "type": "module" у package.json, будьте послідовними в межах пакета й сприймайте змішану базу як міграцію, яку слід закінчити, а не як стан, у якому живуть.

Екосистема найбільша серед усіх середовищ, і це ріже в обидва боки. Додана залежність тягне власні залежності, і в підсумку ви маєте дерево, якого не читали й не можете перевірити, — а це ризик ланцюга постачання, і не теоретичний. Три звички тримають це в межах: комітьте lock-файл, щоб збірки були відтворюваними; запускайте npm audit у CI й вважайте критичну знахідку збоєм збірки; а перед додаванням пакета дивіться, коли він публікувався востаннє й скільки транзитивних залежностей приносить. Утиліта на чотири рядки з дев’ятьма залежностями — це зобов’язання, а стандартна бібліотека виросла достатньо, щоб ті чотири рядки часто вже були в ній.

Про TypeScript: для нового серверного Node він фактично типовий вибір, і на це є підстава. Межі, які має бекенд — тіла HTTP-запитів, рядки бази даних, вміст повідомлень, — це саме ті місця, де хибна форма спричиняє інцидент, а типи роблять форму явною на кожному виклику. Самі дані під час виконання вони не перевіряють, тож валідуйте те, що приходить ззовні, бібліотекою схем — і дайте типам описувати все після цієї точки.

Як це виявляється в реальній поставці

Збій, з яким Node-команди стикаються в проді, рідко буває падінням. Це затримка, що погіршується одразу для всіх, і немає одного повільного ендпоінта, на який показати, — підпис того, що щось займає потік циклу. Вимірювання, яке це називає, — це затримка циклу подій: поставте таймер на десять мілісекунд, подивіться, наскільки пізно він спрацював, і експортуйте різницю як метрику. Коли це число зростає, причина — ваш власний синхронний код, і жодне налаштування бази його не зрушить.

Ще дві речі мають бути в кожному сервісі до того, як він прийме трафік. Тайм-аути на кожен вихідний виклик, бо запит до сервісу, який ніколи не відповідає, тримає ваш обробник відкритим нескінченно, а клієнт, який давно здався, досі коштує вам пам’яті. І коректне завершення, підключене до SIGTERM, бо саме цей сигнал надсилає оркестратор перед зупинкою контейнера: обробіть його закриттям слухача й доопрацюванням запитів — інакше кожне розгортання тихо вбиватиме запити в польоті.

Де це вироджується

  • Робота з CPU в потоці циклу — розбір, гешування, обробка зображень — гальмує кожен паралельний запит.
  • Синхронні виклики до файлової системи в шляху запиту блокують цикл на весь час читання з диска.
  • Стан у змінній рівня модуля тихо розходиться тієї миті, коли з’явився другий процес.
  • Глобальний обробник, що ковтає неспіймані винятки й лишає процес працювати в невідомому стані.
  • Вихідні виклики без тайм-аутів: одна повільна залежність вичерпує з’єднання всього, що вище за течією.
  • Очікування незалежних викликів поспіль множить затримку без причини, яку не виправив би Promise.all.
  • Залежності, додані без перегляду дерева, яке вони приносять, — так і приходить компрометація ланцюга постачання.

Коли це застосовувати

Застосовуйте, коли

  • API та шлюзи, чия робота — викликати інші системи й формувати їхні відповіді.
  • Реальний час — WebSocket, потоки, довгі з’єднання, — де тримати багато простійних сокетів дешево.
  • Команди, які вже пишуть фронтенд на TypeScript: вони отримують спільні типи й один інструментарій з обох боків.
  • Застосунки з рендерингом JavaScript на сервері, де фреймворку середовище Node потрібне так чи інакше.

Уникайте, коли

  • Сервіси з навантаженням на CPU — кодування, аналітика, важкі перетворення, — якщо роботу не винести з циклу повністю.
  • Усе, що потребує спільного змінюваного стану в процесі: він не переживає переходу на більшу кількість процесів.
  • Довгі числові чи наукові навантаження, де і екосистема, і середовище мають хибну форму.
  • Середовища зі суворим переглядом залежностей, де транзитивне дерево npm саме собою є проблемою врядування.

Було корисно?

Поділіться з тим, хто працює над тією ж задачею.