Объяснение потоков Node.js: как избежать сбоев из-за нехватки памяти при работе с файлами
Узнайте, почему загрузка всего файла в память приводит к сбоям серверов Node.js, и как потоки с возможностями чтения, записи, двунаправленного обмена и преобразования решают эту проблему с помощью механизма обратного давления.
Представьте сервер производства, который выходит из строя посреди обычного спокойного дня.
Не было ни всплеска нагрузки, ни потока одновременно работающих пользователей. Просто один человек использовал приложение и нажал кнопку для экспорта большого отчета.
Всего за несколько секунд процесс полностью перестал реагировать, а консоль вывела знакомое сообщение: «JavaScript heap out of memory».
Если вы уже сталкивались с этой ошибкой, вы знаете, насколько она тревожная.
Естественной реакцией является недоумение. Как один файл, запрошенный одним пользователем, может свергнуть всё работающее приложение?
Подобные инциденты — отличные учителя. Они указывают прямо на ключевую концепцию Node.js, которую в конечном итоге должен понять каждый разработчик бэкенда: стримы.
Самая распространенная ошибка новичков
Когда разработчики только начинают работать с 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 гигабайта, этот код попытается сохранить всё это количество данных в памяти до того, как отправит хотя бы один байт клиенту. В большинстве схем облачного хостинга это приведёт к немедленному сбою процесса.
Лучший способ (низкое потребление памяти)
Теперь давайте реализуем ту же функцию загрузки с использованием потоков:
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: libuv, цикл событий и пул потоков — Узнайте, как Node.js использует примитивы операционной системы и пул рабочих потоков libuv для обработки асинхронных операций ввода-вывода, а также о распространенных проблемах с пулом потоков и советах по его настройке.