Geplante Aufgaben mit @Scheduled in Spring Boot

1. Übersicht

Viele Anwendungen müssen Aufgaben nach Zeitplan ausführen: abgelaufene Bestellungen bereinigen, Bestand alle 5 Minuten synchronisieren, Berichte um 8 Uhr morgens senden. Spring unterstützt dies über die Annotation @Scheduled ohne zusätzliche Bibliotheken.

In diesem Artikel werden Sie Scheduling aktivieren und fixedRate, fixedDelay und cron unterscheiden. Sie werden auch die Laufzeiten in eine Konfigurationsdatei auslagern, den Thread-Pool anpassen und Tests für Jobs schreiben. Am Ende des Artikels finden Sie Fälle, in denen Sie @Scheduled nicht verwenden sollten.

2. Scheduling aktivieren

@Scheduled befindet sich bereits in spring-context. Sie benötigen nur das grundlegende Spring-Boot-Starter:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
</dependency>

Fügen Sie dann @EnableScheduling zu einer Konfigurationsklasse hinzu:

@SpringBootApplication
@EnableScheduling
public class ShopApplication {
    public static void main(String[] args) {
        SpringApplication.run(ShopApplication.class, args);
    }
}

Das Weglassen von @EnableScheduling ist der häufigste Fehler. Die Anwendung startet normal, ohne dass ein Fehler gemeldet wird, aber kein Job wird ausgeführt.

Hinweis: Die Methode mit @Scheduled darf keine Parameter haben. Sie sollte void zurückgeben, da der Rückgabewert ignoriert wird. Die Klasse, die sie enthält, muss ein Bean sein, z.B. mit @Component oder @Service.

3. Nach festen Zeitintervallen ausführen

3.1. fixedRate

fixedRate berechnet den Zyklus vom Start der vorherigen Ausführung:

@Component
public class InventoryJob {

    private static final Logger log = LoggerFactory.getLogger(InventoryJob.class);

    @Scheduled(fixedRate = 5000)
    public void syncInventory() {
        log.info("Bestand synchronisieren um {}", LocalTime.now().withNano(0));
    }
}

Die Standardeinheit ist Millisekunden. Der Job wird sofort nach dem Start der Anwendung ausgeführt und läuft dann alle 5 Sekunden:

INFO [   scheduling-1] InventoryJob : Bestand synchronisieren um 09:00:00
INFO [   scheduling-1] InventoryJob : Bestand synchronisieren um 09:00:05
INFO [   scheduling-1] InventoryJob : Bestand synchronisieren um 09:00:10

Wenn eine Ausführung länger als 5 Sekunden dauert, läuft die nächste nicht parallel. Sie startet, sobald die vorherige beendet ist, da standardmäßig nur ein Thread vorhanden ist (scheduling-1).

3.2. fixedDelay

fixedDelay berechnet den Zyklus vom Ende der vorherigen Ausführung. Diese Art eignet sich für Aufgaben, die nicht überlappen dürfen, wie externe API-Aufrufe oder die Verarbeitung von Datenlosen:

@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("E-Mails werden gesendet");
        Thread.sleep(2000); // Aufgabe dauert 2 Sekunden
        log.info("Fertig");
    }
}

Erklärung:

  • initialDelay = 10: Wartet 10 Sekunden nach dem Start vor der ersten Ausführung, um das Starten nicht zu verlangsamen.
  • timeUnit = TimeUnit.SECONDS: Ändert die Einheit für fixedDelay und initialDelay.
  • Jede Ausführung dauert 2 Sekunden, dann 3 Sekunden Wartezeit. Der Start zweier aufeinanderfolgender Ausführungen liegt 5 Sekunden auseinander:
09:00:10 E-Mails werden gesendet
09:00:12 Fertig
09:00:15 E-Mails werden gesendet
09:00:17 Fertig

4. Planung mit Cron

Wenn Sie zu einer bestimmten Zeit ausführen möchten, verwenden Sie cron. Der Cron-Ausdruck in Spring hat 6 Felder, eines mehr als Unix-Cron, das Sekunden am Anfang hat:

┌─ Sekunde (0-59)
│ ┌─ Minute (0-59)
│ │ ┌─ Stunde (0-23)
│ │ │ ┌─ Tag des Monats (1-31)
│ │ │ │ ┌─ Monat (1-12 oder JAN-DEC)
│ │ │ │ │ ┌─ Wochentag (0-7 oder MON-SUN)
* * * * * *

Beispiel: Verkaufsbericht um 8 Uhr morgens Montag bis Freitag nach Vietnam-Zeit senden:

@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();
    }
}

Das Attribut zone ist sehr wichtig, wenn der Server UTC verwendet, was bei Containern häufig der Fall ist. Wenn Sie es weglassen, kommt der Bericht um 15 Uhr statt um 8 Uhr an.

Einige häufig verwendete Ausdrücke:

0 */15 * * * *        alle 15 Minuten
0 0 2 * * *           täglich um 2 Uhr morgens
0 0 0 1 * *           um 0 Uhr am ersten des Monats
0 30 9 * * SAT,SUN    9:30 am Samstag und Sonntag
@daily                entspricht 0 0 0 * * *
@hourly               entspricht 0 0 * * * *

Hinweis: Wenn Sie einen 5-Feld-Ausdruck aus Linux-Crontab kopieren, z.B. 0 8 * * *, wird Spring beim Start eine IllegalStateException werfen. Sie müssen das Sekundenfeld am Anfang hinzufügen.

5. Laufzeiten in die Konfigurationsdatei auslagern

Das Hartcodieren von Laufzeiten zwingt Sie, jedes Mal neu zu erstellen, wenn Sie ändern möchten. Verwenden Sie Platzhalter. Für numerische Attribute verwenden Sie die ...String-Variante:

@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

Der Wert nach : ist der Standard, der verwendet wird, wenn die Eigenschaft nicht deklariert ist. Mit Cron schreiben Sie @Scheduled(cron = "${jobs.sales-report.cron}").

Um einen Cron-Job zu deaktivieren, ohne Code zu ändern, setzen Sie den Wert auf -:

jobs.sales-report.cron=-

Spring erkennt - (die Konstante Scheduled.CRON_DISABLED) und registriert diesen Job nicht. Dies ist praktisch, wenn Sie einen Job in der Entwicklungs- oder Testumgebung deaktivieren möchten.

6. Thread-Pool konfigurieren

Standardmäßig erstellt Spring Boot einen ThreadPoolTaskScheduler mit genau einem Thread. Alle Jobs verwenden denselben Thread. Wenn SalesReportJob 10 Minuten hängen bleibt, muss InventoryJob auch 10 Minuten warten.

Sie können die Pool-Größe erhöhen:

spring.task.scheduling.pool.size=4
spring.task.scheduling.thread-name-prefix=shop-job-

Nach der Erhöhung können verschiedene Jobs parallel laufen:

INFO [   shop-job-1] InventoryJob   : Bestand synchronisieren um 09:00:05
INFO [   shop-job-2] EmailQueueJob  : E-Mails werden gesendet

Ab Spring Boot 3.2 mit Java 21 können Sie Virtual Threads verwenden:

spring.threads.virtual.enabled=true

Dann verwendet Boot SimpleAsyncTaskScheduler, und jede Job-Ausführung läuft auf einem eigenen Virtual Thread. Das Attribut pool.size hat keine Auswirkung mehr.

Hinweis: Das Erhöhen der Pool-Größe bedeutet nicht, dass derselbe Job parallel mit sich selbst läuft. Jede @Scheduled-Methode wird weiterhin nacheinander ausgeführt. Wenn Sie möchten, dass die Ausführungen sich überlappen, müssen Sie @Async hinzufügen (mit @EnableAsync). Das sollten Sie fast nie tun, da es leicht zu doppelten Datenverarbeitungen führt.

7. Fehlerbehandlung

Wenn ein Job eine Exception wirft mit fixedRate oder fixedDelay, protokolliert Spring den Fehler und führt trotzdem beim nächsten Mal erneut aus. Cron ist gleich. Der Job stoppt nicht völlig, aber Fehler können leicht übersehen werden, wenn Sie die Logs nicht überwachen.

Der sicherste Weg ist, Fehler direkt im Job zu fangen und gezielt zu behandeln:

@Scheduled(fixedRate = 5, timeUnit = TimeUnit.MINUTES)
public void syncInventory() {
    try {
        inventoryClient.pullLatestStock();
    } catch (RestClientException ex) {
        log.warn("Lagerbestand konnte nicht abgerufen werden, wird beim nächsten Mal versucht: {}", ex.getMessage());
        alertService.notifyOps("inventory-sync-failed");
    }
}

Wenn Sie einen zentralen Fehlerbehandler für alle Jobs möchten, registrieren Sie einen ErrorHandler über den Boot-Customizer:

@Configuration
public class SchedulingConfig {

    @Bean
    ThreadPoolTaskSchedulerCustomizer schedulerErrorHandler(AlertService alertService) {
        return scheduler -> scheduler.setErrorHandler(ex ->
                alertService.notifyOps("scheduled-job-failed: " + ex.getMessage()));
    }
}

Dieser Customizer gilt nur für ThreadPoolTaskScheduler. Er hat keine Auswirkung, wenn Sie Virtual Threads aktivieren.

8. Tests für Jobs schreiben

Teilen Sie Tests in zwei Schichten auf. Die erste Schicht prüft die Geschäftslogik. Die zweite Schicht prüft, dass der Scheduler den Job tatsächlich aufruft.

Die Geschäftslogik sollte wie eine normale Methode getestet werden, direkt aufgerufen und ohne Wartezeiten:

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);
    }
}

Um das Scheduling zu testen, verwenden Sie Awaitility. Verwenden Sie nicht Thread.sleep, da der Test langsam und anfällig wird. Die Awaitility-Version wird von Spring Boot verwaltet, daher müssen Sie keine Version angeben:

<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));
    }
}

Dieser Test senkt den Zyklus auf 100 ms über properties herab. Dann wartet er maximal 2 Sekunden, bis der Job mindestens 3 Mal läuft. Awaitility überprüft wiederholt und gibt sofort zurück, wenn die Bedingung erfüllt ist, daher ist der Test normalerweise in etwa 300 ms abgeschlossen. @MockitoBean existiert seit Spring Boot 3.4. Mit älteren Versionen verwenden Sie @MockBean.

In anderen Integrationstests sollten Sie das Scheduling deaktivieren, damit Jobs nicht im Hintergrund laufen und Daten verfälschen. Es gibt zwei Möglichkeiten. Die erste ist, den Cron auf - zu setzen. Die zweite ist, @EnableScheduling in eine separate bedingte Konfigurationsklasse zu verschieben:

@Configuration
@EnableScheduling
@ConditionalOnProperty(name = "jobs.enabled", havingValue = "true", matchIfMissing = true)
public class SchedulingConfig { }

Der Test muss dann nur jobs.enabled=false deklarieren.

9. Wann Sie @Scheduled NICHT verwenden sollten

@Scheduled ist einfach, hat aber klare Grenzen:

  • Mehrere Instanzen ausführen. Mit 3 Kubernetes-Pods läuft der Job 3 Mal gleichzeitig, und der Bericht wird 3 Mal gesendet. Sie benötigen einen verteilten Lock, z.B. die ShedLock-Bibliothek, die eine Tabelle in der Datenbank verwendet, oder trennen Sie den Job in einen einzelnen Service.
  • Jobestand speichern. Wenn die Anwendung genau um 8 Uhr ausfällt, geht diese Ausführung verloren und wird nicht nachgeholt. Wenn Sie Misfire-Handling, Laufverlauf oder dynamisches Scheduling zur Laufzeit benötigen, verwenden Sie Quartz.
  • Sehr große oder lange Aufgaben. Verwenden Sie stattdessen Spring Batch oder ein Queue-System für Wiederholung, Batch-Verarbeitung und Fortschrittsverfolgung.
  • Benutzer erstellen Zeitpläne, z.B. „Erinnern Sie mich um 15:00”. Annotationen funktionieren nur mit festen Zeitplänen beim Start. Dynamische Zeitpläne sollten in einer Datenbank gespeichert und mit TaskScheduler programmgesteuert verwendet werden, oder verwenden Sie Quartz.

Weitere häufige Fehler:

  • Geben Sie @Scheduled auf einer private-Methode oder einer Methode einer Nicht-Bean-Klasse an.
  • Vergessen Sie zone, was zu einem verschobenen Jobeintrag auf UTC-Servern führt.
  • Das Platzieren von @Scheduled und @Transactional auf derselben Methode kann funktionieren. Das Trennen der Logik in einen Service mit @Transactional ist jedoch leichter zu testen und zu lesen.

10. Fazit

In diesem Artikel haben Sie:

  • Scheduling mit @EnableScheduling aktiviert;
  • fixedRate, berechnet vom Start, von fixedDelay, berechnet vom Ende, unterschieden;
  • 6-Feld-Cron-Ausdrücke mit Zeitzone geschrieben;
  • Laufzeiten in eine Konfigurationsdatei ausgelagert und Jobs mit - deaktiviert;
  • die Thread-Pool-Größe erhöht oder Virtual Threads verwendet, damit sich Jobs nicht gegenseitig blockieren;
  • Exceptions behandelt und mit Awaitility Tests geschrieben.

Für eine Anwendung, die eine Instanz mit festen Zeitplänen ausführt, ist @Scheduled ausreichend. Wenn das System auf mehrere Instanzen oder höhere Zuverlässigkeit anwächst, ziehen Sie ShedLock oder Quartz in Betracht, bevor Sie auf Produktionsprobleme stoßen.

spring-bootschedulingcronjava