Wie Sie verhindern, dass Cronjobs Ihre ASP.NET-Datenbank ausbremsen
Wiederkehrende Jobs innerhalb Ihres ASP.NET-Prozesses konkurrieren mit der Request-Verarbeitung um dieselben Datenbankverbindungen. Hier, warum das passiert und wie Sie den Trigger externalisieren.
Eine überraschend häufige Ursache für „die API wird jede Nacht um 2 Uhr langsam” ist ein wiederkehrender Job — ein Report, ein Backup, eine Aufräum-Query —, der innerhalb desselben ASP.NET-Prozesses läuft, der auch Requests bedient, gegen dieselbe Datenbank. Niemand hat geplant, dass sich die beiden in die Quere kommen; es passiert einfach, sobald ein Scheduler in der App verdrahtet ist.
Wie es passiert
Ob RecurringJob.AddOrUpdate in Hangfire, ein CronTrigger in Quartz.NET,
oder ein einfacher BackgroundService mit PeriodicTimer — der Job läuft
innerhalb desselben Prozesses wie Ihre Request-Pipeline. Das bedeutet:
- Gemeinsamer Connection-Pool. Ihr
DbContext-Pool hat eine feste Größe. Ein Report-Job, der drei lang laufende Verbindungen hält, sind drei Verbindungen, auf die ein Request-Handler jetzt wartet. - Gemeinsame CPU und GC. Ein Job, der für einen Export einen großen Result Set materialisiert, löst GC-Druck aus, der jeden anderen Thread im Prozess pausiert — auch die, die Requests bedienen.
- Gemeinsame Locks. Ein Aufräum-Job mit einem Bulk-
UPDATEoderDELETEkann Zeilen- oder Seiten-Locks halten, die Reads von Request-Handlern für die Dauer blockieren. - Skaliert mit Ihrer App, nicht mit dem Job. Drei Instanzen hinter einem Load Balancer für Request-Durchsatz zu betreiben bedeutet, dass der Job entweder dreimal feuert (ohne verteiltes Locking) oder Sie die Komplexität von Leader-Election nur eingeführt haben, um einen Report zu planen.
Nichts davon zeigt sich in der Entwicklung. Es zeigt sich um 2 Uhr nachts in Produktion, als P99-Latenz-Spike, den niemand reproduzieren kann — weil lokal nur eine Instanz, ein winziger Datensatz und keine gleichzeitige Request-Last vorliegen.
Die Lösung ist nicht immer „die Query optimieren”
Der Reflex ist, die Query zu indizieren, das Delete zu batchen, NOLOCK-Hints
hinzuzufügen. Manchmal ist das die echte Lösung. Aber oft ist das eigentliche
Problem nicht die Query — sondern dass der Ressourcenverbrauch des Jobs
überhaupt an Ihren Request-bedienenden Prozess und Connection-Pool gekoppelt
ist, unabhängig davon, wie gut sie geschrieben ist.
Trigger vom Prozess entkoppeln
Wenn der Job nicht innerhalb Ihrer App laufen muss — er reagiert nicht auf einen Request, braucht nicht Hangfires Queue oder Quartz’ Clustering —, verschieben Sie den Trigger komplett aus dem Prozess heraus:
// Ein einfacher Endpunkt, kein Hintergrund-Job
app.MapPost("/internal/cron/nightly-cleanup", async (AppDbContext db) =>
{
await db.Database.ExecuteSqlAsync($"DELETE FROM stale_sessions WHERE ...");
return Results.Ok();
}).RequireAuthorization("CronOnly");
Etwas außerhalb Ihrer App — in diesem Fall SteadyCron — ruft diesen Endpunkt planmäßig auf, mit eigenem Timeout und Retry/Backoff. Der Request greift weiterhin auf dieselbe Datenbank zu, aber jetzt als normaler HTTP-Request, den Ihr Connection-Pool bereits kennt, nach Ihren Bedingungen: Sie können ihn aus einem separaten Deployment laufen lassen, drosseln oder auf eine Read-Replica zeigen lassen, wenn der Job leseintensiv ist.
Das behebt keine wirklich schlechte Query. Es bedeutet aber, dass das Scheduling des Jobs nicht mehr an den Prozess-Lebenszyklus, die Connection-Pool-Größe und die horizontale Skalierungsgeschichte Ihrer App gekoppelt ist — was meist den größten Teil des eigentlichen Problems ausmacht.
Wenn Sie den Trigger noch nicht verschieben wollen
Sie können trotzdem Sichtbarkeit bekommen, ohne anzufassen, wo der Job läuft.
Das SteadyCron.Monitoring-SDK
umschließt die bestehende Methode mit TrackAsync, sodass Sie zumindest
wissen, wie lange der Job dauert, und alarmiert werden, wenn er aufhört zu
laufen oder zu lange läuft — nützliche Evidenz für „ist der nächtliche Job
tatsächlich die Ursache”, bevor Sie irgendetwas umbauen.
await monitor.TrackAsync("nightly-cleanup", async ct =>
{
await CleanupStaleSessionsAsync(ct);
}, ct);
Weiterlesen
- SteadyCron vs. Hangfire — der ausführlichere Trade-off zwischen In-Process-wiederkehrenden Jobs und einem extern ausgelösten Zeitplan.
- Quartz.NET vs. Hangfire — wenn Sie eine Scheduling-Bibliothek für die Jobs wählen, die in-process bleiben sollten.