50MB Node.js бандл и потоки: читаем гигабайты логов без OOM
-
Когда Node.js приложение начинает жрать память при обработке больших файлов логов, виноват обычно не сам язык, а неправильная стратегия чтения данных. Большинство новичков загружают весь файл в оперативку через
fs.readFile(), а потом удивляются, почему приложение падает с Out-of-Memory на 100MB файле, хотя процесс стартует с 50MB бандла.В этой статье разберёмся, как правильно работать с потоками, почему они спасают память и какие грабли подстерегают даже опытных разработчиков. Не будет никаких поверхностных советов — только под капот, только практика.
Почему памяти всегда не хватает?
Напомню: Node.js работает на одном потоке в цикле событий (event loop). Когда ты вызываешь
fs.readFile(), весь файл читается в буфер целиком, и этот буфер живёт в V8 heap до тех пор, пока ты его не отпустишь. Если файл больше свободной памяти — привет, OOM.Дело усугубляется, если приложение уже занимает 50MB бандлом (модули, парсеры, все эти дело). Остаётся жалкие 100-150MB, если контейнер выделил 256MB памяти по умолчанию. Один большой файл — и система просит кредит у своп-памяти, затем вообще падает.
Потоки (streams) решают эту проблему элегантно: вместо загрузки всего файла сразу, ты читаешь его чанками. Каждый кусок обрабатывается и выбрасывается, освобождая память. Это не оптимизация — это правильная архитектура для работы с большими данными.
Streams: как они работают под капотом
Поток в Node.js — это по сути конвейер. На одном конце входные данные (readable), на другом — результат (writable), посредине — трансформация (transform). Система автоматически регулирует скорость чтения в зависимости от скорости обработки, чтобы буфер не переполнялся.
Когда ты создаёшь
fs.createReadStream(), Node.js не читает весь файл в память. Вместо этого он берёт кусок (по умолчанию 64KB), эмитит событиеdata, ждёт, пока ты обработаешь этот кусок, потом берёт следующий. Если обработка медленная, поток паузирует и ждёт. Это называется backpressure — естественное регулирование потока данных.Вот классический пример, который часто делают неправильно:
// Плохо: весь файл в памяти const data = fs.readFileSync('huge.log'); const lines = data.toString().split('\n'); lines.forEach(line => processLine(line)); // Хорошо: потоки и трансформация const readline = require('readline'); const fs = require('fs'); const stream = fs.createReadStream('huge.log'); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); rl.on('line', (line) => { processLine(line); });Второй вариант обработает файл в сотни раз большего размера на тех же 50MB памяти, потому что в каждый момент времени в буфере живёт только несколько строк.
Реальная задача: парсим логи и считаем ошибки
Представь: нужно прочитать логи размером в 5GB и найти все строки с ошибками, сгруппировать их по типам. Если просто загрузить в память — мертвец. Потоки же позволяют обработать это за несколько минут, не упав.
Вот как строится такое решение: чтение потоком, парсинг каждой строки, фильтрация и накопление статистики. Вся работа идёт параллельно в рамках одного потока цикла событий (помнишь, это не системные потоки, а асинхронные операции).
Примерная архитектура:
const fs = require('fs'); const { Transform } = require('stream'); const readline = require('readline'); const errors = {}; const stream = fs.createReadStream('app.log'); const logParser = new Transform({ objectMode: true, transform(chunk, encoding, callback) { try { const line = chunk.toString(); const parsed = JSON.parse(line); if (parsed.level === 'ERROR') { this.push(parsed); } } catch (e) { // Пропускаем невалидные строки } callback(); } }); const aggregator = new Transform({ objectMode: true, transform(errorObj, encoding, callback) { const key = errorObj.code || 'unknown'; errors[key] = (errors[key] || 0) + 1; callback(); } }); stream .pipe(logParser) .pipe(aggregator) .on('finish', () => { console.log('Обработано, результаты:', errors); });Видишь? Два Transform потока цепляются через
pipe(), и данные текут от одного к другому. Если парсер перегружен, чтение замедляется. Если агрегатор не справляется, парсер ждёт. Система сама балансирует нагрузку.Частые ошибки при работе с потоками
Даже когда понимаешь теорию, можно наступить на грабли. Вот типичные проблемы:
Накопление данных вместо обработки. Многие пишут код, который слушает
dataи складывает всё в массив:const chunks = []; readable.on('data', chunk => { chunks.push(chunk); // Это же OOM, только медленнее! });Это то же самое, что загрузить файл целиком. Нужно обрабатывать данные сразу, когда они приходят.
Забывчивость про backpressure. Если не слушать сигнал
drain, поток может переполниться:// Плохо: игнорируем backpressure readable.on('data', chunk => { writable.write(chunk); // Может выбросить всё в памяти }); // Хорошо: учитываем сигнал readable.on('data', chunk => { const flushed = writable.write(chunk); if (!flushed) { readable.pause(); } }); writable.on('drain', () => { readable.resume(); });К счастью,
pipe()делает это автоматически, поэтому используй его, где возможно.Утечки памяти при обработке ошибок. Если поток упадёт с ошибкой и не закроется, буферы останутся в памяти:
stream .pipe(transform) .pipe(writable) .on('error', (err) => { console.error('Ошибка:', err); stream.destroy(); // ОБЯЗАТЕЛЬНО закрой поток! // иначе ждёшь утечку памяти });Мешанина с объектными потоками. Когда используешь
objectMode: true, нельзя просто так пайпить в обычный writable. Нужна промежуточная сериализация.Практические трюки для массивных логов
Есть несколько техник, которые облегчают жизнь конкретно при работе с логами:
-
Разбиение по размеру. Не обрабатывай один 100GB файл. Разбей на части по 100MB, обрабатывай параллельно (но не более 4-5 потоков, чтобы не упал бекенд). Merge результаты в конце.
-
Прессование данных. Если логи уже гжипованы, Node.js умеет распаковывать их прямо в потоке через
zlib.createGunzip(). Это даже экономнее, чем распаковывать файл на диск.
const zlib = require('zlib'); fs.createReadStream('logs.gz') .pipe(zlib.createGunzip()) .pipe(logParser) .pipe(aggregator);-
Worker threads для тяжёлого парсинга. Если парсинг логов требует вычислений (regex, шифрование, что-то сложное), распредели это на worker threads. Main thread будет читать и писать, workers — обрабатывать.
-
Прогрессивная запись результатов. Не жди, пока обработаются все логи, чтобы написать результат. Пиши постепенно в базу или файл. Это разгружает память.
Метод Скорость Память Сложность Для логов readFile Быстро Ужас Просто Нет Streams + pipe Нормально Отлично Простой Да Worker threads Быстро Хорошо Средне Да Разбиение по частям Зависит Хорошо Сложно Да Когда streams — оverkill?
Потоки — мощный инструмент, но не всегда нужны. Если файл 1MB и памяти в системе 2GB, можешь просто читать целиком. Не усложняй код без причины.
А вот когда потоки действительно спасают жизнь: обработка логов на боевом сервере, экспорт больших таблиц из БД, трансформация видео, генерация PDF из огромных шаблонов. Везде, где данные больше, чем разумно держать в памяти.
Помни также: потоки — это не только про экономию памяти. Это про асинхронность и неблокирующую обработку. Читаешь с диска не блокируя весь процесс, обрабатываешь параллельно, пишешь результат. Идеально для микросервисов, где каждая миллисекунда задержки — потеря денег.
Отладка и профилирование потоков
Когда память всё равно растёт, нужна диагностика. Node.js встроенный инспектор поможет: запусти приложение с флагом
--inspectи подключайся из DevTools.Обрати внимание на heap snapshots: сравни снимки памяти в начале обработки и в конце. Если осталась куча объектов, которых не должно быть, есть утечка. Обычно виноваты event listeners, которые не удалили после завершения потока.
Есть и простой способ: используй
console.log(process.memoryUsage())в нескольких местах обработки. Если свободная память только снижается, значит что-то накапливается.const used = process.memoryUsage(); console.log(`Heap used: ${Math.round(used.heapUsed / 1024 / 1024)}MB`); console.log(`External: ${Math.round(used.external / 1024 / 1024)}MB`);Вот так потоки решают проблему целиком
Популярный миф: чтобы работать с большими данными в Node.js, нужны worker threads или Go. На самом деле обычные потоки справляются с гигабайтами данных, если используешь их правильно. 50MB бандл приложения и 5GB лог-файл — не конкурирующие ресурсы, потому что на диск/сеть идёт асинхронная операция, не блокирующая цикл событий.
Но есть нюансы: потоки — это не серебряная пуля. Если обработка каждой строки требует вычислений, это всё равно замораживает event loop на микросекунды. Для действительно heavy lifting нужны worker threads. Но для чтения, фильтрации и агрегации — потоки идеальны и простоваты.
-
Здравствуйте! Похоже, вас заинтересовала эта беседа, но у вас ещё нет аккаунта.
Надоело каждый раз пролистывать одни и те же посты? Зарегистрировав аккаунт, вы всегда будете возвращаться на ту же страницу, где были раньше, и сможете выбирать, получать ли уведомления о новых ответах (по электронной почте или в виде push-уведомлений). Вы также сможете сохранять закладки и ставить лайки постам, чтобы выразить свою благодарность другим участникам сообщества.
С вашими комментариями этот пост мог бы стать ещё лучше 💗
Зарегистрироваться Войти© 2024 - 2026 ExLends, Inc. Все права защищены.