Manejo de tareas programadas con @Scheduled en Spring Boot
1. Descripción general
Muchas aplicaciones necesitan ejecutar tareas según un cronograma: limpiar pedidos expirados, sincronizar inventario cada 5 minutos, enviar reportes a las 8 de la mañana. Spring proporciona soporte para esto mediante la anotación @Scheduled sin necesidad de librerías adicionales.
En este artículo, habilitarás la programación de tareas y distinguirás entre fixedRate, fixedDelay y cron. También aprenderás a externalizar el cronograma en archivo de configuración, ajustar el thread pool y escribir pruebas para tus tareas. Al final se presentan casos en los que no debes usar @Scheduled.
2. Habilitar programación de tareas
@Scheduled viene incluido en spring-context. Solo necesitas el starter básico de Spring Boot:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
Luego, añade @EnableScheduling a una clase de configuración:
@SpringBootApplication
@EnableScheduling
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
Olvidar @EnableScheduling es el error más común. La aplicación inicia normalmente sin mostrar errores, pero ninguna tarea se ejecuta.
Nota: El método anotado con
@Scheduledno debe tener parámetros. Debe devolvervoid, ya que el valor de retorno será ignorado. La clase que lo contiene debe ser un bean, por ejemplo anotada con@Componento@Service.
3. Ejecutar en intervalos de tiempo fijo
3.1. fixedRate
fixedRate calcula el período desde el inicio de la ejecución anterior:
@Component
public class InventoryJob {
private static final Logger log = LoggerFactory.getLogger(InventoryJob.class);
@Scheduled(fixedRate = 5000)
public void syncInventory() {
log.info("Sincronizando inventario a las {}", LocalTime.now().withNano(0));
}
}
La unidad por defecto es milisegundos. La tarea se ejecuta cuando la aplicación está lista, luego cada 5 segundos:
INFO [ scheduling-1] InventoryJob : Sincronizando inventario a las 09:00:00
INFO [ scheduling-1] InventoryJob : Sincronizando inventario a las 09:00:05
INFO [ scheduling-1] InventoryJob : Sincronizando inventario a las 09:00:10
Si una ejecución toma más de 5 segundos, la siguiente no se ejecuta en paralelo. Comienza tan pronto como termina la anterior, porque por defecto hay un solo thread (scheduling-1).
3.2. fixedDelay
fixedDelay calcula el período desde el final de la ejecución anterior. Este tipo es adecuado para tareas que no deben superponerse, como llamar a APIs externas o procesar datos en lotes:
@Component
public class EmailQueueJob {
private static final Logger log = LoggerFactory.getLogger(EmailQueueJob.class);
@Scheduled(fixedDelay = 3, initialDelay = 10, timeUnit = TimeUnit.SECONDS)
public void sendPendingEmails() throws InterruptedException {
log.info("Iniciando envío de correos");
Thread.sleep(2000); // simula una tarea que toma 2 segundos
log.info("Envío completado");
}
}
Explicación:
initialDelay = 10: espera 10 segundos después del arranque antes de ejecutar por primera vez, para evitar ralentizar el inicio.timeUnit = TimeUnit.SECONDS: cambia la unidad tanto parafixedDelaycomo parainitialDelay.- Cada ejecución toma 2 segundos, luego espera 3 segundos. El inicio de dos ejecuciones consecutivas está separado por 5 segundos:
09:00:10 Iniciando envío de correos
09:00:12 Envío completado
09:00:15 Iniciando envío de correos
09:00:17 Envío completado
4. Programar con cron
Cuando necesites ejecutar a horas específicas, usa cron. La expresión cron de Spring tiene 6 campos, uno más que cron de Unix (el campo de segundos al principio):
┌─ segundos (0-59)
│ ┌─ minutos (0-59)
│ │ ┌─ horas (0-23)
│ │ │ ┌─ día del mes (1-31)
│ │ │ │ ┌─ mes (1-12 o JAN-DEC)
│ │ │ │ │ ┌─ día de la semana (0-7 o MON-SUN)
* * * * * *
Por ejemplo, enviar un reporte de ventas a las 8 de la mañana de lunes a viernes, en zona horaria de Vietnam:
@Component
public class SalesReportJob {
private final ReportService reportService;
public SalesReportJob(ReportService reportService) {
this.reportService = reportService;
}
@Scheduled(cron = "0 0 8 * * MON-FRI", zone = "Asia/Ho_Chi_Minh")
public void sendDailyReport() {
reportService.sendYesterdaySales();
}
}
El atributo zone es muy importante cuando el servidor usa UTC, algo común en contenedores. Si lo omites, el reporte llegará a las 15:00 en lugar de las 08:00.
Algunas expresiones comúnmente usadas:
0 */15 * * * * cada 15 minutos
0 0 2 * * * 2 de la mañana cada día
0 0 0 1 * * medianoche del primer día de cada mes
0 30 9 * * SAT,SUN 9:30 sábados y domingos
@daily equivalente a 0 0 0 * * *
@hourly equivalente a 0 0 * * * *
Nota: Si copias una expresión de 5 campos desde crontab de Linux, por ejemplo
0 8 * * *, Spring lanzaráIllegalStateExceptiondurante el arranque. Necesitas añadir el campo de segundos al principio.
5. Externalizar el cronograma en archivo de configuración
Codificar el cronograma obliga a recompilar cada vez que lo cambies. Usa placeholders en su lugar. Para propiedades numéricas, usa la variante ...String:
@Component
public class OrderCleanupJob {
private final OrderRepository orderRepository;
private final AtomicInteger runCount = new AtomicInteger();
public OrderCleanupJob(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Scheduled(fixedDelayString = "${jobs.order-cleanup.delay-ms:60000}")
public void removeExpiredOrders() {
orderRepository.deleteExpiredBefore(Instant.now().minus(Duration.ofHours(24)));
runCount.incrementAndGet();
}
public int getRunCount() {
return runCount.get();
}
}
jobs.order-cleanup.delay-ms=30000
jobs.sales-report.cron=0 0 8 * * MON-FRI
El valor después de : es el valor por defecto, usado cuando la propiedad no está declarada. Para cron, escribes @Scheduled(cron = "${jobs.sales-report.cron}").
Para desactivar una tarea cron sin modificar el código, establece el valor en -:
jobs.sales-report.cron=-
Spring reconoce - (la constante Scheduled.CRON_DISABLED) y no registra esa tarea. Esto es útil cuando quieres desactivar una tarea en entornos de desarrollo o prueba.
6. Configurar el pool de threads
Por defecto, Spring Boot crea un ThreadPoolTaskScheduler con exactamente un thread. Todas las tareas comparten ese thread. Si SalesReportJob se cuelga durante 10 minutos, InventoryJob también debe esperar 10 minutos.
Puedes aumentar el tamaño del pool:
spring.task.scheduling.pool.size=4
spring.task.scheduling.thread-name-prefix=shop-job-
Después de aumentarlo, diferentes tareas pueden ejecutarse en paralelo:
INFO [ shop-job-1] InventoryJob : Sincronizando inventario a las 09:00:05
INFO [ shop-job-2] EmailQueueJob : Iniciando envío de correos
Desde Spring Boot 3.2, cuando ejecutas Java 21, puedes usar threads virtuales:
spring.threads.virtual.enabled=true
En este caso Boot usa SimpleAsyncTaskScheduler, y cada ejecución de una tarea se ejecuta en su propio thread virtual. El atributo pool.size ya no tiene efecto.
Nota: Aumentar el tamaño del pool no hace que una misma tarea se ejecute superpuesta sobre sí misma. Cada método
@Scheduledaún se ejecuta secuencialmente. Si quieres que las ejecuciones se superpongan, debes añadir@Async(junto con@EnableAsync). Casi nunca debes hacer esto, ya que fácilmente causa procesamiento duplicado de datos.
7. Manejo de excepciones
Cuando una tarea lanza una excepción con fixedRate o fixedDelay, Spring registra el error y aún así ejecuta nuevamente en la siguiente oportunidad. Lo mismo ocurre con cron. La tarea no se detiene, pero es fácil que el error pase desapercibido si no monitoreas los registros.
La forma más segura es capturar el error directamente en la tarea y manejarlo intencionalmente:
@Scheduled(fixedRate = 5, timeUnit = TimeUnit.MINUTES)
public void syncInventory() {
try {
inventoryClient.pullLatestStock();
} catch (RestClientException ex) {
log.warn("No se pudo llamar al almacén, se reintentará en el próximo ciclo: {}", ex.getMessage());
alertService.notifyOps("inventory-sync-failed");
}
}
Si quieres un punto de manejo centralizado para todas las tareas, registra un ErrorHandler mediante un customizer de Boot:
@Configuration
public class SchedulingConfig {
@Bean
ThreadPoolTaskSchedulerCustomizer schedulerErrorHandler(AlertService alertService) {
return scheduler -> scheduler.setErrorHandler(ex ->
alertService.notifyOps("scheduled-job-failed: " + ex.getMessage()));
}
}
Este customizer solo se aplica a ThreadPoolTaskScheduler. No tiene efecto cuando activas threads virtuales.
8. Escribir pruebas para tareas
Separa tus pruebas en dos niveles. El primer nivel prueba la lógica de negocio. El segundo nivel verifica que el scheduler realmente ejecute la tarea.
La lógica de negocio debe probarse como un método normal, llamándolo directamente sin necesidad de esperar:
class OrderCleanupJobUnitTest {
@Test
void shouldDeleteOrdersOlderThan24Hours() {
OrderRepository repo = mock(OrderRepository.class);
OrderCleanupJob job = new OrderCleanupJob(repo);
job.removeExpiredOrders();
ArgumentCaptor<Instant> captor = ArgumentCaptor.forClass(Instant.class);
verify(repo).deleteExpiredBefore(captor.capture());
assertThat(captor.getValue())
.isBefore(Instant.now().minus(Duration.ofHours(23)));
assertThat(job.getRunCount()).isEqualTo(1);
}
}
Para probar la programación, usa Awaitility. No debes usar Thread.sleep, porque las pruebas serán lentas e inconsistentes. Spring Boot ya gestiona la versión de Awaitility, así que no necesitas especificarla:
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>
@SpringBootTest(properties = "jobs.order-cleanup.delay-ms=100")
class OrderCleanupJobSchedulingTest {
@MockitoBean
OrderRepository orderRepository;
@Autowired
OrderCleanupJob job;
@Test
void shouldRunRepeatedly() {
await().atMost(Duration.ofSeconds(2))
.untilAsserted(() -> assertThat(job.getRunCount()).isGreaterThanOrEqualTo(3));
}
}
Esta prueba reduce el período a 100 ms mediante properties. Luego espera un máximo de 2 segundos hasta que la tarea se ejecute al menos 3 veces. Awaitility verifica repetidamente y regresa tan pronto como la condición se cumple, así que la prueba generalmente termina en aproximadamente 300 ms. @MockitoBean está disponible desde Spring Boot 3.4. Con versiones anteriores, usa @MockBean.
En otras pruebas de integración, debes desactivar la programación para que las tareas no se ejecuten en segundo plano y causen datos inconsistentes. Hay dos formas. La primera es establecer cron a -. La segunda es mover @EnableScheduling a una clase de configuración separada, con una condición:
@Configuration
@EnableScheduling
@ConditionalOnProperty(name = "jobs.enabled", havingValue = "true", matchIfMissing = true)
public class SchedulingConfig { }
Luego, las pruebas solo necesitan declarar jobs.enabled=false.
9. Cuándo NO usar @Scheduled
@Scheduled es simple, pero tiene limitaciones claras:
- Ejecutar en múltiples instancias. Con 3 pods de Kubernetes, la tarea se ejecutará 3 veces simultáneamente y el reporte se enviará 3 veces. Necesitas un candado distribuido, como la librería ShedLock que usa una tabla en la base de datos, o separar la tarea en un servicio que se ejecuta una sola vez.
- Necesitar guardar el estado de la tarea. Si la aplicación se apaga justo a las 8, esa ejecución se perderá y no será recompensada. Cuando necesites manejo de fallos, historial de ejecuciones o programación dinámica en tiempo de ejecución, usa Quartz.
- Tareas muy pesadas o que se ejecutan durante horas. Considera cambiar a Spring Batch o un sistema de colas para obtener reintentos, procesamiento en lotes y monitoreo de progreso.
- Cronogramas creados por usuarios, por ejemplo “recuérdame a las 15:00”. Las anotaciones solo funcionan con cronogramas fijos en el momento del arranque. Los cronogramas dinámicos deben almacenarse en base de datos y usarse con
TaskSchedulerprogramáticamente, o usar Quartz.
Algunos errores comunes adicionales:
- Anotar un método
privateo un método de una clase que no es un bean con@Scheduled. - Olvidar
zone, causando que la tarea se ejecute a la hora incorrecta en un servidor con UTC. - Poner
@Scheduledy@Transactionalen el mismo método puede funcionar. Sin embargo, separar la lógica en un servicio con@Transactionalserá más fácil de probar y de leer.
10. Conclusión
En este artículo, has:
- habilitado la programación con
@EnableScheduling; - distinguido entre
fixedRate, calculado desde el inicio, yfixedDelay, calculado desde el final; - escrito expresiones cron de 6 campos con zona horaria;
- externalizado el cronograma en archivo de configuración y desactivado tareas con
-; - aumentado el thread pool o usado threads virtuales para que las tareas no se bloqueen mutuamente;
- manejado excepciones y escrito pruebas con Awaitility.
Para una aplicación que se ejecuta en una sola instancia y con cronograma fijo, @Scheduled es suficiente. Cuando el sistema se escala a múltiples instancias o requiere mayor confiabilidad, considera ShedLock o Quartz antes de enfrentar problemas en producción.