Comment empêcher les cron jobs de ralentir votre base de données ASP.NET
Les jobs récurrents qui tournent dans votre processus ASP.NET entrent en concurrence avec le traitement des requêtes pour les mêmes connexions base de données. Pourquoi cela arrive, et comment externaliser le déclencheur.
Une cause étonnamment fréquente du « l’API devient lente toutes les nuits à 2 h » est un job récurrent — un rapport, une sauvegarde, une requête de nettoyage — qui tourne dans le même processus ASP.NET qui sert aussi les requêtes, contre la même base de données. Personne n’a prévu que les deux se gênent ; ça arrive simplement par défaut dès qu’un scheduler est câblé dans l’app.
Comment ça arrive
Qu’il s’agisse d’un RecurringJob.AddOrUpdate dans Hangfire, d’un
CronTrigger dans Quartz.NET, ou d’un simple BackgroundService avec un
PeriodicTimer, le job tourne dans le même processus que votre pipeline
de requêtes. Cela signifie :
- Pool de connexions partagé. Votre pool de
DbContexta une taille fixe. Un job de rapport qui détient trois connexions longues, ce sont trois connexions qu’un request handler attend désormais. - CPU et GC partagés. Un job qui matérialise un grand result set pour un export déclenche une pression GC qui met en pause tous les autres threads du processus, y compris ceux qui servent les requêtes.
- Verrous partagés. Un job de nettoyage faisant un
UPDATEouDELETEen masse peut détenir des verrous de lignes ou de pages qui bloquent les lectures des request handlers pendant toute la durée. - Scale avec votre app, pas avec le job. Faire tourner trois instances derrière un load balancer pour le débit de requêtes signifie que le job se déclenche soit trois fois (sans verrouillage distribué), soit que vous avez ajouté la complexité d’une élection de leader juste pour planifier un rapport.
Rien de tout cela n’apparaît en développement. Ça apparaît à 2 h du matin en production, sous forme de pic de latence P99 que personne ne peut reproduire — parce qu’en local il n’y a qu’une instance, un jeu de données minuscule, et aucune charge de requêtes concurrente.
La solution n’est pas toujours « optimiser la requête »
Le réflexe est d’indexer la requête, de batcher le delete, d’ajouter des
hints NOLOCK. Parfois c’est la vraie solution. Mais souvent le problème
sous-jacent n’est pas la requête — c’est que la consommation de ressources du
job est couplée à votre processus de service de requêtes et à votre pool de
connexions tout court, peu importe à quel point elle est bien écrite.
Découpler le déclencheur du processus
Si le job n’a pas besoin de tourner à l’intérieur de votre app — il ne réagit pas à une requête, n’a pas besoin de la file de Hangfire ni du clustering de Quartz —, sortez le déclencheur entièrement du processus :
// Un simple endpoint, pas un job en arrière-plan
app.MapPost("/internal/cron/nightly-cleanup", async (AppDbContext db) =>
{
await db.Database.ExecuteSqlAsync($"DELETE FROM stale_sessions WHERE ...");
return Results.Ok();
}).RequireAuthorization("CronOnly");
Quelque chose hors de votre app — SteadyCron, dans ce cas — appelle cet endpoint selon le planning, avec son propre timeout et réessai/backoff. La requête touche toujours la même base de données, mais c’est désormais une requête HTTP normale que votre pool de connexions sait déjà gérer, selon vos conditions : vous pouvez la faire tourner depuis un déploiement séparé, la limiter, ou la diriger vers une réplique de lecture si le job est plutôt en lecture.
Cela ne corrige pas une requête vraiment mauvaise. Mais cela signifie que la planification du job n’est plus couplée au cycle de vie du processus de votre app, à la taille de son pool de connexions, ni à sa stratégie de scaling horizontal — ce qui constitue généralement la majeure partie du vrai problème.
Si vous n’êtes pas prêt à déplacer le déclencheur
Vous pouvez tout de même obtenir de la visibilité sans toucher à l’endroit où
le job tourne. Le SDK SteadyCron.Monitoring
enveloppe la méthode existante avec TrackAsync, pour au moins savoir combien
de temps prend le job et être alerté s’il s’arrête de tourner ou commence à
durer trop longtemps — une preuve utile pour savoir si « le job nocturne est
vraiment la cause » avant de restructurer quoi que ce soit.
await monitor.TrackAsync("nightly-cleanup", async ct =>
{
await CleanupStaleSessionsAsync(ct);
}, ct);
À lire aussi
- SteadyCron vs Hangfire — le compromis complet entre jobs récurrents in-process et un planning déclenché depuis l’extérieur.
- Quartz.NET vs Hangfire — si vous choisissez une bibliothèque de planification pour les jobs qui doivent rester in-process.