Buffer и потоки Stream

«Почему нельзя просто прочитать файл целиком и отдать клиенту?» — вопрос, за которым стоит вся тема потоков.

Buffer — фрагмент бинарных данных фиксированной длины, живущий вне кучи V8. Stream — абстракция для обработки данных порциями, не загружая их в память целиком.

Что проверяет интервьюер

Умеете ли вы думать о памяти. Разница между fs.readFile и fs.createReadStream — это разница между сервисом, который падает на файле в 2 ГБ, и сервисом, которому всё равно на размер. Отдельно почти всегда спрашивают про backpressure: это тот вопрос, где джун и middle расходятся окончательно.

Buffer: зачем он нужен

JavaScript-строки — это Unicode-текст, и хранить в них байты картинки нельзя без искажений. Buffer (наследник Uint8Array) держит сырые байты и умеет перекодировать их в строку и обратно с явным указанием кодировки.

const buf = Buffer.from('привет', 'utf8');

console.log(buf.length);              // 12 — в UTF-8 кириллица занимает 2 байта на символ
console.log('привет'.length);         // 6 — символов
console.log(buf.toString('base64'));  // 0L/RgNC40LLQtdGC

const safe = Buffer.alloc(4);         // заполнен нулями
const fast = Buffer.allocUnsafe(4);   // быстрее, но содержит мусор из памяти

Два момента, которые любят на интервью. Первый: buf.length — это байты, а не символы, поэтому для многобайтных символов длины расходятся. Второй: Buffer.allocUnsafe не обнуляет память, и в буфер может попасть содержимое ранее освобождённых участков — использовать его можно только если вы сразу перезаписываете весь буфер целиком.

Четыре типа потоков

  • Readable — источник: fs.createReadStream, тело входящего HTTP-запроса.
  • Writable — приёмник: fs.createWriteStream, объект ответа res.
  • Duplex — и то и другое: TCP-сокет.
  • Transform — duplex, преобразующий данные на лету: zlib.createGzip(), шифрование, парсер CSV.
const fs = require('node:fs');
const zlib = require('node:zlib');
const { pipeline } = require('node:stream/promises');

// плохо: файл целиком в памяти, 2 ГБ файл = 2 ГБ RSS и почти верный OOM
app.get('/report-bad', async (req, res) => {
  const data = await fs.promises.readFile('/data/report.csv');
  res.end(data);
});

// хорошо: постоянное потребление памяти независимо от размера файла
app.get('/report', async (req, res) => {
  res.setHeader('Content-Encoding', 'gzip');
  await pipeline(
    fs.createReadStream('/data/report.csv'),
    zlib.createGzip(),
    res,
  );
});

Второй вариант читает файл кусками (по умолчанию по 64 КБ), сжимает каждый кусок и сразу отправляет клиенту. Пик памяти не зависит от размера файла, а первый байт уходит клиенту почти мгновенно.

Backpressure — ключевой вопрос темы

Представьте: диск читает со скоростью 500 МБ/с, а клиент забирает данные по мобильной сети на 1 МБ/с. Если просто перекладывать данные, разница будет копиться в памяти процесса, пока он не упадёт. Backpressure — это механизм обратного давления, который заставляет источник притормозить.

// ЛОВУШКА: игнорируем возвращаемое значение write
readable.on('data', (chunk) => {
  writable.write(chunk);        // если вернулось false, буфер уже переполнен
});

// ПРАВИЛЬНО вручную
readable.on('data', (chunk) => {
  const ok = writable.write(chunk);
  if (!ok) {
    readable.pause();                                  // притормозили источник
    writable.once('drain', () => readable.resume());   // буфер разгрузился — продолжаем
  }
});

// ПРАВИЛЬНО на практике: pipeline делает всё сам
await pipeline(readable, writable);

writable.write() возвращает false, когда внутренний буфер (highWaterMark) переполнен. Это сигнал «хватит, подожди события drain». Именно эту логику и реализуют pipe и pipeline.

Почему pipeline, а не pipe

Старый readable.pipe(writable) обрабатывает backpressure, но не пробрасывает ошибки и не закрывает остальные потоки цепочки при сбое — типичный источник утечки файловых дескрипторов. stream.pipeline (особенно промисная версия из node:stream/promises) корректно уничтожает все потоки при ошибке и даёт нормальный await. На собеседовании ответ «использую pipeline, потому что pipe не пробрасывает ошибки» звучит очень уверенно.

Типичные ошибки кандидатов

  • Определяют поток как «способ читать файл по частям» и не могут назвать Transform или duplex-сокет.
  • Не знают про backpressure и считают, что pipe — это просто цикл копирования.
  • Используют readFile для отдачи больших файлов и не видят в этом риска.
  • Путают Buffer.alloc и Buffer.allocUnsafe, не понимая риска второго.
  • Считают, что buf.length возвращает число символов.

Как ответить кратко

Buffer — это сырые байты вне кучи V8, нужен для бинарных данных и явных кодировок; его длина считается в байтах. Stream позволяет обрабатывать данные порциями, поэтому память не зависит от объёма: бывают Readable, Writable, Duplex и Transform. Backpressure — механизм обратного давления: write() возвращает false при переполнении буфера, и источник нужно поставить на паузу до события drain. Вручную это писать не надо — достаточно stream.pipeline, который вдобавок корректно пробрасывает ошибки и закрывает потоки, чего не делает старый pipe.

Проверьте себя
1. Что такое backpressure в потоках Node.js?
AОграничение числа одновременных соединений сервера
BМеханизм, притормаживающий источник, когда приёмник не успевает обрабатывать данные
CПовторная отправка данных при ошибке записи
DСжатие данных перед записью в поток
2. Почему stream.pipeline предпочтительнее readable.pipe(writable)?
AОн работает быстрее за счёт увеличенного размера чанка
BОн корректно пробрасывает ошибки и уничтожает все потоки цепочки при сбое
CОн единственный поддерживает backpressure
DОн не требует закрывать файловые дескрипторы вообще
3. Что вернёт Buffer.from('привет', 'utf8').length?
A6
B12
C1
DЗависит от платформы