@Scheduled w Spring Boot – jak robić zadania cykliczne

You are currently viewing @Scheduled w Spring Boot – jak robić zadania cykliczne

Wstęp

Masz aplikację w Spring Boot i potrzebujesz, żeby coś wykonywało się cyklicznie? Synchronizacja z zewnętrznym API co 5 minut, czyszczenie wygasłych tokenów raz na godzinę, raport generowany o północy, przypomnienia mailowe codziennie o 8:00 – sytuacje, w których takie zadania są w sam raz. W Springu nie musisz do tego sięgać po Quartza ani pisać własnego schedulera. Wystarczy adnotacja @Scheduled w Spring Boot i kilka linijek konfiguracji.

W tym Szybkim Strzale pokażę Ci jak włączyć harmonogram w aplikacji, omówię trzy tryby pracy (fixedRate, fixedDelay, cron) z gotowym kodem i wyjaśnię najczęstsze pułapki, które potrafią mocno uprzykrzyć życie na produkcji. Wszystko na Java 25 i Spring Boot 4.

Jak włączyć harmonogram (@Scheduled) w Spring Boot

Mechanizm harmonogramu jest domyślnie wyłączony. Żeby go uruchomić, dodajesz adnotację @EnableScheduling na klasie konfiguracyjnej albo bezpośrednio na klasie głównej aplikacji.

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

Tyle. Od tego momentu Spring skanuje bean’y w aplikacji i wszystkie metody oznaczone @Scheduled zaczyna wywoływać zgodnie z ustawieniami.

Sama metoda, którą chcesz harmonogramować, musi spełnić kilka warunków:

  • znajdować się w bean’ie zarządzanym przez Springa (@Component@Service itp.),
  • być publiczna,
  • nie przyjmować argumentów i zwracać void.

Najprostszy przykład – zadanie uruchamiane co 5 sekund:

@Service
public class HeartbeatTask {

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

    @Scheduled(fixedRate = 5000)
    public void ping() {
        log.info("Aplikacja żyje - {}", Instant.now());
    }
}

Po starcie aplikacji w logach co 5 sekund zobaczysz wpis. Pierwsze uruchomienie nastąpi od razu po załadowaniu kontekstu Springa.

Jedna rzecz, o której warto pamiętać od początku: domyślny TaskScheduler w Springu działa na jednym wątku. To znaczy, że wszystkie Twoje scheduled metody dzielą jeden wątek roboczy. Jeśli któraś trwa długo, blokuje pozostałe. Wrócę do tego w sekcji o pułapkach.

Tryby pracy @Scheduled

@Scheduled daje Ci trzy sposoby określenia kiedy metoda ma się uruchamiać. Każdy ma inny scenariusz, w którym ma sens.

fixedRate – stały interwał od startu

@Scheduled(fixedRate = 60_000)
public void syncData() {
    // pobranie danych z API
}

Tryb fixedRate odlicza czas od początku poprzedniego uruchomienia. Jeśli ustawisz 60 sekund, Spring spróbuje wystartować metodę co 60 sekund, niezależnie od tego, czy poprzednie wywołanie się skończyło.

Co się dzieje, gdy zadanie trwa dłużej niż interwał? Domyślnie kolejne wywołania czekają w kolejce – Spring używa jednego wątku, więc nie odpali drugiej instancji równolegle. Jeśli chcesz prawdziwego równoległego wykonywania, musisz zwiększyć pool (sekcja „Pułapki”).

fixedDelay – odstęp od końca poprzedniego

@Scheduled(fixedDelay = 30_000)
public void cleanupTempFiles() {
    // czyszczenie plików tymczasowych
}

fixedDelay startuje odliczanie dopiero po zakończeniu poprzedniego wykonania. Jeśli zadanie trwa 10 sekund, a delay to 30 sekund, kolejne uruchomienie nastąpi 40 sekund po starcie poprzedniego.

Z mojego doświadczenia to bezpieczniejszy domyślny wybór dla większości zadań w tle. Nieprzewidywalne czasy wykonania (zewnętrzne API, baza pod obciążeniem) nie tworzą kolejki, a system nie próbuje gonić sam siebie.

cron – wyrażenie czasowe

@Scheduled(cron = "0 0 2 * * *", zone = "Europe/Warsaw")
public void generateDailyReport() {
    // raport o 2:00 czasu polskiego
}

cron daje Ci pełną kontrolę nad harmonogramem. W Springu używa się sześciu pól (od lewej): sekundy, minuty, godziny, dzień miesiąca, miesiąc, dzień tygodnia.

Kilka praktycznych przykładów:

  • "0 0 2 * * *" – codziennie o 2:00
  • "0 */15 * * * *" – co 15 minut
  • "0 0 9 * * MON-FRI" – w dni robocze o 9:00
  • "0 0 0 1 * *" – pierwszego dnia każdego miesiąca o północy

Atrybut zone jest ważny, jeśli aplikacja chodzi w UTC, a Ty chcesz raport o polskiej drugiej rano. Bez niego cron interpretuje czas w strefie systemowej kontenera – i potrafi to zaskoczyć po przeniesieniu deploymentu z lokalnej maszyny na klaster.

Pod pisanie crona istnieje wiele stron, które pomagają w jego pisaniu np. crontab. Natomiast w dobie AI wystarczy napisać prostego prompta, gdzie opiszemy jaki cykl nas interesuje, a AI wyświetli nam gotowy cron do wklejenia.

initialDelay – pierwsze uruchomienie z opóźnieniem

Możesz zdecydować, że zadanie ma poczekać X ms po starcie aplikacji, zanim ruszy:

@Scheduled(fixedDelay = 60_000, initialDelay = 30_000)
public void warmupCache() {
    // start 30s po uruchomieniu, potem co 60s
}

Przydatne, gdy chcesz dać aplikacji chwilę na rozgrzewkę – nawiązanie połączeń z bazą, załadowanie cache’a albo zaczekanie aż wystartują wszystkie zależne serwisy.

Kiedy wybrać co?

fixedRate sprawdza się przy krótkich, przewidywalnych zadaniach, gdy zależy Ci na regularnym rytmie. Klasyczne przypadki to heartbeat, zbieranie metryk czy ping do monitoringu – czyli zadania, które wykonują się szybko i mają chodzić co X sekund jak w zegarku.

fixedDelay wybierz, gdy czas wykonania jest nieprzewidywalny i nie chcesz, żeby system gonił własny ogon. To dobry wybór dla synchronizacji z zewnętrznym API, czyszczenia plików tymczasowych albo prostego mechanizmu retry. Każde uruchomienie poczeka na poprzednie, więc kolejka się nie spiętrzy.

cron zostaw na sytuacje, w których ważna jest konkretna godzina albo dzień tygodnia: raport dobowy o 2:00, nocne czyszczenie bazy danych, mailing o 8:00 rano w dni robocze. Wszędzie tam, gdzie biznes mówi „ma się odpalić o…” zamiast „co jakiś czas”.

Najczęstsze pułapki w praktyce

Jeden wątek dla wszystkich zadań

Jak wspomniałem – domyślny TaskScheduler ma pool size = 1. Jeśli masz pięć metod @Scheduled i jedna z nich blokuje się na 30 sekund (bo np. zewnętrzne API się zacięło), pozostałe cztery czekają. Rozwiązanie:

@Configuration
public class SchedulerConfig implements SchedulingConfigurer {

    @Override
    public void configureTasks(ScheduledTaskRegistrar registrar) {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(10);
        scheduler.setThreadNamePrefix("scheduled-task-");
        scheduler.initialize();
        registrar.setTaskScheduler(scheduler);
    }
}

Albo prościej w application.yml:

spring:
  task:
    scheduling:
      pool:
        size: 10

Wiele instancji aplikacji = wielokrotne uruchomienie

To pułapka, na którą wpada każdy raz w karierze. Aplikacja chodzi w Kubernetesie z trzema podami, masz raport o północy – i nagle zamiast jednego raportu dostajesz trzy. Każdy pod ma własnego @Scheduled i każdy odpala go niezależnie.

Najprostsze rozwiązanie to ShedLock – biblioteka, która tworzy lock na bazie (Postgres, Redis itp.) i pozwala odpalić zadanie tylko jednej instancji w danym momencie.

@Scheduled(cron = "0 0 2 * * *")
@SchedulerLock(name = "dailyReport", lockAtMostFor = "10m")
public void generateDailyReport() {
    // odpali się tylko w jednym podzie
}

Alternatyw jest zresztą więcej i każda ma swoje miejsce. Quartz w trybie klastrowym to klasyk – wszystkie instancje współdzielą tabele Quartza w bazie i koordynują się przez nią. Sprawdza się, gdy potrzebujesz bogatego API harmonogramowania: priorytety, listenery, persistowane taski. Drugą opcją jest dedykowany worker – osobny deployment z replicas: 1, który zajmuje się tylko zadaniami cyklicznymi, a reszta klastra obsługuje ruch HTTP.

Coraz częściej spotykam podejście, w którym scheduling jest w ogóle wynoszony poza aplikację. Kubernetes CronJob o zadanej porze odpala nowego poda z Twoim taskiem (np. ten sam obraz aplikacji uruchomiony z innym profilem albo z osobnym entrypointem). Aplikacja Spring Boot w ogóle nie musi wiedzieć, że istnieje harmonogram – minus to cold start przy każdym uruchomieniu. Podobny pomysł realizują zewnętrzne schedulery chmurowe jak AWS EventBridge Scheduler, GCP Cloud Scheduler czy Azure Logic Apps – wywołują endpoint Twojej aplikacji o zadanej porze, a Ty zarządzasz cronami w jednym miejscu w panelu chmury.

Jeszcze inna ścieżka to cron na bazie danych. Rozwiązania jak pg_cron w PostgreSQL pozwalają zaplanować zapytania SQL bezpośrednio na bazie. Mają sens dla operacji typu czyszczenie tabel, nocne agregaty, reindeksowanie – czyli wszędzie tam, gdzie task to w zasadzie kilka linii SQL i nie potrzebujesz logiki biznesowej w Javie.

Z mojego doświadczenia: dla małych i średnich projektów wystarczy @Scheduled plus ShedLock. Gdy harmonogramów robi się więcej i stają się częścią procesów biznesowych, przenosisz je na zewnątrz – najczęściej do Kubernetes CronJob albo schedulerów chmurowych. Masz wtedy jedno miejsce kontroli i historię wykonań poza aplikacją.

Wyjątek nie zatrzymuje schedulera

Spring nie przestaje wywoływać metody po tym, jak rzucisz w niej wyjątek – kolejne uruchomienie nastąpi normalnie. Ale niezalogowany wyjątek znika i przez tydzień nie wiesz, że nocny job leci na czerwono.

@Scheduled(fixedDelay = 60_000)
public void syncData() {
    try {
        externalApi.fetch();
    } catch (Exception e) {
        log.error("Sync failed", e);
    }
}

Brak monitoringu

Schedulowane zadania mają nieprzyjemną właściwość: jak coś przestanie działać, dowiadujesz się o tym w najgorszym możliwym momencie – od klienta, który nie dostał faktury, albo od działu finansów, który czeka na nocny raport. Bez monitoringu cisza w schedulerze brzmi tak samo jak prawidłowa praca.

Pierwszy krok to wbudowany endpoint Actuator /actuator/scheduledtasks – pokazuje listę wszystkich schedulowanych metod w aplikacji wraz z konfiguracją (cron, fixedRate, fixedDelay, initialDelay). Wystarczy dodać spring-boot-starter-actuator i odsłonić endpoint w application.yml:

management:
  endpoints:
    web:
      exposure:
        include: health, scheduledtasks

Drugi krok to własne metryki przez Micrometer. Najprostsza wersja: licznik uruchomień, licznik błędów i histogram czasu wykonania:

@Service
public class ReportTask {

    private final MeterRegistry meterRegistry;
    private final ReportService reportService;

    @Scheduled(cron = "0 0 2 * * *")
    public void generateReport() {
        Timer.Sample sample = Timer.start(meterRegistry);
        try {
            reportService.generate();
            meterRegistry.counter("scheduled.report.success").increment();
        } catch (Exception e) {
            meterRegistry.counter("scheduled.report.failure").increment();
            log.error("Report failed", e);
        } finally {
            sample.stop(meterRegistry.timer("scheduled.report.duration"));
        }
    }
}

Te metryki podpinasz pod Prometheus + Grafanę i dostajesz alert, gdy scheduled.report.failure rośnie albo gdy scheduled.report.success przestaje rosnąć w spodziewanym tempie. Ten drugi alert jest często ważniejszy – brak metryki to sygnał, że task się w ogóle nie odpalił, co potrafi być najgorszym scenariuszem (bo wyjątku też nie zobaczysz w logach).

Hardkodowane wartości

Trzymanie cron = "0 0 2 * * *" bezpośrednio w kodzie to prosta droga do wdrażania nowej wersji za każdym razem, gdy biznes chce przesunąć raport o godzinę. Lepiej:

@Scheduled(cron = "${app.scheduler.report-cron}", zone = "Europe/Warsaw")
public void generateDailyReport() {
    // ...
}

I w application.yml:

app:
  scheduler:
    report-cron: "0 0 2 * * *"

Teraz wystarczy zmienić konfigurację, bez budowania nowej wersji aplikacji.

Podsumowanie

@Scheduled w Spring Boot 4 to najszybszy sposób na cykliczne zadania w aplikacji – @EnableScheduling na klasie głównej, adnotacja na metodzie i jedziesz. Domyślnie sięgaj po fixedDelay, gdy zadanie ma nieprzewidywalny czas wykonania, po fixedRate przy stabilnych, krótkich zadaniach typu heartbeat, a po cron – gdy zależy Ci na konkretnej godzinie albo dniu tygodnia.

Przed wdrożeniem na produkcję pamiętaj o paru rzeczach: zwiększ pool wątków, jeśli masz więcej niż jedno zadanie; w środowisku z wieloma instancjami sięgnij po ShedLocka albo Quartza; zawsze loguj wyjątki w schedulowanych metodach; wyciągnij wartości cron do konfiguracji.

A jakie cykliczne zadania robisz w swoich projektach? Dajesz radę z domyślnym @Scheduled, czy musiałeś sięgnąć po Quartza w klastrze? Daj znać w komentarzu.

Subskrybuj
Powiadom o
guest
0 komentarzy
Najstarsze
Najnowsze Najwięcej głosów
Opinie w linii
Zobacz wszystkie komentarze