Головна / Статті / Пояснення потоків Node.js: як усунути збої через нестачу пам’яті при роботі з файлами.

Пояснення потоків Node.js: як усунути збої через нестачу пам’яті при роботі з файлами.

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

1993 слів

стріми.

Коли розробники тільки починають працювати з Node.js, вони зазвичай обирають найпростіші доступні інструменти.

Для читання файлу з диска найпоширенішим вибором є fs.readFile(). Він простий у використанні: ви передаєте шлях до файлу, використовуєте калебек або await, і ви отримуєте всій зміст файлу.

Типова версія цього коду виглядає так:

import fs from 'node:fs/promises';
async function sendFile(filePath) {
  // Reading the entire file at once
  const bigData = await fs.readFile(filePath);
  return bigData;
}

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

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

Але потім настає реальність.

Чому читання всього одночасно не спрацьовує

Розгляньте, як насправді використовується оперативна пам’ять вашого пристрою. Коли виконується fs.readFile(), Node.js завантажує весь файл у пам’ять, байт за байтом, перш ніж повернути його вам.

Припустимо, що ваш сервер має виділено для додатку лише 1 гігабайт оперативної пам’яті.

Тепер уявіть, що користувач намагається завантажити відео або запитує файл журналу розміром 900 мегабайт.

Виклик fs.readFile() для цього 900-мегабайтного файлу спричиняє ланцюгову реакцію:

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

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

Що таке потоки простими словами?

На мить відійдіть від коду та подумайте про аналогію з реального життя.

Уявіть, що вам потрібно перенести воду з великого озера у сад у вашому дворі.

Ви не спробуєте набрати всю воду з озера в один величезний відро та перенести його — це просто занадто велика вага для того, щоб хтось міг її підняти.

Натомість ви під’єднаєте садовий шланг.

Вода тече через цей шланг у вигляді тонкого, безперервного потоку: трохи води надходить з одного кінця, проходить по трубі та виходить з іншого кінця на ґрунт.

За допомогою просто вузького шланга ви можете з часом перевезти мільйони літрів води, ніколи не піднімаючи всю її кількість одночасно.

Стрім у Node.js працює саме так, як ця шлангова система.

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

За замовчуванням розмір одного чанка становить приблизно 64 кілобайти.

Node.js бере один чанк, обробляє його, передає туди, куди це потрібно, а потім звільняє його з пам’яті, переходячи до наступного чанка.

Саме тому сервер може передавати файл розміром 10 гігабайт, використовуючи при цьому лише близько 20–30 мегабайт оперативної пам’яті.

Чотири типи стрімів у Node.js

Node.js надає чотири основні елементи для роботи з потоковими даними. Вам не обов’язково відразу опановувати кожну деталь, але варто знати, як називається кожен з них:

1. Читабельні стріми

Читабельний стрім — це той, з якого ви отримуєте дані.

  • Прикладами є читання файлу з диска, отримання тіла вхідного HTTP-запиту або читання рядків з результатів запиту до бази даних.

2. Потоки для запису

Потік для запису — це той, у який ви додаєте дані.

  • Прикладами є запис контенту у новий файл, надсилання відповіді браузеру або запис байтів через мережевий сокет.

3. Дуплексні потоки

Дуплексний потік дозволяє виконувати обидві операції одночасно: ви можете читати з нього та записувати в нього одночасно.

  • Приклад: мережеве з’єднання, таке як TCP-сокет, де ви надсилаєте дані та отримуєте їх назад через те саме з’єднання.

4. Потоки трансформації

Потік трансформації — це спеціалізований дуплексний потік. Його завдання — змінювати дані під час їх проходження, а не просто передавати їх без змін.

  • Приклад: стиснення файлу у форматі .gzip під час його передачі або шифрування тексту під час збереження на диск.

Порівняння: приклади коду

Давайте порівняємо ці підходи за допомогою конкретної ситуації. Уявіть, що ви створюєте простий HTTP-сервер, який дозволяє відвідувачам завантажувати великий файл.

Поганий спосіб (високе споживання пам’яті)

JavaScript

import http from 'node:http';
import fs from 'node:fs/promises';
const server = http.createServer(async (req, res) => {
  try {
    // We load the whole file into RAM first
    const fileData = await fs.readFile('./massive-dataset.csv');

    res.writeHead(200, { 'Content-Type': 'text/csv' });
    res.end(fileData);
  } catch (error) {
    res.writeHead(500);
    res.end('Something broke');
  }
});server.listen(3000);

Якщо файл massive-dataset.csv має розмір 2 гігабайти, цей код спробує зберегти всі 2 гігабайти в пам’яті ще до того, як надіслати хоча б один байт клієнту. У більшості середовищ хмарного хостингу це призведе до негайної зупинки процесу.

Кращий спосіб (низьке споживання пам’яті)

Тепер давайте створимо ту саму функцію завантаження, використовуючи потоки:

JavaScript

import http from 'node:http';
import fs from 'node:fs';
const server = http.createServer((req, res) => {
  // We create a readable stream
  const readStream = fs.createReadStream('./massive-dataset.csv');  res.writeHead(200, { 'Content-Type': 'text/csv' });  // We connect our read stream directly to the response
  readStream.pipe(res);  readStream.on('error', (err) => {
    res.writeHead(500);
    res.end('File not found or error reading');
  });
});server.listen(3000);

Чи помітили ви виклик .pipe()?

Цей один виклик методу здійснює щось дуже потужне – він під’єднує наш потік читання файлу безпосередньо до вихідної HTTP-відповіді (res).

Як тільки диск передає перший невеликий фрагмент даних (наприклад, 64 КБ), Node.js негайно передає його клієнту. Не потрібно чекати, поки буде прочитаний весь файл. Використання пам’яті залишається низьким та стабільним протягом усього часу завантаження.

Розуміння зворотного тиску (проблема затору)

Існує ключове поняття в стрімінгу, яке має зрозуміти кожен розробник: зворотний тиск.

Зверніться на мить до аналогії садового шланга.

Уявіть, що ви надходите воду у трубу зі швидкістю 100 літрів на секунду, тоді як вихідний клапан дозволяє витікати лише 10 літрів на секунду.

Тиск усе більше накопичується всередині труби, і якщо вона недостатньо міцна, вона тріскається.

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

Отже, якщо Node.js продовжує зчитувати дані з диска швидше, ніж клієнт може їх отримати, куди потрапляють ці зайві дані?

Вони накопичуються у оперативній пам’яті вашого сервера, чекаючи на надсилання.

Якщо це не контролювати, це скасовує всю сенс використання потоків, оскільки споживання пам’яті знову починає зростати.

Як сучасний Node.js вирішує цю проблему

На щастя, поточні версії Node.js містять вбудоване рішення саме для цієї проблеми: функцію pipeline, яка доступна у модулі stream/promises.

Замість того, щоб покладатися на старий підхід .pipe(), сучасний код повинен використовувати pipeline:

JavaScript

import http from 'node:http';
import fs from 'node:fs';
import { pipeline } from 'node:stream/promises';
const server = http.createServer(async (req, res) => {
  const readStream = fs.createReadStream('./massive-dataset.csv');  try {
    // pipeline handles backpressure and cleans up automatically
    await pipeline(readStream, res);
  } catch (error) {
    if (!res.headersSent) {
      res.writeHead(500);
      res.end('Transfer failed');
    }
  }
});server.listen(3000);

Що ж робить pipeline кращим вибором, ніж .pipe()?

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

Реальні ситуації, де потоки допомагають вам

Потоки призначені не лише для передачі величезних відеофайлів чи масивних завантажень. Вони тихо використовуються у різних повсякденних сценаріях:

  • Обробка журналів: Для пошуку помилок у величезному журналі сервера не потрібно завантажувати весь файл у пам’ять. Можна обробляти його рядок за рядком.
  • Трансформація зображень та відео: Коли хтось завантажує фото високої роздільної здатності, можна безпосередньо передавати отриманий файл у інструмент для зміни розміру зображень, пропускаючи крок запису первинного файлу на диск.
  • Експорт баз даних: Під час експорту мільйонів рядків у формат CSV потрібно брати їх партіями з курсора бази даних та передавати безпосередньо клієнту по мірі їх надходження.
  • Шифрування даних: Шифрування конфіденційної інформації під час її запису у хмарний сховище.

Поширені помилки, яких слід уникати

Навіть розробники, які розуміють теорію потоків, можуть стикнутися з кількома практичними труднощами:

  • Пропуск обробників помилок: Старіші API потоків не передають помилки автоматично. Якщо певний етап у вашій системі обробки викидає помилку, а ніхто її не спостерігає, весь процес може зазнати невдачі. Краще використовувати pipeline або явно слухати подію 'error'.
  • Перетворення потоків назад у буфери: Існує спокуса збирати всі події 'data' у масив, а потім об’єднувати все в один великий рядок чи буфер. Це скасовує переваги з точки зору використання пам’яті, які ви намагалися досягти спочатку.
  • Залишення ресурсів у відкритому стані: Якщо операція зазнає невдачі на певному етапі, переконайтеся, що всі відкриті описники файлів будуть належним чином закриті, а не залишаться у робочому стані.

Заключні міркування

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

Професійна робота з бекенд-системами вимагає відмови від такої уявної моделі.

Дані не завжди є нерухомим, твердим об’єктом. Частіше за все вони поводяться як річка, що тече.

Щоб взаємодіяти з ними, не потрібно брати всю річку цілком — достатньо дозволити їй течти повз вас поступово, трохи за раз.

Як тільки стріми стануть частиною вашого інструментарію, великі файли більше не будуть причиною занепокоєння. Ваша інфраструктура може функціонувати на більш ефективних та дешевших серверах. Ваші додатки стають більш реактивними щодо користувачів. І, можливо, найголовніше — ви можете спокійно спати, знаючи, що несподівано великий завантажуваний файл розміром 2 ГБ не призведе до зупинки вашого сервера посеред ночі.

Пов’язані статті

  • Node.js Concurrency Explained: libuv, the Event Loop, and Thread Pool — Дізнайтеся, як Node.js використовує примітиви операційної системи та пул робочих потоків libuv для обробки асинхронних операцій вводу-виводу, а також про поширені проблеми пулу потоків та поради щодо його налаштування.
  • Node.js Streams Beyond the Basics: Memory, Backpressure, and Real Failures — Дізнайтеся, як стріми Node.js взаємодіють із Web Streams, яку економію пам’яті можна досягти за допомогою реальних тестів, а також про помилки в продакшені, які проявляються лише під навантаженням.
  • Understanding Node.js Streams: The Problem They Actually Solve — Дізнайтеся, чому існують стріми Node.js, як внутрішньо працює механізм передачі даних, та що насправді означає поняття backpressure для ефективної обробки великих об’ємів даних.
  • Сесії проти JWT: як вибрати правильну модель автентифікації для Node.js — Дізнайтеся, у чому насправді різниця між сесіями та JWT у процесі автентифікації в Node.js, де полягають їхні обмеження, та як вибрати між ними, щоб потім не шкодувати.
  • Node.js 26: Temporal API, Map Upserts та Undici 8 пояснено — Пояснюються основні зміни в Node.js 26, спрямовані на роботу з бекендом, включаючи стабільний Temporal API, вбудовані методи Map upsert, покращення продуктивності Undici 8 та зміни, які потрібно перевірити перед оновленням.