Startseite / Artikel / Warum NestJS @Cron-Jobs nur einmal pro Replikation ausgeführt werden und wie man das behebt

Warum NestJS @Cron-Jobs nur einmal pro Replikation ausgeführt werden und wie man das behebt

Erfahren Sie, warum sich die internen @Cron-Planungen vermehren, wenn ein NestJS-Dienst skaliert wird, und wie externe Auslöser, idempotente Schreibvorgänge sowie geschützte Endpunkte dieses Problem beheben.

1350 Wörter

Eine nächtliche Aufgabe, die eigentlich genau ein Datensatz pro Entität erstellen sollte, beginnt nun Duplikate zu erzeugen: identische Zeilen für dieselbe Entität und denselben Startdatum, die nur wenige Sekunden voneinander entfernt geschrieben werden, wobei keiner der Datensätze beschädigt ist. Im Code der Aufgabe hat sich nichts geändert. Was sich geändert hat, ist, dass der Dienst nun auf mehreren Replikas läuft und der Scheduler in jeder davon vorhanden ist. Dieser Artikel erklärt, warum sich der @Cron-Decorator von NestJS auf diese Weise verhält, vergleicht drei Möglichkeiten, das Problem zu beheben, und geht auf das gewählte Design ein, einschließlich der damit verbundenen Sicherheits- und Zeitzone-Einstellungen.

Wie die Duplikate entstehen

Betrachten wir eine Aufgabe, die jede aktive Entität in ihr nächstes begrenztes Zeitfenster verschiebt und täglich einen neuen Datensatz pro Entität erstellt. Die übliche Implementierung in NestJS verwendet den @nestjs/schedule-Decorator:

@Injectable()
export class WindowGenerationService {
  @Cron('0 8 * * *') // every day at 08:00
  async generateNextWindows() {
    const entities = await this.repo.findActiveEndingSoon();
    for (const entity of entities) {
      await this.repo.createNextWindow(entity);
    }
  }
}

Genau das empfiehlt die Dokumentation, und es funktioniert einwandfrei, solange der Dienst als einzige Instanz läuft.

Das Problem tritt nach der horizontalen Skalierung auf. Bei drei Instanzen gibt es drei Prozesse, in denen jeweils das Modul gestartet wird, und @Cron registriert seinen Timer in jedem dieser Prozesse. Nichts koordiniert sie: Um 08:00 feuern alle drei gleichzeitig ab. Da die Aufgabe keine Idempotenzprüfung durchführt und kein Schloss verwendet, sucht jede Ausführung nach einem vorhandenen Fenster, findet keines und schreibt ihre eigene Kopie.

Zwei Kosten entstehen dabei. Die offensichtliche ist die Doppelung von Daten. Die weniger offensichtliche ist der Verschwendung: Jede Kopie erledigt zur gleichen Zeit dieselbe Arbeit, wodurch zusätzliche Anstrengungen für die Aufbereitung des Ergebnisses nötig sind. Ein Prozessplaner in einem skalierten Service ist nicht nur ein Fehler bezüglich der Korrektheit; er verbraucht aus Designgründen Rechenleistung in proportion zur Anzahl der Kopien. Im Code findet sich nichts, was darauf hindeutet, weshalb dieser Problemtyp eher in Staging-Daten als bei der Überprüfung zum Vorschein kommt.

Drei Möglichkeiten, das zu beheben

Option 1: ein verteiltes Schloss

Behalten Sie @Cron, lassen Sie die Instanzen jedoch um einen Lock konkurrieren – beispielsweise um einen Datenbank-Advisory-Lock oder einen Schlüssel im Cache – und erlauben Sie nur dem Gewinner, auszuführen. Das funktioniert, ersetzt aber einen sichtbaren Fehler durch einen unsichtbaren. Duplizierte Zeilen können zumindest gefunden und gelöscht werden. Eine ausgelassene Ausführung hingegen nicht: Wenn der Lock-Backend um 08:00 Uhr nicht verfügbar ist oder eine Instanz vor Ablauf der TTL beim Halten des Locks abstürzt, findet die Aufgabe einfach nicht statt, und niemand bemerkt es, bis ein fehlendes Zeitfenster Tage später Probleme verursacht. Zudem entsteht dadurch eine neue Abhängigkeit sowie ein neuer stiller Fehlermodus, um einen in der falschen Schicht platzierten Timer auszugleichen.

Option 2: Alleinige Idempotenz

Sorgen Sie dafür, dass die Aufgabe sicher mehrmals ausgeführt werden kann, sodass zusätzliche Ausführungen nichts bewirken. Das ist kostengünstig und korrekt, doch drei Container müssen trotzdem jede Nacht gestartet werden, um überflüssige Arbeit zu leisten.

Option 3: Planung außerhalb der Anwendung durchführen

Die Anwendung sollte nicht entscheiden, wann die Aufgabe ausgeführt wird. Lassen Sie einen externen Scheduler für den Zeitplan zuständig sein, der eine HTTP-Anfrage an einen gewöhnlichen Endpunkt sendet; die Anwendung entscheidet lediglich was passiert, wenn sie diese Anfrage erhält. Ein Auslöser führt zu einer einzigen Ausführung, und das Hinzufügen von Replikas multipliziert nichts mehr. Häufige Orte für solche Auslöser sind Kubernetes CronJobs, der Scheduler-Service eines Cloud-Anbieters oder ein Zeitplan für CI-Pipelines.

Das gewählte Design kombiniert Option 3 mit der Idempotenz aus Option 2 als Sicherheitsnetz.

Das neue Design

Der @Cron-Decorator wird entfernt, und die Job-Logik befindet sich hinter einem Endpunkt:

@Post('jobs/run')
async runJob(@Body() body: RunJobDto) {
  this.assertValidSecret(body.secret);
  return this.jobs.run(body.jobKey);
}

Der externe Scheduler ruft dies einmal zur festgelegten Zeit auf. Der Load Balancer leitet die Anfrage an eine Instanz weiter, die die Aufgabe ausführt; die anderen Kopien werden dabei nie involviert. Durch das Übergeben einer jobKey kann ein einziger Endpunkt mehrere Aufgaben versenden.

Eine praktische Verbesserung: Eine Aufgabe, die mehrere Minuten dauert, kann länger andauern als die HTTP-Timeout-Zeit des Schedulers. Bei langen Aufgaben sollte man in Betracht ziehen, die Anfrage schnell zu bestätigen und die Arbeit im Hintergrund auszuführen, während gleichzeitig Überschneidungen vermieden werden.

Idempotenz als Sicherheitsnetz

Die Idempotenzprüfung bleibt bestehen, denn „genau einmal ausführen“ ist ein Versprechen, das die Infrastruktur in der Regel hält, aber gelegentlich gebrochen wird: Ein Scheduler versucht es nach Ablauf der Zeit erneut, jemand triggert die Aufgabe manuell oder eine Instanz startet mitten in der Ausführung neu.

async createNextWindow(entity: Entity) {
  const existing = await this.repo.findByEntityIdAndStartDate(
    entity.id,
    entity.nextStartDate,
  );
  if (existing) return; // already done, no-op
  await this.repo.create(/* ... */);
}

Bevor ein Fenster erstellt wird, sucht die Methode nach einem mit derselben Entity-ID und Startdatum und gibt frühzeitig zurück, falls es bereits existiert. Beachten Sie, dass das Vorgehen „Prüfen und dann einfügen“ selbst bei exakt überschneidenden Abläufen problematisch sein kann. Eine zuverlässige Lösung ist eine eindeutige Beschränkung in der Datenbank für die Entity-ID und das Startdatum, sodass doppelte Eingaben gleichzeitig fehlschlagen, anstatt gespeichert zu werden. Für eine ausführlichere Betrachtung der Gestaltung fehlerresistenter Schreibvorgänge siehe unsere Anleitung zu Idempotenzschlüsseln in Node.js POST-Endpunkten.

Was die Änderung kostet

Ein Job-Endpunkt ist ein öffentlicher Endpunkt

Durch Umwandlung einer privaten Nachtjob-Funktion in eine HTTP-Route entsteht ein Button, den jeder, der ihn findet, wiederholt betätigen kann. Daher erfordert die Route ein gemeinsames Geheimnis, und die Art und Weise, wie dieses Geheimnis verglichen wird, ist entscheidend:

private assertValidSecret(provided: string) {
  const expected = this.config.cronSecret;
  const a = Buffer.from(provided);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    throw new UnauthorizedException();
  }
}

Die Vergleichsoperation provided === expected kann durch Zeitmessungen Informationen preisgeben: Der Vergleich von Zeichenketten stoppt oft bereits beim ersten nicht übereinstimmenden Zeichen, wodurch die Dauer des Scheiterns Aufschluss darüber gibt, wie korrekt die Schätzung war. Dadurch kann ein Angreifer das geheime Element Stück für Stück rekonstruieren. Node.js’ timingSafeEqual aus dem crypto-Modul führt hingegen einen Vergleich in konstanter Zeit durch. Dabei sind Puffer gleicher Länge erforderlich, weshalb zunächst die Länge überprüft wird; das Hashen beider Werte vor dem Vergleich verhindert sogar die Enthüllung der Länge. Prüfer weisen dieses Problem nur selten auf, und darauf zu vertrauen, dass der Endpunkt unentdeckt bleibt, ist keine geeignete Strategie.

Zwei weitere Verschärfungsschritte sind erwähnenswert. Das Senden des Geheimnisses in einem Anfrage-Header statt im Body hält es außerhalb der Middleware zur Protokollierung des Bodies. Zudem verhindert die Überprüfung, dass secret in RunJobDto eine nicht leere Zeichenkette ist, dass Buffer.from bei fehlendem Eingang einen Fehler auslöst.

Die Wahl der Stunde ist eine Produktentscheidung

Der zweite Kostenfaktor wird leicht unterschätzt: die Auswahl des Zeitpunkts. Die Aufgabe muss für jeden Benutzer nach Beginn des Tages ausgeführt werden, und die Benutzer befinden sich in verschiedenen US-Zeitzonen – eine Stunde mittags an der Ostküste liegt daher noch vor Tagesanbruch an der Westküste. Daher wird der Zeitplan auf eine feste UTC-Stunde festgelegt, die anhand der am weitesten westlich gelegenen Zeitzone des Unternehmens gewählt wird, da dies die Zeit ist, zu der überall sicher gearbeitet werden kann. Der Cron-Job verwendet UTC, während die Anforderung die Ortszeit vorsieht; die Umrechnung zwischen diesen beiden Zeiten ist der entscheidende Punkt. Schreiben Sie diese Begründung neben den Zeitplan, denn sie geht allein aus der Cron-Expression nicht hervor.

Kernpunkte

  • @Cron wird in jedem Prozess ausgeführt, der das Modul lädt, sodass seine Ausführungen mit der Anzahl der Replikate ohne jegliche Warnung im Code zunehmen.
  • Distributed locks beheben Duplikate, führen aber zu stillen Fehlversuchen sowie einer zusätzlichen Abhängigkeit.
  • Die Planung betrifft das „Wann“ und gehört zur externen Infrastruktur; die Anwendung sollte nur bestimmen, „was passiert“, wenn sie ausgelöst wird.
  • Bewahren Sie auf jeden Fall die Idempotenz – idealerweise durch eine eindeutige Datenbankbeschränkung –, denn die einmalige Lieferung ist das Versprechen eines anderen.
  • Ein Job-Endpoint benötigt echten Schutz: ein Geheimnis im Vergleich mit timingSafeEqual, Eingabenvalidierung sowie sinnvolle Protokollierung.
  • Definieren Sie Zeitpläne absichtlich in UTC, basierend auf der Zeitzone, die die Anforderung am stärksten einschränkt.