
Tareas que se ejecutan solas
Casi cualquier aplicación Spring Boot tiene una o varias tareas periódicas: limpiar datos temporales cada noche, sincronizar con un sistema externo cada hora, enviar recordatorios diarios a las 9:00, generar un informe mensual el primer día del mes. La anotación @Scheduled resuelve este caso sin librerías externas.
Activación
Hay que añadir @EnableScheduling en una clase de configuración o en la aplicación principal:
@SpringBootApplication
@EnableScheduling
public class EmpleadosApplication { ... }
A partir de ahí, cualquier método anotado con @Scheduled en un bean Spring se ejecuta automáticamente según su configuración.
Tipos de programación
fixedRate
Ejecuta cada N milisegundos desde el inicio de la ejecución anterior:
@Scheduled(fixedRate = 30000) // cada 30 segundos
public void heartbeat() {
log.info("Aplicación viva: {}", Instant.now());
}
Si la ejecución tarda 40 segundos, la siguiente intenta arrancar inmediatamente sin esperar (el thread pool decide). Riesgo: ejecuciones solapadas si el método tarda más que el intervalo.
fixedDelay
Ejecuta N milisegundos después de que termine la ejecución anterior:
@Scheduled(fixedDelay = 60000) // espera 60s tras terminar
public void sincronizar() {
// Si tarda 50s, próxima ejecución en t+110s
// Si tarda 5min, próxima ejecución en t+360s
sincronizarConSistemaExterno();
}
Garantiza que no hay solapamiento. Adecuado para tareas con duración variable (lecturas a APIs externas, procesamiento de archivos).
cron
Sintaxis Quartz segundo minuto hora día-mes mes día-semana:
@Scheduled(cron = "0 0 2 * * *") // todos los días a las 02:00
public void backup() {
realizarBackupNocturno();
}
@Scheduled(cron = "0 0 9 * * MON-FRI") // lunes-viernes a las 09:00
public void recordatorioDiario() {
enviarRecordatoriosDiarios();
}
@Scheduled(cron = "0 0 0 1 * *") // primer día de cada mes a medianoche
public void informeMensual() {
generarInformeMensual();
}
Casos comunes:
0 */15 * * * *— cada 15 minutos0 0 */2 * * *— cada 2 horas, en punto0 0 0 * * 0— domingos a medianoche0 30 8 ? * MON-FRI— días laborables a las 08:30
El ? se usa cuando no aplica un campo (típicamente día-mes vs día-semana son excluyentes).
initialDelay
Combinable con cualquiera de los anteriores. Espera N milisegundos antes de la primera ejecución:
@Scheduled(fixedRate = 60000, initialDelay = 10000)
public void heartbeat() {
// Primera ejecución a los 10s del arranque, luego cada 60s
}
Útil para no bombardear servicios externos al arranque cuando varias aplicaciones se levantan a la vez.
Expresiones desde propiedades
Lo idiomático es no hardcodear valores; configurarlos por entorno:
app:
scheduler:
sincronizacion-cron: "0 0 */2 * * *"
backup-cron: "0 0 2 * * *"
@Scheduled(cron = "${app.scheduler.sincronizacion-cron}")
public void sincronizar() { ... }
@Scheduled(cron = "${app.scheduler.backup-cron:-}")
public void backup() { ... } // ":- " desactiva la tarea si la prop falta
Con cron = "-" Spring Boot deshabilita la tarea. Útil para apagarla en perfiles concretos.
TaskScheduler con thread pool dedicado
Por defecto, Spring usa un único thread para todas las tareas @Scheduled. Si dos tareas concurren, la segunda espera. En proyectos con varias tareas conviene un pool dedicado:
@Configuration
public class SchedulingConfig {
@Bean
public TaskScheduler taskScheduler() {
var scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(5);
scheduler.setThreadNamePrefix("scheduler-");
scheduler.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
scheduler.setWaitForTasksToCompleteOnShutdown(true);
scheduler.setAwaitTerminationSeconds(30);
return scheduler;
}
}
Spring Boot detecta el bean TaskScheduler y lo usa para todas las tareas @Scheduled. Con poolSize = 5, hasta 5 tareas pueden ejecutarse en paralelo.
waitForTasksToCompleteOnShutdown(true) asegura que el shutdown de la aplicación espera a que las tareas en curso terminen, evitando datos parciales en sistemas externos.
Virtual Threads en Spring Boot 4
Con Java 25 LTS y Spring Boot 4, el TaskScheduler puede usar Virtual Threads:
@Bean
public TaskScheduler taskScheduler() {
var scheduler = new SimpleAsyncTaskScheduler();
scheduler.setVirtualThreads(true);
scheduler.setThreadNamePrefix("vscheduler-");
return scheduler;
}
Con Virtual Threads, no hay límite efectivo de tareas concurrentes (hasta el límite del SO). Útil para tareas IO-bound (sincronizaciones, llamadas HTTP).
El problema en multi-instancia
Cuando la aplicación se despliega en varias instancias (Kubernetes con réplicas, servidores tras un balanceador), cada instancia ejecuta sus tareas programadas. Eso significa que backup() se ejecutaría una vez por instancia: si hay 3 réplicas, el backup se hace 3 veces a las 02:00.
Para tareas idempotentes y baratas, eso es aceptable (heartbeat, refresh de cache local). Para tareas pesadas (backup, generación de informes, envío de emails masivos), es un desastre.
La solución estándar es ShedLock: un sistema de locks distribuidos sobre una BD compartida que garantiza que solo una instancia ejecuta cada tarea por intervalo.
Configuración de ShedLock
Dependencia:
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-spring</artifactId>
<version>5.x</version>
</dependency>
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-jdbc-template</artifactId>
<version>5.x</version>
</dependency>
Tabla en BD:
CREATE TABLE shedlock (
name VARCHAR(64) NOT NULL PRIMARY KEY,
lock_until TIMESTAMP(3) NOT NULL,
locked_at TIMESTAMP(3) NOT NULL,
locked_by VARCHAR(255) NOT NULL
);
Configuración:
@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
public class SchedulingConfig {
@Bean
public LockProvider lockProvider(DataSource dataSource) {
return new JdbcTemplateLockProvider(JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(new JdbcTemplate(dataSource))
.usingDbTime()
.build());
}
}
Tarea protegida:
@Component
public class BackupScheduler {
@Scheduled(cron = "0 0 2 * * *")
@SchedulerLock(name = "backupNocturno", lockAtMostFor = "30m", lockAtLeastFor = "5m")
public void backupNocturno() {
log.info("Iniciando backup nocturno");
realizarBackup();
}
}
lockAtMostFor: tiempo máximo que el lock se mantiene (si la JVM crashea, el lock libera tras este tiempo).lockAtLeastFor: tiempo mínimo que el lock se mantiene aunque la tarea termine antes (evita re-ejecución si el reloj de una instancia va ligeramente adelantado).
Con esto, las 3 instancias intentan ejecutar a las 02:00. Solo una adquiere el lock. Las otras dos saltan inmediatamente.
Manejo de errores
Si una tarea programada lanza una excepción no se reintenta automáticamente y se pierde silenciosamente. Hay que envolver el código:
@Scheduled(fixedRate = 60000)
public void sincronizar() {
try {
sincronizarConSistemaExterno();
} catch (Exception e) {
log.error("Fallo en sincronización", e);
// Métricas: incrementar contador de errores
// Alerta: notificar a Slack o pagerduty si es crítico
}
}
Spring tiene @Retryable (Spring Retry) que se combina con @Scheduled:
@Scheduled(fixedRate = 60000)
@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 5000))
public void sincronizar() {
sincronizarConSistemaExterno();
}
@Recover
public void recover(Exception e) {
log.error("Sincronización falló tras 3 intentos", e);
}
Con esto, si la tarea falla, Spring Retry reintenta hasta 3 veces con 5 segundos entre intentos.
Métricas con Micrometer
Para monitorizar el comportamiento de las tareas:
@Component
@RequiredArgsConstructor
public class BackupScheduler {
private final MeterRegistry meterRegistry;
private Counter ejecuciones;
private Counter fallos;
private Timer duracion;
@PostConstruct
void initMetrics() {
ejecuciones = Counter.builder("scheduler.backup.runs").register(meterRegistry);
fallos = Counter.builder("scheduler.backup.failures").register(meterRegistry);
duracion = Timer.builder("scheduler.backup.duration").register(meterRegistry);
}
@Scheduled(cron = "0 0 2 * * *")
@SchedulerLock(name = "backupNocturno")
public void backupNocturno() {
var sample = Timer.start(meterRegistry);
try {
ejecuciones.increment();
realizarBackup();
} catch (Exception e) {
fallos.increment();
log.error("Fallo en backup", e);
} finally {
sample.stop(duracion);
}
}
}
En Prometheus/Grafana se ve la frecuencia de ejecución, la tasa de fallos y la duración de cada backup. Si una noche el backup tarda el doble, la métrica lo evidencia.
Testing de tareas programadas
Hay dos enfoques:
Llamada directa al método
El más simple. Probar la lógica sin esperar al scheduler:
@SpringBootTest
class BackupSchedulerTest {
@Autowired BackupScheduler scheduler;
@MockitoBean BackupService backupService;
@Test
void backupNocturno_llamaAlServicio() {
scheduler.backupNocturno();
verify(backupService).realizarBackup();
}
}
Test del cron expression
Validar que la expresión cron es la esperada:
@Test
void cronExpressionEsCorrecta() {
var method = BackupScheduler.class.getDeclaredMethod("backupNocturno");
var scheduled = method.getAnnotation(Scheduled.class);
assertThat(scheduled.cron()).isEqualTo("0 0 2 * * *");
var cron = CronExpression.parse(scheduled.cron());
var nextRun = cron.next(LocalDateTime.now());
// ...
}
Awaitility para tareas con fixedRate
Para tests de integración que esperan a la ejecución:
@SpringBootTest(properties = "app.scheduler.heartbeat-rate=100")
class HeartbeatTest {
@Autowired AtomicInteger heartbeatCount;
@Test
void heartbeatSeEjecuta() {
await().atMost(Duration.ofSeconds(5))
.untilAtomic(heartbeatCount, greaterThan(3));
}
}
Awaitility espera de forma no bloqueante hasta que la condición se cumple.
Buenas prácticas
- Configurar el cron desde properties, no hardcodearlo. Permite ajustar por entorno y desactivar con
"-". - Pool de threads dedicado si hay más de 2-3 tareas. El default mono-thread es trampa.
- ShedLock en multi-instancia para tareas no idempotentes.
- Manejo explícito de errores en cada tarea. Las excepciones no propagadas se pierden.
- Métricas con Micrometer en cualquier tarea de negocio. Es la única forma de saber que se ejecutó y cómo.
- Tareas pequeñas y rápidas. Si una tarea tarda más de unos minutos, conviene partirla en sub-tareas o moverla a un sistema dedicado (Quartz, Spring Batch).
- No combinar @Scheduled con @Async sin entender bien las implicaciones:
@Asyncusa otro pool y puede saltarse el@SchedulerLock.
Las tareas programadas son una herramienta básica que se vuelve traicionera en multi-instancia. Bien aplicadas con cron desde config, pool dedicado, ShedLock y métricas son robustas y trazables. Mal aplicadas, son la causa de duplicación de emails, backups corruptos por concurrencia y bugs intermitentes que solo aparecen en producción.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Spring Boot es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Spring Boot
Explora más contenido relacionado con Spring Boot y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Programar tareas periódicas con @Scheduled. Distinguir cron, fixedRate, fixedDelay e initialDelay. Configurar el TaskScheduler con thread pool dedicado. Garantizar ejecución única en entornos clusterizados con ShedLock. Probar tareas programadas en aislamiento.