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.