Memory Leaks in Node.js debuggn
A Node-Prozess, der über Stunden oda Tage langsam im Speicherverbrauch klettert und dann irgendwann vom Container-Orchestrator wegn OOM abgschossn wird, is oane der frustrierenderen Bug-Klassen - er lasst si lokal nie reproduziern, die Logs kurz vor'm Absturz schaun ganz normal aus, und "den Pod neu startn" lasst das Symptom verschwindn, ohne wos zu reparieren. Do is, wia ma das Leck tatsächlich findet, statt's nur zu umgehn.
Wos a "Leck" in ana Sprache mit Garbage Collection heißt
Node hot koa manuelle Speicherverwaltung, drum is a Leck ka fehlendes free() - es is a Objekt, des vom Garbage Collector no immer ois erreichbar gilt, von irgendwo, wos er ois Root betrachtet (globaler Scope, a aktive Closure, a langlebige Datenstruktur), long nachdem dein Code logisch damit fertig is. Der GC kann's ned einsammeln, weil du's soweit er des beurteiln kann vielleicht no brauchst. Der Bug is imma "i hoit no a Referenz drauf", nie "i hob vergessn, es freizugebn".
De üblichen Verdächtigen
Wachsende Collections ohne Verdrängung. A Map oda Array, des ois Cache dient und nur .set()-/.push()-Aufrufe kriagt, nie Aufräumen:
// leaks: every request adds an entry, nothing ever removes one
const responseCache = new Map();
app.get('/api/report/:id', async (req, res) => {
if (!responseCache.has(req.params.id)) {
responseCache.set(req.params.id, await generateReport(req.params.id));
}
res.json(responseCache.get(req.params.id));
});
Jede jemois angforderte eindeutige id bleibt für immer im Speicher. Behebe des mit ana Obergrenze: a LRU-Cache, der den am längsten ungenutzten Eintrag über a Größenlimit hinaus verdrängt, oda a TTL, die Einträge nach fester Zeit ablaufen lasst.
Event-Listener, de länger leben ois das, wo sie dranhängen. Nodes EventEmitter hoit a starke Referenz auf jeden Listener, den du registrierst. Wennst in ana Funktion, de wiederholt läuft, an Listener anhängst und nie entfernst, sammeln si Listener an - und jeder schließt über alle Variablen im Scope, wos die a am Leben hoit.
// leaks: a new listener added every time this runs, old ones never removed
function watchConnection(connection) {
dbPool.on('error', (err) => {
logger.error(`Connection error: ${err.message}`, { connectionId: connection.id });
});
}
Ruf watchConnection 10.000 Moi über die Lebensdauer der App auf, und dbPool hoit 10.000 Listener, jeder schließt über sein eigenes connection-Objekt. Lösung: den Listener entfernen, wennst fertig bist (emitter.off(event, handler)), oda emitter.once() nutzn, wenn er nur oamoi feuern soit.
Timer, de nie gelöscht wern. setInterval hoit seine Closure - und olles, wos sie referenziert - am Leben, solang der Timer läuft, was standardmäßig für immer is.
// leaks if `session` objects are meant to be short-lived
function startHeartbeat(session) {
setInterval(() => {
session.lastSeen = Date.now();
}, 30_000);
// no reference to the interval kept anywhere — it can never be cancelled
}
Wennst den Rückgabewert von setInterval/setTimeout ned aufhebst, host den einzigen Handle valorn, mit dem du später clearInterval/clearTimeout aufrufn kannst - der Timer und seine Closure lebn, bis der Prozess endet.
Closures über große Objekte, wenn nur a klans Stückl gebraucht wird. A Funktion, de über a ganzes Request- oda Response-Objekt schließt, nur um oa Feld zu nutzen, hoit das ganze Objekt (und olles, wos des referenziert) am Leben, solang die Closure existiert.
// keeps the entire `req` object alive inside `processedLater`
function schedule(req) {
const processedLater = () => console.log(req.user.id);
queue.push(processedLater);
}
// only keeps the one value you actually need
function schedule(req) {
const userId = req.user.id;
const processedLater = () => console.log(userId);
queue.push(processedLater);
}
Bestätigen, dass's wirklich a Leck is, ned nur normales GC-Sägezahnmuster
Gesunder Node-Speicherverbrauch is ned flach - er geht auf und ab, wenn der GC läuft. A Leck is a Trend: Die Basislinie nach jedem GC-Zyklus klettert mit da Zeit immer weiter, ned nur die Spitzen zwischn den Zyklen.
setInterval(() => {
const { heapUsed, rss } = process.memoryUsage();
console.log(`heapUsed=${(heapUsed / 1024 / 1024).toFixed(1)}MB rss=${(rss / 1024 / 1024).toFixed(1)}MB`);
}, 60_000);
Log des a poar Stunden unter echtem Traffic. Wenn heapUsed nach jedem Tal weiter nach oben trendet, host a Leck; wenn er um an stabilen Bereich oszilliert, is des normales GC-Verhalten.
Das tatsächlich leckende Objekt mit Heap-Snapshots finden
Sobois du woaßt, dass's a Leck gibt, is das Werkzeug zum Finden von wos --inspect plus der Heap-Profiler der Chrome DevTools, der zwoa Snapshots vergleicht, de a poar Minuten auseinander unter Last gnommen wurden:
node --inspect server.js
Öffne chrome://inspect in Chrome, verbinde dich mit dem Prozess, und im Memory-Tab:
- Mach an Heap-Snapshot (Basislinie).
- Lass die App a poar Minuten unter realistischer Last laufn.
- Mach an zwoatn Snapshot.
- Stell die Vergleichsansicht auf "Comparison" zwischn den beiden - sie sortiert Objekte danach, wie vül neiche Instanzen aufgetaucht und ned eingsammelt worn sand.
Der Objekttyp ganz oben in dem Vergleich - meist wos verdächtig Spezifisches, wia deine eigene Session-Klasse oda a Closure-Zähler, der weiter wachst - is fast imma das Leck. Von dort zeigt der Retainer-Baum der DevTools, warum es no erreichbar is: wöche Variable, wöches Array oda wöche Map die Referenz hoitet, de das Einsammeln verhindert. Der Retainer-Pfad is die eigentliche Antwort - er sogt dir genau, wöche Codezeiln du reparieren muasst.
A billigerer erster Durchgang: --max-old-space-size und Snapshots beim Absturz
Wennst das Leck ned leicht in ana Debugger-Session reproduziern kannst, kriagst automatisch an Heap-Snapshot zum OOM-Zeitpunkt, wennst startest mit:
node --max-old-space-size=512 --heapsnapshot-near-heap-limit=3 server.js
Das schreibt bis zu 3 Heap-Snapshots auf die Platte, kurz bevor der Prozess sonst wegn Erreichen vom Speicherlimit abstürzen würd - nützlich in Produktion oda Staging, wo du live koan Debugger anhängen kannst, aber die entstandene .heapsnapshot-Datei holn und danach für dieselbe Vergleichsanalyse in die DevTools lodn kannst.
Fazit
Jedes Node-Speicherleck lasst si auf dieselbe Grundursache reduziern: Irgendwos hoit no a Referenz, de der Code nimma braucht. Caches ohne Verdrängung, Listener ohne Entfernung, Timer ohne Löschen und Closures über mehr, ois sie brauchn, sand die vier Stellen, wo diese Referenz fast imma sitzt. Bestätige den Trend mit process.memoryUsage() über die Zeit, dann find das konkrete Objekt mit am Heap-Snapshot-Vergleich - aus den Symptomen alloa zu raten zeigt selten auf die echte Codezeiln.