almessadi.
Zur Übersicht

So finden Sie Speicherlecks in Node.js mit Heap-Snapshots_

Heap-Snapshots, behaltener Speicher und die Event-Listener-Muster, die alte Anfragen lange nach ihrem garbage collection am Leben erhalten.

Veröffentlicht15. April 2024
Lesezeit9 min read

Langsame Speicherlecks in Node.js sind normalerweise auf die schlimmste Art und Weise langweilig.

Der Dienst startet einwandfrei. Der Durchsatz sieht normal aus. Die CPU-Auslastung ist akzeptabel. Dann steigt der Speicher über Stunden stetig an, bis der Container neu gestartet wird und der Zyklus von vorne beginnt.

Dieses Muster ist ein Geschenk. Es bedeutet, dass Sie systematisch untersuchen können.

Beginnen Sie mit dem Nachweis, dass es sich um ein Leck handelt

Ein steigendes Speicherdiagramm allein ist nicht genügend.

Fragen Sie sich:

  • Sinkt der Speicher, nachdem der Verkehr zurückgeht?
  • Wächst der heapUsed, rss oder beides?
  • Hat sich die Anfragefrequenz oder die Payload-Größe gleichzeitig geändert?

Wenn der Speicher weiterhin ansteigt, ohne zurückzukommen, beginnen Sie, nach zurückbehaltenen Referenzen zu suchen.

Heap-Snapshots sind das schnellste ernsthafte Werkzeug

Wenn das Leck in JavaScript-Objekten liegt, nehmen Sie einen Heap-Snapshot:

import { writeHeapSnapshot } from "node:v8";

const path = writeHeapSnapshot();
console.log(`heap snapshot: ${path}`);

Öffnen Sie ihn in den DevTools und sortieren Sie nach dem zurückbehaltenen Speicher.

Der wichtige Begriff ist zurückbehaltener Speicher, nicht flacher Speicher. Sie suchen nach dem Objekt, das einen unerwartet großen Subgraph am Leben hält.

Ein sehr häufiges Leckmuster

Diese Form tritt in realen Diensten ständig auf:

app.use((req, _res, next) => {
  db.on("queryCompleted", () => {
    logger.info({ userId: req.user.id }, "Abfrage abgeschlossen");
  });

  next();
});

Das Problem ist nicht nur die Anzahl der Listener. Es ist das, was der Listener übergibt.

Jede Anfrage fügt einem langlebigen Emitter einen neuen Callback hinzu. Jeder Callback behält req. Wenn nichts den Listener entfernt, bleiben alte Anfrage-Objekte erreichbar, und der Garbage Collector kann sie nicht zurückgewinnen.

Die Lösung ist normalerweise Lebenszyklusdisziplin

Bevorzugen Sie eines dieser Muster:

  • Registrieren Sie einen einzigen langlebigen Listener außerhalb des Anfragepfads.
  • Verwenden Sie .once(...), wenn der Listener sich automatisch entfernen soll.
  • Entfernen Sie Listener ausdrücklich in den Bereinigungswegen.

Zum Beispiel:

function onQueryCompleted(event) {
  logger.info({ queryId: event.id }, "Abfrage abgeschlossen");
}

db.on("queryCompleted", onQueryCompleted);

Diese Version erfasst keine anforderungsabhängigen Daten mehr versehentlich.

Worauf Sie im Snapshot achten sollten

Die nützlichsten Anzeichen sind:

  • Große Arrays oder Maps, die an globalen Objekten hängen
  • Closures, die anforderungsspezifischen Zustand behalten
  • Caches ohne Eviction
  • Listener-Listen, die mit dem Verkehr wachsen

Sie suchen nicht nur nach "groß". Sie suchen nach "Warum lebt das noch?"

Verhinderung des nächsten Lecks

Einige Gewohnheiten fangen viele Node-Lecks frühzeitig auf:

  • Vermeiden Sie das Anhängen von Listenern in heißen Anfragepfaden.
  • Binden Sie Caches ausdrücklich.
  • Bevorzugen Sie Streaming über das Puffern großer Payloads.
  • Grafiken Sie den Speicher zusammen mit dem Durchsatz.
  • Nehmen Sie Snapshots, bevor sich der Vorfall verschlechtert.

Das Debugging von Speicherlecks fühlt sich geheimnisvoll an, bis Sie das erste klar sehen. Danach lässt sich der Großteil auf die Lebensdauer von Objekten zurückführen.

Weitere Lektüre