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
| Metrik | Vorher | Nachher |
|---|---|---|
| RSS-Peak | 1,7 GB (OOM) | 95 MB |
| Laufzeit | crash nach 3 min | 47 s |
| Stabilität | täglich Pager | 6 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.