Wyjaśnienie strumieni w Node.js: jak naprawić awarie spowodowane brakiem pamięci przy pracy z plikami
Dowiedz się, dlaczego ładowanie całych plików do pamięci powoduje awarie serwerów Node.js oraz jak strumienie czytelne, zapisywalne, dwukierunkowe i transformacyjne rozwiązują ten problem dzięki zasadzie backpressure.
Załóżmy serwer produkcyjny, który przestaje działać w środku zwykłego, spokojnego popołudnia.
Nie było nagłego wzrostu ruchu ani napływu jednoczesnych użytkowników. Tylko jedna osoba korzystająca z aplikacji, która kliknęła przycisk w celu eksportowania dużego raportu.
W ciągu kilku sekund proces całkowicie przestał reagować, a konsola wyświetliła znajomą wiadomość: „JavaScript heap out of memory.”
Jeśli kiedyś napotkaliście ten błąd, wiecie, jak niepokojący jest.
Pierwotną reakcją jest zakłopotanie. Jak to możliwe, że jeden plik, zażądany przez jednego użytkownika, może sparaliżować całą działającą aplikację?
Tego typu incydenty są doskonałym nauczycielem. Wskazują bezpośrednio na kluczową koncepcję Node.js, którą każdy programista backend musi ostatecznie zrozumieć: strumienie.
Największy błąd popełniany przez początkujących
Gdy programiści są początkujący w Node.js, zazwyczaj sięgają po najprostsze dostępne narzędzia.
Aby odczytać plik z dysku, najczęściej wybierane jest fs.readFile(). Jest to proste w użyciu: podaje się ścieżkę, używa się funkcji callback lub await, a cała zawartość pliku jest zwracana.
Typowa wersja tego kodu wygląda tak:
import fs from 'node:fs/promises';
async function sendFile(filePath) {
// Reading the entire file at once
const bigData = await fs.readFile(filePath);
return bigData;
}
Ten sposób działa dobrze, dopóki pliki pozostają małe. Plik tekstowy o wielkości 50 kilobajtów ładowa się natychmiast. Małe zdjęcie profilowe również nie stanowi problemu.
Ponieważ wszystko działa poprawnie podczas testów lokalnych, łatwo jest założyć, że kod jest gotowy do użycia w produkcji bez żadnych zmian.
Jednak potem pojawia się rzeczywistość.
Dlaczego odczytywanie wszystkiego naraz nie działa
Rozważ, w jaki sposób faktycznie wykorzystywana jest pamięć RAM twojego komputera. Gdy uruchamia się fs.readFile(), Node.js ładuje cały plik do pamięci, bajt po bajcie, zanim go przekaże.
Załóżmy, że twój serwer ma do dyspozycji dla aplikacji zaledwie 1 gigabajt pamięci RAM.
A teraz załóżmy, że jakiś użytkownik próbuje przesłać film lub poprosić o plik logów o wielkości 900 megabajtów.
Wezwanie fs.readFile() do tego pliku o wielkości 900 megabajtów wywołuje łańcuch reakcji:
- Node.js natychmiast żąda od systemu operacyjnego 900 megabajtów pamięci.
- Kolektor śmieci pracuje nadgodzinami, gdy dostępna pamięć się zmniejsza.
- Jeśli drugi użytkownik jednocześnie poprosi o ten sam plik, zapotrzebowanie na pamięć wzrasta do 1800 megabajtów.
- Server wyczerpuje swój budżet pamięci i całkowicie się zawiesza.
Błąd nie jest spowodowany uszkodzonym plikiem. Zdarza się to, ponieważ cała ilość danych jest pobierana jednocześnie, zamiast stopniowo.
Czym są strumienie w prostych słowach?
Odstąp na chwilę od kodu i pomyśl o analogii z rzeczywistego życia.
Załóżmy, że musisz przenieść wodę z dużego jeziora do ogrodu za twoim domem.
Nie próbowałbyś nabierać całego jeziora do jednego ogromnego wiadra i przenieść go – to po prostu zbyt duża waga, by ktokolwiek mógł ją podnieść.
Zamiast tego podłączyłbyś szlankę ogrodową.
Woda przepływa przez tę szlankę w cienkim, ciągłym strumieniu: trochę wody wpływa z jednego końca, przepływa wzdłuż rury i wypływa z drugiego końca na ziemię.
Dzięki zwykłej wąskiej szlance możesz z czasem przenieść miliony litrów wody, nigdy nie podnosząc jednocześnie całej objętości.
Strumień w Node.js funkcjonuje dokładnie tak jak ta rura.
Zamiast ściągać cały plik do pamięci za jednym razem, strumień odczytuje go w małych, łatwych do przetworzenia fragmentach zwanych chunkami.
Domyślnie jeden chunk ma zazwyczaj około 64 kilobajtów.
Node.js pobiera jeden chunk, przetwarza go, przekazuje go tam, gdzie jest potrzebny, a następnie uwalnia go z pamięci, zanim przejdzie do kolejnego chunka.
Dlatego serwer może przesyłać plik o wielkości 10 gigabajtów, zużywając przy tym jedynie około 20 do 30 megabajtów pamięci RAM.
Cztery typy strumieni w Node.js
Node.js oferuje cztery podstawowe elementy do pracy z danymi w formie strumieni. Nie musisz od razu opanować każdego szczegółu, ale warto wiedzieć, jak nazywają się poszczególne typy:
1. Strumienie do odczytu
Strumień do odczytu to taki, z którego pobieramy dane.
- Przykładami są odczyt pliku z dysku, otrzymanie treści przychodzącej żądania HTTP lub odczyt wierszy z zapytania do bazy danych.
2. Strumienie pisalne
Strumień pisalny to taki, do którego wprowadza się dane.
- Przykładami są zapisywanie treści do nowego pliku, wysyłanie odpowiedzi do przeglądarki lub zapisywanie bajtów przez gniazdo sieciowe.
3. Strumienie dwukierunkowe
Strumień dwukierunkowy umożliwia wykonywanie obu czynności jednocześnie: można z niego odczytywać i do niego pisać jednocześnie.
- Przykład: połączenie sieciowe, takie jak gniazdo TCP, gdzie dane są wysyłane i odbierane przez to samo połączenie.
4. Strumienie transformujące
Strumień transformujący to specjalistyczny strumień dwukierunkowy. Jego zadaniem jest modyfikowanie danych w trakcie ich przepływu, a nie tylko przenoszenie ich bez zmian.
- Przykład: kompresowanie pliku w formacie
.gzipw trakcie jego przesyłania lub szyfrowanie tekstu przed zapisaniem na dysku.
Zobaczenie różnicy: przykłady kodu
Porównajmy te podejścia na konkretnym przykładzie. Wyobraźmy sobie, że budujemy prosty serwer HTTP umożliwiający odwiedzającym pobieranie dużego pliku.
Zła metoda (wysokie zużycie pamięci)
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);
Jeśli plik massive-dataset.csv ma rozmiar 2 gigabajty, ten kod będzie próbował przechować całe 2 gigabajty w pamięci, zanim wyśle choćby jeden bajt do klienta. W większości rozwiązań chmurowych to spowoduje natychmiastowe zatrzymanie procesu.
Lepsza metoda (niskie zużycie pamięci)
A teraz zbudujmy tę samą funkcję pobierania przy użyciu strumieni:
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);
Zauważyli państwo wywołanie .pipe()?
To jedno wywołanie metody umożliwia osiągnięcie imponujących efektów – łączy nasz strumień odczytu pliku bezpośrednio z wyjściową odpowiedzią HTTP (res).
Gdy dysk dostarcza pierwszy mały fragment pliku (np. 64 KB), Node.js natychmiast przekazuje go do klienta. Nie ma potrzeby czekać, aż zostanie odczytany cały plik. Zużycie pamięci pozostaje niskie i stałe przez cały czas pobierania.
Rozumienie backpressure (problem korków w ruchu)
Istnieje kluczowa koncepcja w przepływowych aplikacjach, którą powinien znać każdy programista: backpressure.
Powróćmy na chwilę do analogii z wężem ogrodowym.
Załóżmy, że wpompowujemy wodę do rury z prędkością 100 litrów na sekundę, podczas gdy zawór wyjściowy pozwala uciec jedynie 10 litrów na sekundę.
Ciśnienie w rurze stale rośnie, a jeśli rura nie jest wystarczająco mocna, pęka.
Taki sam problem pojawia się ciągle w oprogramowaniu. SSD może dostarczać dane z prędkością setek megabajtów na sekundę. Tymczasem osoba pobierająca twój plik może korzystać z wolnego połączenia mobilnego.
Zatem jeśli Node.js nadal pobiera dane z dysku szybciej, niż klient jest w stanie je otrzymać, dokąd trafia ta nadmiarowa ilość danych?
Gromadzi się ona w pamięci RAM twojego serwera, czekając na wysłanie.
Jeśli tego nie kontrolować, to unieważnia cały sens używania strumieni, ponieważ zużycie pamięci znów gwałtownie rośnie.
Jak nowoczesny Node.js rozwiązuje ten problem
Na szczęście aktualne wersje Node.js zawierają wbudowane rozwiązanie właśnie na ten problem: funkcję pipeline, dostępną w module stream/promises.
Zamiast polegać na starszym podejściu .pipe(), nowoczesny kod powinien preferować 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);
Cóż sprawia, że pipeline jest lepszym wyborem niż .pipe()?
- Rеагuje na nierówne prędkości: gdy klient spowalnia odbiór danych, automatycznie wstrzymuje strumień do odczytu, dopóki klient nie będzie mógł przyjąć więcej danych.
- Grzecznie radzi sobie z błędami: jeśli ktoś zamknie przeglądarkę w trakcie pobierania,
pipelinezamyka strumień odczytu i prawidłowo zwalnia zasoby pliku, zapobiegając wyciekom pamięci.
Sytuacje z życia rzeczywistego, w których strumienie pomagają
Strumienie nie są przeznaczone wyłącznie do przesyłania ogromnych plików wideo czy dużych pobieranych plików. Pojawiają się również we wszelkiego rodzaju codziennych scenariuszach produkcyjnych:
- Przetwarzanie logów: Skanowanie obszernych logów serwera w poszukiwaniu błędów nie wymaga ładowania całego pliku do pamięci. Można je przeglądać linijka po linijce.
- Transformacje obrazów i wideo: Gdy ktoś przesyła zdjęcie o wysokiej rozdzielczości, można bezpośrednio przekierować otrzymany plik do narzędzia do zmiany rozmiaru obrazu, pomijając krok zapisywania surowego pliku na dysku najpierw.
- Eksport z bazy danych: Podczas eksportowania milionów wierszy do pliku CSV należy pobierać je małymi partiami z kursora bazy danych i przekazywać bezpośrednio do klienta w miarę ich przybywania.
- Szyfrowanie danych: Szyfrowanie poufnych informacji w trakcie zapisywania ich do chmury.
Powszechne błędy, których należy unikać
Nawet programiści znający teorię strumieni mogą napotkać kilka praktycznych trudności:
- Pomijanie obsługowników błędów: Starsze API strumieniowych nie przenoszą błędów automatycznie. Jeśli jakiś etap w Twoim łańcuchu wywoła błąd, a nic go nie obsługuje, cały proces może się zawieść. Lepiej używać
pipelinelub wyraźnie słuchać zdarzenia'error'. - Zmiana strumieni na bufory: Kuszące jest gromadzenie wszystkich zdarzeń
'data'w tablicy, a następnie łączenie ich w jedną dużą ciągłą literę lub bufor. Takie postępowanie unieważnia korzyści pamięciowe, których próbowałeś osiągnąć od początku. - Zostawianie zasobów otwartych: Jeśli operacja zawiedzie w trakcie wykonywania, upewnij się, że wszystkie otwarte deskrypcje plików zostaną prawidłowo zamknięte, a nie pozostaną otwarte.
Ostateczne uwagi
Gdy programiści są początkujący w programowaniu, mają tendencję do postrzegania danych jako czegoś stałego i kompletnego, co po prostu tam leży w oczekiwaniu na użycie – czy to cały plik, pełna tabela w bazie danych, czy gotowa odpowiedź.
Profesjonalna praca nad systemami backend wymaga porzucenia tego modelu myślowego.
Dane nie zawsze są solidnym, nieruchomym obiektem. Częściej niż nie zachowują się jak płynąca rzeka.
Nie musisz nabierać całej rzeki, aby z nią interakcjonować. Wystarczy pozwolić jej płynąć obok ciebie, kropla po kropli.
Gdy strumieniowanie stanie się częścią twojego zestawu narzędzi, duże pliki przestaną być powodem obaw. Twoja infrastruktura może działać na lżejszych, tańszych serwerach. Twoje aplikacje reagują szybciej na użytkowników. A co najważniejsze, możesz spać spokojnie, wiedząc, że nieoczekiwanie duży plik o wielkości 2 GB nie spowoduje awarii twojego serwera w środku nocy.
Powiązane artykuły
- Node.js Concurrency Explained: libuv, the Event Loop, and Thread Pool — Dowiedz się, jak Node.js wykorzystuje prymitywy systemu operacyjnego dostępne w libuv oraz zbiór wątków roboczych do obsługi asynchronicznego I/O, a także o częstych problemach związanych z zbiorem wątków i wskazówkach dotyczących ich optymalizacji.