Node.js
Node.js — це JavaScript на сервері й середовище, яке зробило звичним «один процес, тисячі з’єднань». Досягає воно цього тим, що ніколи ні на що не чекає само: ввід-вивід передано операційній системі або невеликому пулу потоків, а ваш код виконується між цим рівно в одному потоці. У цьому його сила для API — і його жорстка межа для будь-яких обчислень. На цій сторінці — модель вводу-виводу, масштабування, поділ модулів і помилки, що з’являються лише під навантаженням.
- Ваш код
- Один потік на процес
- Ввід-вивід
- Делеговано, не в потоках
- Масштабування
- Більше процесів
Що Node делегує і кому
Традиційний сервер дає кожному з’єднанню потік, і цей потік стоїть заблокований, доки відповідає база даних. Node робить навпаки: він узагалі ніколи не блокується на з’єднанні. Він просить операційну систему стежити за всіма сокетами одразу й повідомляти, які з них готові, тож десять тисяч простійних з’єднань коштують десять тисяч маленьких об’єктів, а не десять тисяч потоків. Саме тому один процес Node тримає стільки з’єднань, скільки вичерпало б сервер із потоком на запит.
Проте не за всім стоїть неблокуючий системний виклик, і саме це людей дивує. Читання з файлової системи, 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 саме собою є проблемою врядування.
Було корисно?
Поділіться з тим, хто працює над тією ж задачею.