Паспраўка пра Node.js Streams: як усунуць збоі файлавання чаргаванням вызваленыя нехапам каляркі
Дазвольце дазнаць, чаму завантажэнне цэлых файлоў у памяц прыводзіць да зупінкі сервераў Node.js, і як стрэмы з можнасцю чытання, запісу, двунаправленага абмену дадзеннямі і трансформацыі рашуюць гэту проблему за дапамою механізма backpressure.
Уявіце сервер для адміністрацыі, які перестае працаваць у середзіні звычнага, спакойнага вечара.
Не было ні падышку навантажэння, ні наплыву адночасных корыстаўцоў. Толькі адна людзь вядала прыемнік, якая нажала кантакт, каб выэкспортаць вялікі звыт.
Чэрез кальканасекунд процес абсалютна перестаў рэагаваць, а консоль вывела знайомую паведамленне: "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-кілабайтны текстовы файл завантажваецца мгновэна. Маленькая фота таксама не створяе проблем.
Паколькі пад час локальных тэстаў усё працюе правільна, бывае спакуса выдумваць, што код вже готовы да викорыстання ў працоўным серавере.
Але потым прыходзіць рэальнасць.
Чаму чытанне всего за раз не вяршыцца
Разважыце, як на самай працоўвае RAM вашага комп’ютера. Калі запускаецца fs.readFile(), Node.js завантажае весь файл у памяць, байт за байтом, перш чым вернуць яго вам.
Падазроўваючы, што ваш сервер мае выдзелана для прыемлівкая програмы толькі 1 гігабайт RAM.
Тепер падазроўваючы, што ўжо адны чалавек прабуе завантажыць відеа, альбо запрашае файл лога розмерам 900 мегабайтаў.
Выкліканне fs.readFile() для гэтага файла розмерам 900 мегабайтаў спрычынае ланцоўую рэакцію:
- Node.js негайна запрашае 900 мегабайтаў памяці у аператывной системе.
- Колектар сметанкі працюе пэўны час, калі доступная памяць зменшыцца.
- Якщо другі чалавек адночасна запрашае той самы файл, патрэба ў памяці вырастае да 1800 мегабайтаў.
- Сервер выкараняе свой бюджет памяці і проста зупініваецца.
Адзінакоўства не вызвана паспялеваным файлам. Яна выступае тады, калі весь пакет дадзеных прымушаны перавозіцца за аднам разам, у працоўны час, замест таго, каб ён перавозіўся паступова.
Што такое стрымы простымі словамі?
На момент адступіце ад коду і падумайце пра рэальны аналагію.
Падазроўваеце, вам трэба перавезці воду з большога азера ў сад за домам.
Вы не будзеце працаваць над тым, каб заскапаць усе вода з азера ў одну вялічынную вёдра і перанесці яе — гэта проста занадто вялікі вага, каб хтось мог узняць яго.
У замен вы падключыце садовую шланг.
Вода тэча через гэты шланг у вяскі, неперывны стрым: частка вады заходзіць у адны канец, праходзіць па трубе і выходзіць у другі канец на зямлю.
За дапамогою толькі вузкага шланга вы можете перавозіць мільйоны літраў з часам, ніколі не паднімаючы ўсю колькасць за аднам разам.
Стрэм у Node.js працюе адносна так сама, як і тая шлангавая лінія.
Уместо таго, каб усё файл з’яўлялася ў памяці за адны раз, стрэм чытае яго ў маленькіх, зрозумелых частках, якія называюцца чанкамі.
Па значэнню чанк зазвычай становіць або 64 кілабайты.
Node.js бере адзін чанк, обрабоцвае яго, перадае ў тое месца, куды ён трэба, а пасля звалівае яго з памяці, пераходзячы да наступнага чанка.
Самэў гэтага сервер можа перадаваць файл размерам 10 гігабайт, выкарыстоўваючы толькі ад 20 да 30 мегабайт RAM.
Чатыры типы стрэмоў у 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 продовжвае выкарыстоўвати диск для запрашэння даных быстрей, чым кліент можа іх прыjąць, куды тады прабіваюць этыя зайвыя даны?
Яны накапліваюцца ў RAM вашага сервера, чакаючы на адправку.
Якщо не прыняць заходаў, гэта паспяшае занепад усіх пераваг выкарыстоўвання стрымоў, адколі спрацоўнасць памяці зноў падымаецца.
Як сучасны 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закінчыць чытанне стрэму і належным чынам звалічыць кантролер файлу, запобiegаючы відклэкаў памяці.
Рэальныя ситуацыі, дзе стрэмы вам дапамагаюць
Стрэмы не прызначаны толькі для перасылкі вялічезных відеафайлаў чы большых завантажэнняў. Яны таямна выкорыстоўваюцца ў разных повсякдзенных сцэнарыях:
- Адрасаванне логаў: Для пашуку абэрасцей у вялікім логе сервера не трэба завантажваць усі файлы ў памяць. Можна працаваць з ягою лінія за лініяй.
- Трансфармацыі зображэнняў і відэа: Калі хтось заваносі фота высокай разыяўнасці, можна безпосередньа перадаць яго ў інструмент для зменшэння разыяўнасці зображэння, прахіднаючы крок запісу первіснага файла на диск.
- Экспорт базы дадзенаў: Пры экспорце мільйонаў рэядкаў у формат CSV трэба браць рэядкі пачынаючы з маленькіх частак з курсара базы дадзенаў і перадаваць іх безпосередньа кліенту, калі яны прыходзяць.
- Шифраванне дадзенаў: Шифраванне чутлівых дадзенаў падчас ўваходжэння іх у хмарны сховішч.
Пашытковыя памылкі, якіх трэба ужывацца
Нават разработчыкі, якія розумеюць тэорыю стрімоў, могу спаткнуцца пра калькі практычных прыемоў:
- Айскачанне працоўнікаў з каштовамі: Старэйшыя API для стрымоў не перадаюць каштовы автаматычна. Якщо які-небудзь элемент у вашай ланцуговой обработцы выклікае каштов, а ніхто яго не пераслухоўвае, весь процес можа зупніцца. Корыстуйцеся
pipelineабо явна пераслухоўваюце змест'error'. - Ператварэнне стрымоў у буферы: Існуе спакуса скарыстацца кожным зместам
'data'у массиве, а пасля з’едыніць усё ў одны вялікі строк або буфер. Такое падходжэнне скасоўвае прыбуткі з памяці, якія вы намагаліся атрымаць з самага пачатку. - Заліквідацыя неактыўных рэсурсаў: Якщо якая-небудзь операцыя зазнае невядомага на стадії выканання, пераконайцеся, што всі ачыненыя описывачы файлоў будуць належна закрытыя, а не застаюцца актыўнымі.
Заключныя меркі
Калі разработчыкі толькі пачынаюць працаваць з кодаваннем, яны частая асоціююць даны з чымось стабільным і завершаным, якое проста чакае, калі йго будуць викорыстоўваць — чы то цэлы файл, заполненыя табліца базы дадзеных, чы то готовая адпаведь.
Професійная праця над системамі бэкенду вымагае абыцца такой ментальной модэллю.
Даны не завжды ўособляюць сабою тверды, нерухомы об’ект. Частае явішчэнне — яны паводзяцца як ручай.
Ёсць не патрэба запоўняць усім ручаем, каб з яким-небудзь чынам са ўсімі яго взаімаць. Дастатнька проста прабачыць яму тэкучы праз вас, паштоўку за раз.
Калі стрімы стануць частью вашага інструментарыя, вялікія файлы больш не будуць прычыной абавантажэння. Ваша інфраструктура можа працаваць на менш моцных, дышэчых серверах. Вашы прыкладнікі стануць адказныяя для ўжывачаў. А, можа, найважнейшае — вы зможаце спакойна спаць, ведаючы, што неспадзевана вяліка загрузка у 2 ГБ не змусіць ваш сервера зупніцца посеред ночы.
Спаднія статыкі
- Паясненне канкурэнцыі ў Node.js: libuv, цикл змаганняў і пул нитак — Дазнаецеся, як Node.js выкарыстоўвае прымітывы ОС і пул рабочых нитак libuv для обработкі асінхронных операцый I/O, а таксама прыклады распашчых момантав у пуле нитак і советы ўсунення яных.