Startseite / Artikel / Erklärung zu Node.js Streams: Behebung von Speicherfehlern bei Dateien

Erklärung zu Node.js Streams: Behebung von Speicherfehlern bei Dateien

Erfahren Sie, warum das Laden ganzer Dateien in den Speicher zu Abstürzen von Node.js-Servern führt und wie lesbare, schreibbare, duplex- sowie Transformationsströme dies durch Backpressure beheben.

1993 Wörter

Stellen Sie sich einen Produktionsserver vor, der mitten an einem gewöhnlichen, ruhigen Nachmittag ausfällt.

Es gab keinen Anstieg der Nutzerzahlen und auch keine Flut von gleichzeitigen Benutzern. Nur eine einzige Person nutzte die Anwendung und klickte auf einen Button, um einen großen Bericht zu exportieren.

Bereits nach wenigen Sekunden hörte der Prozess völlig auf zu reagieren, und die Konsole zeigte eine bekannte Meldung an: „JavaScript Heap out of Memory.“

Falls Sie bereits auf diesen Fehler gestoßen sind, wissen Sie, wie beunruhigend er ist.

Die natürliche Reaktion ist Verwirrung. Wie kann eine einzige Datei, die von einem Benutzer angefordert wird, eine ganze laufende Anwendung zum Stillstand bringen?

Ein solches Ereignis ist eine hervorragende Lektion. Es weist direkt auf ein grundlegendes Konzept von Node.js hin, das jeder Backend-Entwickler irgendwann verstehen muss: Streams.

Der große Fehler, den die meisten Anfänger machen

Wenn Entwickler mit Node.js neu sind, greifen sie in der Regel auf die einfachsten verfügbaren Tools zurück.

Zum Lesen einer Datei von der Festplatte ist die gängige Wahl oft fs.readFile(). Sie ist einfach zu verwenden: Man gibt einen Pfad an, verwendet eine Callback-Funktion oder await, und der gesamte Inhalt der Datei wird zurückgegeben.

Eine typische Version sieht so aus:

import fs from 'node:fs/promises';
async function sendFile(filePath) {
  // Reading the entire file at once
  const bigData = await fs.readFile(filePath);
  return bigData;
}

Dieser Ansatz funktioniert gut, solange die Dateien klein bleiben. Eine 50-Kilobyte-Textdatei lädt sofort, und auch ein kleines Profilbild stellt kein Problem dar.

Weil bei lokalen Tests alles funktioniert, ist es verlockend anzunehmen, der Code sei bereits für die Produktion bereit.

Dann tritt die Realität ein.

Warum das Einmalige Lesen aller Daten fehlschlägt

Betrachten Sie, wie der RAM Ihres Computers tatsächlich genutzt wird. Wenn fs.readFile() ausgeführt wird, lädt Node.js die gesamte Datei byte für Byte in den Speicher, bevor sie an Sie zurückgegeben wird.

Nehmen wir an, Ihr Server hat nur 1 Gigabyte RAM für die Anwendung zugewiesen.

Nun nehmen wir an, ein Benutzer versucht, ein Video hochzuladen oder bittet um eine 900-Megabyte große Roh-Logdatei.

Die Aufrufung von fs.readFile() auf dieser 900-Megabyte-Datei löst eine Kettenreaktion aus:

  • Node.js beantragt sofort 900 Megabyte Speicher vom Betriebssystem.
  • Da der verfügbare Speicher schrumpft, arbeitet der Abfallsammler über die übliche Zeit hinaus.
  • Falls ein zweiter Benutzer gleichzeitig dieselbe Datei anfordert, steigt der Speicherbedarf auf 1800 Megabyte.
  • Der Server erschöpft sein Speichervolumen und stürzt komplett ab.

Der Fehler wird nicht durch eine beschädigte Datei verursacht. Er tritt auf, weil die gesamte Datenmenge auf einmal aufgenommen wird anstatt schrittweise verarbeitet.

Was sind Streams in einfachen Worten?

Legen Sie den Code für einen Moment beiseite und denken Sie an eine Analogie aus der Realität.

Nehmen wir an, Sie müssen Wasser von einem großen See in Ihren Garten holen.

Man würde nicht versuchen, den ganzen See in einen riesigen Eimer zu füllen und ihn damit zu transportieren – das ist einfach zu schwer für jeden Menschen, ihn zu heben.

Stattdessen würde man einen Gartenschlauch verwenden.

Das Wasser fließt durch diesen Schlauch in einem dünnen, kontinuierlichen Strahl: Ein wenig Wasser tritt am einen Ende ein, fließt entlang des Schlauchs und tritt am anderen Ende auf den Boden aus.

Mit nichts weiter als einem schmalen Schlauch können Sie im Laufe der Zeit Millionen von Litern transportieren, ohne jemals das gesamte Volumen auf einmal heben zu müssen.

Ein Stream in Node.js funktioniert genau wie diese Schlauchleitung.

Anstatt eine ganze Datei auf einmal in den Speicher zu laden, liest ein Stream sie in kleinen, verarbeitbaren Abschnitten, die als Blöcke bezeichnet werden.

Standardmäßig beträgt die Größe eines Blocks in der Regel etwa 64 Kilobyte.

Node.js holt einen Block ab, verarbeitet ihn, leitet ihn dorthin weiter, wohin er benötigt wird, und gibt ihn anschließend aus dem Speicher frei, bevor es zum nächsten Block übergeht.

Deshalb kann ein Server eine 10-Gigabyte-Datei übertragen, während er nur etwa 20 bis 30 Megabyte RAM verbraucht.

Die vier Arten von Streams in Node.js

Node.js stellt vier grundlegende Bausteine zum Arbeiten mit strömenden Daten bereit. Es ist nicht notwendig, sofort alle Details zu beherrschen, aber es lohnt sich, zu wissen, wie jeder von ihnen genannt wird:

1. Lesebare Streams

Ein lesebarer Stream ist ein Stream, von dem man Daten abruft.

  • Beispiele hierfür sind das Lesen einer Datei von der Festplatte, das Empfangen des Anhangs einer eingehenden HTTP-Anfrage oder das Zurücklesen von Zeilen aus einer Datenbankabfrage.

2. Schreibbare Ströme

Ein schreibbarer Stream ist ein Stream, in den man Daten schreibt.

  • Beispiele hierfür sind das Schreiben von Inhalten in eine neue Datei, das Senden einer Antwort an einen Browser oder das Schreiben von Bytes über ein Netzwerk-Socket.

3. Duplex-Ströme

Ein Duplex-Stream ermöglicht es, beide Aufgaben gleichzeitig auszuführen: Man kann gleichzeitig daraus lesen und darauf schreiben.

  • Beispiel: Eine Netzwerkverbindung wie ein TCP-Socket, über den man Daten sendet und gleichzeitig Daten zurück erhält.

4. Transformierende Ströme

Ein transformierender Stream ist ein spezialisierter Duplex-Stream. Seine Aufgabe besteht darin, die Daten während ihres Durchlaufs zu verändern, anstatt sie unverändert weiterzuleiten.

  • Beispiel: Komprimieren einer Datei im .gzip-Format während des Datenflusses oder Verschlüsseln von Texten auf dem Weg zum Speicher.

Der Unterschied sichtbar machen: Codebeispiele

Lassen Sie uns diese Ansätze anhand eines konkreten Szenarios vergleichen. Stellen Sie sich vor, Sie entwickeln einen einfachen HTTP-Server, der Besuchern das Herunterladen einer großen Datei ermöglicht.

Die schlechte Methode (hoher Speicherverbrauch)

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);

Falls massive-dataset.csv zufällig 2 Gigabyte groß ist, versucht dieser Code, die gesamten 2 Gigabyte im Speicher zu halten, bevor er auch nur ein einziges Byte an den Client sendet. In den meisten Cloud-Hosting-Umgebungen führt dies dazu, dass der Prozess sofort abstürzt.

Die bessere Methode (geringer Speicherverbrauch)

Bauen wir nun dieselbe Download-Funktion mithilfe von Streams auf:

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);

Beachten Sie den Aufruf von .pipe()?

Der Aufruf dieser Methode bewirkt etwas Beeindruckendes: Er verbindet unseren Datei-Lese-Stream direkt mit der ausgehenden HTTP-Antwort (res).

Sobald der Datenträger den ersten kleinen Datenblock liefert (zum Beispiel 64 KB), leitet Node.js ihn umgehend an den Client weiter. Es ist nicht notwendig, bis zum vollständigen Lesen der Datei zu warten. Der Speicherverbrauch bleibt während des gesamten Downloads gering und konstant.

Verständnis von Backpressure (Das Problem des Verkehrsstaus)

Es gibt ein zentrales Konzept im Streaming, das jeder Entwickler verstehen sollte: Backpressure.

Kehren wir kurz zur Analogie mit dem Gartenschlauch zurück.

Stellen Sie sich vor, Sie pumpen Wasser mit 100 Litern pro Sekunde in ein Rohr, während das Auslassventil nur 10 Liter pro Sekunde durchlassen lässt.

Der Druck im Rohr steigt ständig an, und wenn das Rohr nicht stark genug ist, platzt es.

Dieselbe Art von Problem tritt ständig in Software auf. Eine SSD kann Daten mit Hunderten von Megabyte pro Sekunde liefern. Gleichzeitig könnte die Person, die Ihre Datei herunterlädt, über eine langsame Mobilverbindung verfügen.

Falls Node.js also Daten schneller vom Festplattenspeicher abruft, als der Client sie empfangen kann, wohin gelangen diese überschüssigen Daten?

Sie sammeln sich im RAM Ihres Servers an und warten darauf, gesendet zu werden.

Lässt man das unkontrolliert, zunichtemacht das den ganzen Sinn der Verwendung von Streams, da der Speicherverbrauch wieder ansteigt.

Wie moderne Node.js-Versionen dieses Problem lösen

Zum Glück enthalten aktuelle Versionen von Node.js eine integrierte Lösung für genau dieses Problem: die pipeline-Funktion, die im stream/promises-Modul verfügbar ist.

Anstatt auf den älteren .pipe()-Ansatz zu setzen, sollte moderner Code pipeline bevorzugen:

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);

Was macht pipeline also zu einer besseren Wahl als .pipe()?

  • Es reagiert auf unterschiedliche Übertragungsgeschwindigkeiten: Wenn der Client mit dem Empfangen von Daten langsamer ist, pausiert es den Lesestrom automatisch, bis der Client mehr empfangen kann.
  • Es handhabt Fehler geschickt: Wenn jemand den Browser während des Downloads schließt, beendet pipeline den Lesestrom und gibt den Dateihandle ordnungsgemäß frei, wodurch Speicherverluste verhindert werden.

Reale Situationen, in denen Streams helfen

Streams sind nicht nur zum Versenden großer Video-Dateien oder riesiger Downloads vorgesehen. Sie tauchen leise in allen möglichen alltäglichen Produktionsszenarien auf:

  • Protokollverarbeitung: Das Scannen umfangreicher Serverprotokolle nach Fehlern erfordert es nicht, die gesamte Datei in den Speicher zu laden. Stattdessen kann man sie Zeile für Zeile durchlaufen.
  • Bild- und Videobearbeitung: Wenn jemand ein hochauflösendes Foto hochlädt, kann man die eingehende Datei direkt an ein Tool zur Anpassung der Bildgröße weiterleiten, ohne vorher die Rohdatei auf die Festplatte schreiben zu müssen.
  • Datenbankexporte: Beim Export von Millionen von Zeilen in ein CSV-Format sollten die Zeilen in kleinen Blöcken über einen Datenbankcursor abgerufen und direkt an den Client gesendet werden, sobald sie verfügbar sind.
  • Datenverschlüsselung: Die Verschlüsselung sensibler Informationen während des Schreibens in die Cloud-Speicherung.

Häufige Fehler, die man vermeiden sollte

Auch Entwickler, die die Theorie hinter Streams verstehen, können über einige praktische Fallstricke stolpern:

  • Auslassen von Fehlerbehandlern: Ältere Stream-APIs übertragen Fehler nicht automatisch. Wenn ein Schritt in Ihrer Pipeline einen Fehler auslöst und nichts darauf lauscht, kann der gesamte Prozess fehlschlagen. Verwenden Sie weiterhin pipeline oder hören Sie sich explizit auf das 'error'-Event auf.
  • Umwandlung von Streams in Buffers: Es ist verlockend, jedes 'data'-Event in ein Array zu sammeln und anschließend alles zu einer einzigen großen Zeichenkette oder einem Buffer zusammenzufügen. Dadurch werden die Speichervorteile, die Sie ursprünglich erreichen wollten, zunichte gemacht.
  • Lösen nicht geschlossener Ressourcen: Wenn eine Operation mitten im Lauf fehlschlägt, stellen Sie sicher, dass alle geöffneten Dateidescriptoren ordnungsgemäß geschlossen werden und nicht weiter bestehen bleiben.

Zusammenfassung

Wenn Entwickler mit dem Programmieren neu sind, neigen sie dazu, Daten als etwas Festes und Vollständiges zu betrachten, das einfach da liegt und verwendet werden wartet – sei es eine ganze Datei, ein vollständiger Datenbanktabellensatz oder eine fertige Antwort.

Um professionell an Backend-Systemen zu arbeiten, muss man dieses mentale Modell aufgeben.

Daten sind nicht immer ein fester, unverrückbarer Gegenstand. Meistens verhalten sie sich wie ein fließender Fluss.

Man muss keinen ganzen Fluss aufnehmen, um mit ihm zu interagieren. Man muss ihn einfach langsam an sich vorbeifließen lassen.

Sobald Streams zu Ihrem Werkzeugkasten gehören, sind große Dateien nicht mehr etwas, wovor man Angst haben muss. Ihre Infrastruktur kann auf sparsameren, günstigeren Servern laufen. Ihre Anwendungen reagieren schneller auf die Nutzer. Und vielleicht am wichtigsten: Sie können beruhigt sein, denn ein unerwartet großer 2-GB-Upload wird Ihren Server mitten in der Nacht nicht zum Absturz bringen.

Verwandte Artikel

  • Node.js Streams jenseits der Grundlagen: Speicher, Rückdruck und echte Fehler — Erfahren Sie, wie Node.js Streams mit Web Streams interagieren, welche Speichereinsparungen durch echte Benchmarks erzielt werden können und welche Produktionsfehler erst unter Last auftreten.
  • Node.js Streams verstehen: Das Problem, das sie tatsächlich lösen — Erfahren Sie, warum Node.js Streams existieren, wie das Piping intern funktioniert und was Rückdruck wirklich bedeutet, um große Daten effizient zu verarbeiten.
  • Sessions vs. JWTs: Die richtige Node.js-Authentifizierungsstrategie wählen — Erfahren Sie, wie Sessions und JWTs sich bei der Node.js-Authentifizierung tatsächlich unterscheiden, wo jede Methode ihre Grenzen hat, und wie Sie zwischen ihnen wählen können, ohne später Bedauern zu haben.
  • Node.js 26: Temporal API, Map Upserts und Undici 8 erläutert — Es werden die wichtigsten Veränderungen in Node.js 26 für den Backend-Bereich erläutert, darunter die stabile Temporal API, native Map-Upsert-Methoden, Leistungsverbesserungen durch Undici 8 sowie Änderungen, die vor einem Upgrade geprüft werden sollten.