json-formatieren.de

Case-Study · Node.js 22 Daily-ETL-Job auf Hetzner CX21 (4 GB RAM)

Case-Study: 500 MB JSON-Dump streamen

Analytics-Pipeline mit 540 MB JSON-Dump pro Tag. JSON.parse crashte mit Heap-OOM bei 1,7 GB RSS. Umstellung auf stream-json brachte RSS auf 95 MB.

Metriken vor / nach

Größe / Volumen

540 MB

540 MB

Parse / Laufzeit

OOM nach 3 min

47 s · 95 MB RSS

Status

crashte täglich

läuft 6 Wochen stabil

Maßnahmen

  • JSON.parse durch stream-json Pipeline ersetzt
  • Object-Filterung auf Event-Level (statt im Memory-Tree)
  • Output zeilenweise als NDJSON in PostgreSQL COPY-Stream
  • Node --max-old-space-size erhöht - als Sicherheitsnetz, nicht mehr nötig
  • Monitoring auf RSS-Spitze pro Run

Ausgangslage: ein Daily-ETL-Job bei einem AKARA-Kunden zog jeden Morgen einen JSON-Dump aus dem Analytics-Vendor - pro Tag etwa 540 MB, alles in einem einzigen Top-Level-Array von 1,8 Mio Events. Der Job lief auf einer 4-GB-RAM-VM, war seit Monaten stabil und kippte plötzlich.

Was passiert war

Mit dem Wachstum des Kunden überschritt der Dump die 500-MB-Marke. JSON.parse benötigt im Schnitt 2-3× die Datei-Größe als Heap - bei 540 MB also rund 1,5 GB. Plus V8-Overhead und der eigentlichen Anwendung war die VM bei 1,7 GB RSS und der Node-Prozess wurde vom Linux OOM-Killer beendet.

Maßnahme 1: stream-json statt JSON.parse

Die npm-Library stream-json (von uhop) bietet einen SAX-artigen Streaming-Parser. Pipeline:

import { parser } from 'stream-json';
import { streamArray } from 'stream-json/streamers/StreamArray.js';

await pipeline(
  fs.createReadStream('dump.json'),
  parser(),
  streamArray(),
  async function* (events) {
    for await (const { value } of events) {
      yield processEvent(value);
    }
  },
  copyToPostgres,
);

Statt das gesamte Array in den Speicher zu laden, wird jedes Event einzeln durchgereicht. Der Filter passiert direkt während des Parsings - Memory bleibt konstant.

Maßnahme 2: NDJSON-Zwischenspeicher

Der Postgres-Bulk-Insert via COPY erwartete CSV. Umstellung auf JSON-Lines (eine JSON-Entity pro Zeile) verkürzte den Code um 60 Zeilen Validierungs-Logik und beschleunigte den Insert um ~30 %, weil Postgres mit jsonb nativ umgehen kann.

Ergebnis

MetrikVorherNachher
RSS-Peak1,7 GB (OOM)95 MB
Laufzeitcrash nach 3 min47 s
Stabilitättäglich Pager6 Wochen ohne Vorfall
Implementierungszeit-12 min Diff

Lehre: für JSON-Dumps über 100 MB ist Streaming-Parsing kein Premature-Optimization, sondern Pflicht. Das eigentliche Tooling ist trivial - die Migration scheitert eher daran, dass Anwendungslogik den vollen Baum erwartet (Sortierung, Cross-References). Wenn das gegeben ist: streamen.