Blog

Cómo evitar que los cron jobs ralenticen tu base de datos ASP.NET

Los jobs recurrentes que corren dentro de tu proceso ASP.NET compiten con el manejo de peticiones por las mismas conexiones de base de datos. Por qué pasa esto y cómo externalizar el disparador.

SteadyCron dotnetaspnetbase-de-datosscheduling

Una causa sorprendentemente común de “la API se vuelve lenta cada noche a las 2 a.m.” es un job recurrente — un informe, un backup, una query de limpieza — que corre dentro del mismo proceso ASP.NET que también sirve peticiones, contra la misma base de datos. Nadie planeó que ambos compitieran entre sí; simplemente pasa por defecto en cuanto un scheduler se conecta a la app.

Cómo ocurre

Ya sea un RecurringJob.AddOrUpdate en Hangfire, un CronTrigger en Quartz.NET, o un simple BackgroundService con un PeriodicTimer, el job corre dentro del mismo proceso que tu pipeline de peticiones. Eso significa:

  • Pool de conexiones compartido. Tu pool de DbContext tiene un tamaño fijo. Un job de informes que mantiene tres conexiones de larga duración son tres conexiones que un request handler está esperando ahora.
  • CPU y GC compartidos. Un job que materializa un result set grande para exportar dispara presión de GC que pausa todos los demás hilos del proceso, incluidos los que sirven peticiones.
  • Bloqueos compartidos. Un job de limpieza con un UPDATE o DELETE masivo puede mantener bloqueos de filas o páginas que bloquean lecturas de request handlers durante toda su duración.
  • Escala con tu app, no con el job. Correr tres instancias detrás de un balanceador de carga para el throughput de peticiones significa que el job o se dispara tres veces (sin bloqueo distribuido) o has añadido la complejidad de elección de líder solo para programar un informe.

Nada de esto se ve en desarrollo. Se ve a las 2 a.m. en producción, como un pico de latencia P99 que nadie puede reproducir — porque en local solo hay una instancia, un dataset minúsculo, y ninguna carga de peticiones concurrente.

La solución no siempre es “optimizar la query”

El instinto es indexar la query, agrupar el delete en batches, añadir hints NOLOCK. A veces esa es la solución real. Pero a menudo el problema de fondo no es la query — es que el consumo de recursos del job está acoplado a tu proceso de servicio de peticiones y a tu pool de conexiones, sin importar lo bien escrita que esté.

Desacoplar el disparador del proceso

Si el job no necesita correr dentro de tu app — no reacciona a una petición, no necesita la cola de Hangfire ni el clustering de Quartz —, saca el disparador del proceso por completo:

// Un endpoint sencillo, no un job en segundo plano
app.MapPost("/internal/cron/nightly-cleanup", async (AppDbContext db) =>
{
    await db.Database.ExecuteSqlAsync($"DELETE FROM stale_sessions WHERE ...");
    return Results.Ok();
}).RequireAuthorization("CronOnly");

Algo fuera de tu app — SteadyCron, en este caso — llama a ese endpoint según el horario, con su propio timeout y retry/backoff. La petición sigue tocando la misma base de datos, pero ahora es una petición HTTP normal que tu pool de conexiones ya sabe manejar, en tus términos: puedes correrla desde un deployment separado, limitarla, o apuntarla a una réplica de lectura si el job es de lectura intensiva.

Esto no arregla una query realmente mala. Pero sí significa que la programación del job deja de estar acoplada al ciclo de vida del proceso de tu app, al tamaño de su pool de conexiones y a su estrategia de escalado horizontal — que suele ser la mayor parte del problema real.

Si todavía no estás listo para mover el disparador

Aun puedes obtener visibilidad sin tocar dónde corre el job. El SDK SteadyCron.Monitoring envuelve el método existente con TrackAsync, así al menos sabes cuánto tarda el job y recibes una alerta si deja de correr o empieza a tardar demasiado — evidencia útil para saber si “el job nocturno es realmente la causa” antes de reestructurar nada.

await monitor.TrackAsync("nightly-cleanup", async ct =>
{
    await CleanupStaleSessionsAsync(ct);
}, ct);

Relacionado

  • SteadyCron vs Hangfire — el trade-off completo entre jobs recurrentes in-process y un horario disparado desde fuera.
  • Quartz.NET vs Hangfire — si estás eligiendo una librería de programación para los jobs que deben permanecer in-process.