Gérer les tâches planifiées avec @Scheduled dans Spring Boot
1. Vue d’ensemble
De nombreuses applications doivent exécuter des tâches selon un calendrier : nettoyer les commandes expirées, synchroniser l’inventaire toutes les 5 minutes, envoyer un rapport à 8 heures du matin. Spring supporte cela via l’annotation @Scheduled sans avoir besoin de bibliothèques externes.
Dans cet article, vous allez activer la planification et distinguer fixedRate, fixedDelay et cron. Vous allez aussi externaliser le calendrier dans un fichier de configuration, ajuster le thread pool et écrire des tests pour vos jobs. À la fin, vous verrez les cas où il ne faut pas utiliser @Scheduled.
2. Activer la planification
@Scheduled fait partie de spring-context. Vous n’avez besoin que du starter de base de Spring Boot :
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
Ensuite, ajoutez @EnableScheduling à une classe de configuration :
@SpringBootApplication
@EnableScheduling
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
Oublier @EnableScheduling est l’erreur la plus courante. L’application démarre normalement sans signaler d’erreur, mais aucun job n’est exécuté.
Note : La méthode marquée par
@Scheduleddoit n’avoir aucun paramètre. Elle doit retournervoid, car la valeur de retour est ignorée. La classe qui la contient doit être un bean, par exemple avec@Componentou@Service.
3. Exécution à intervalles de temps fixes
3.1. fixedRate
fixedRate calcule le cycle à partir du moment du démarrage de la précédente exécution :
@Component
public class InventoryJob {
private static final Logger log = LoggerFactory.getLogger(InventoryJob.class);
@Scheduled(fixedRate = 5000)
public void syncInventory() {
log.info("Synchronisation de l'inventaire à {}", LocalTime.now().withNano(0));
}
}
L’unité par défaut est la milliseconde. Le job s’exécute dès que l’application est prête, puis toutes les 5 secondes :
INFO [ scheduling-1] InventoryJob : Synchronisation de l'inventaire à 09:00:00
INFO [ scheduling-1] InventoryJob : Synchronisation de l'inventaire à 09:00:05
INFO [ scheduling-1] InventoryJob : Synchronisation de l'inventaire à 09:00:10
Si une exécution prend plus de 5 secondes, l’exécution suivante ne s’exécute pas en parallèle. Elle démarre dès que la précédente est terminée, car par défaut il n’y a qu’un seul thread (scheduling-1).
3.2. fixedDelay
fixedDelay calcule le cycle à partir du moment de la fin de l’exécution précédente. Ce type est adapté aux tâches qui ne doivent pas se chevaucher, comme appeler une API externe ou traiter des données par lots :
@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("Début d'envoi des emails");
Thread.sleep(2000); // simule un travail qui prend 2 secondes
log.info("Envoi terminé");
}
}
Explications :
initialDelay = 10: attend 10 secondes après le démarrage avant la première exécution, pour éviter de ralentir l’initialisation.timeUnit = TimeUnit.SECONDS: change l’unité pourfixedDelayetinitialDelay.- Chaque exécution prend 2 secondes, puis on attend 3 secondes. Les deux exécutions consécutives commencent à 5 secondes d’intervalle :
09:00:10 Début d'envoi des emails
09:00:12 Envoi terminé
09:00:15 Début d'envoi des emails
09:00:17 Envoi terminé
4. Planification avec cron
Quand vous avez besoin d’exécuter à des heures précises, utilisez cron. L’expression cron de Spring a 6 champs, un de plus que le cron Unix (le champ des secondes en tête) :
┌─ secondes (0-59)
│ ┌─ minutes (0-59)
│ │ ┌─ heures (0-23)
│ │ │ ┌─ jour du mois (1-31)
│ │ │ │ ┌─ mois (1-12 ou JAN-DEC)
│ │ │ │ │ ┌─ jour de la semaine (0-7 ou MON-SUN)
* * * * * *
Par exemple, envoyer un rapport de ventes à 8 heures du matin, du lundi au vendredi, selon l’heure du 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();
}
}
L’attribut zone est très important quand le serveur fonctionne en UTC, ce qui est courant avec les conteneurs. Si vous l’omettez, le rapport arrivera à 15 heures au lieu de 8 heures.
Quelques expressions cron courantes :
0 */15 * * * * toutes les 15 minutes
0 0 2 * * * 2 heures du matin chaque jour
0 0 0 1 * * minuit le premier jour de chaque mois
0 30 9 * * SAT,SUN 9h30 samedi et dimanche
@daily équivalent à 0 0 0 * * *
@hourly équivalent à 0 0 * * * *
Note : Si vous copiez une expression 5 champs depuis crontab Linux, par exemple
0 8 * * *, Spring lèvera uneIllegalStateExceptionau démarrage. Vous devez ajouter le champ des secondes en début.
5. Externaliser le calendrier dans un fichier de configuration
Coder le calendrier en dur vous force à recompiler chaque fois que vous le modifiez. Utilisez des placeholders. Pour les propriétés numériques, utilisez 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
La valeur après : est la valeur par défaut, utilisée si la propriété n’est pas définie. Pour cron, vous écrivez @Scheduled(cron = "${jobs.sales-report.cron}").
Pour désactiver un job cron sans modifier le code, définissez la valeur à - :
jobs.sales-report.cron=-
Spring reconnaît - (la constante Scheduled.CRON_DISABLED) et n’enregistre pas ce job. C’est pratique quand vous voulez désactiver un job en environnement de développement ou de test.
6. Configuration du thread pool
Par défaut, Spring Boot crée un ThreadPoolTaskScheduler avec exactement un thread. Tous les jobs partagent ce thread. Si SalesReportJob se bloque pendant 10 minutes, InventoryJob doit aussi attendre 10 minutes.
Vous pouvez augmenter la taille du pool :
spring.task.scheduling.pool.size=4
spring.task.scheduling.thread-name-prefix=shop-job-
Après augmentation, les différents jobs peuvent s’exécuter en parallèle :
INFO [ shop-job-1] InventoryJob : Synchronisation de l'inventaire à 09:00:05
INFO [ shop-job-2] EmailQueueJob : Début d'envoi des emails
À partir de Spring Boot 3.2, avec Java 21, vous pouvez utiliser des virtual threads :
spring.threads.virtual.enabled=true
Boot utilisera alors SimpleAsyncTaskScheduler, et chaque exécution d’un job s’exécute sur un virtual thread distinct. L’attribut pool.size n’a plus d’effet.
Note : Augmenter la taille du pool n’empêche pas un même job de s’exécuter en se chevauchant lui-même. Chaque méthode
@Scheduleds’exécute toujours séquentiellement. Pour que les exécutions se chevauchent, vous devez ajouter@Async(avec@EnableAsync). C’est rarement souhaitable, car cela risque de créer des incohérences de données.
7. Gestion des exceptions
Quand un job lève une exception avec fixedRate ou fixedDelay, Spring enregistre l’erreur puis réexécute toujours le job à la prochaine occurrence. Cron fonctionne pareil. Le job ne s’arrête pas, mais l’erreur risque de passer inaperçue si vous ne consultez pas les logs.
La façon la plus sûre est de capturer l’erreur directement dans le job et la gérer intentionnellement :
@Scheduled(fixedRate = 5, timeUnit = TimeUnit.MINUTES)
public void syncInventory() {
try {
inventoryClient.pullLatestStock();
} catch (RestClientException ex) {
log.warn("Impossible d'atteindre l'inventaire, nouvel essai à la prochaine exécution : {}", ex.getMessage());
alertService.notifyOps("inventory-sync-failed");
}
}
Si vous voulez un point de traitement centralisé pour tous les jobs, enregistrez un ErrorHandler via un customizer Boot :
@Configuration
public class SchedulingConfig {
@Bean
ThreadPoolTaskSchedulerCustomizer schedulerErrorHandler(AlertService alertService) {
return scheduler -> scheduler.setErrorHandler(ex ->
alertService.notifyOps("scheduled-job-failed: " + ex.getMessage()));
}
}
Ce customizer s’applique seulement à ThreadPoolTaskScheduler. Il n’a aucun effet si vous activez les virtual threads.
8. Écrire des tests pour un job
Divisez les tests en deux niveaux. Le premier niveau teste la logique métier. Le deuxième niveau teste que le scheduler appelle réellement le job.
La logique métier doit être testée comme une méthode ordinaire, en l’appelant directement et sans attendre :
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);
}
}
Pour vérifier la planification, utilisez Awaitility. N’utilisez pas Thread.sleep, car le test sera lent et instable. Spring Boot gère déjà la version d’Awaitility, vous n’avez pas besoin de spécifier la version :
<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));
}
}
Ce test réduit le cycle à 100 ms via properties. Ensuite, il attend au maximum 2 secondes que le job s’exécute au moins 3 fois. Awaitility vérifie en boucle et revient dès que la condition est satisfaite, donc le test se termine généralement en environ 300 ms. @MockitoBean existe depuis Spring Boot 3.4. Avec des versions antérieures, utilisez @MockBean.
Dans d’autres tests d’intégration, vous devriez désactiver la planification pour empêcher les jobs de s’exécuter en arrière-plan et de corrompre les données. Il y a deux façons. La première est de mettre cron à -. La deuxième est de déplacer @EnableScheduling vers une classe de configuration distincte avec une condition :
@Configuration
@EnableScheduling
@ConditionalOnProperty(name = "jobs.enabled", havingValue = "true", matchIfMissing = true)
public class SchedulingConfig { }
Vos tests n’ont alors besoin que de jobs.enabled=false.
9. Quand NE PAS utiliser @Scheduled
@Scheduled est simple, mais il a des limites claires :
- Exécution sur plusieurs instances. Avec 3 pods Kubernetes, le job s’exécute 3 fois en parallèle, et le rapport est envoyé 3 fois. Vous avez besoin d’un verrou distribué, par exemple la bibliothèque ShedLock qui utilise une table en base de données, ou isoler le job dans un service unique.
- Besoin de conserver l’état du job. Si l’application s’arrête à 8 heures exactement, cette exécution sera perdue et ne sera pas rattrapée. Si vous avez besoin de gestion des misfires, d’un historique d’exécutions ou d’une planification dynamique au runtime, utilisez Quartz.
- Job très lourd ou s’exécutant pendant des heures. Préférez Spring Batch ou un système de queue pour bénéficier des rejeux, du traitement par lots et du suivi de la progression.
- Calendrier créé par l’utilisateur, par exemple “rappelez-moi à 15h00”. Les annotations ne sont adaptées qu’aux calendriers fixes au démarrage. Un calendrier dynamique doit être stocké en base de données et géré via
TaskScheduleren programmation, ou utilisez Quartz.
Quelques erreurs courantes à noter :
- Placer
@Scheduledsur une méthodeprivateou sur une méthode d’une classe qui n’est pas un bean. - Oublier
zone, ce qui décale le job sur un serveur UTC. - Placer
@Scheduledet@Transactionalsur la même méthode peut fonctionner. Cependant, séparer la logique vers un service avec@Transactionalsera plus facile à tester et à lire.
10. Conclusion
Dans cet article, vous avez :
- activé la planification avec
@EnableScheduling; - distingué
fixedRate, calculé à partir du début, defixedDelay, calculé à partir de la fin ; - écrit une expression cron 6 champs avec fuseau horaire ;
- externalisé le calendrier dans un fichier de configuration et désactivé un job avec
-; - augmenté le thread pool ou utilisé les virtual threads pour éviter que les jobs se bloquent mutuellement ;
- géré les exceptions et écrit des tests avec Awaitility.
Pour une application exécutée sur une seule instance avec un calendrier fixe, @Scheduled est suffisant. Quand votre système se développe sur plusieurs instances ou a besoin d’une fiabilité plus élevée, envisagez ShedLock ou Quartz avant de rencontrer des problèmes en production.