Czy ta sama metoda @Scheduled może bezpiecznie działać na trzech replikach Spring Boota, czy każda instancja wykona ją osobno? Jeśli zadanie wysyła raport, pobiera statusy z zewnętrznego API albo aktualizuje rekordy w bazie, odpowiedź decyduje o duplikatach, wyścigach i trudnych do odtworzenia logach. ShedLock rozwiązuje ten konkretny problem: koordynuje uruchomienie zadania w klastrze przez współdzieloną blokadę, bez zamiany całego harmonogramu na osobny silnik workflow. [2]
Dlaczego @Scheduled nie wystarcza w aplikacji uruchomionej w klastrze
Ten sam harmonogram, wiele instancji, wiele wykonań
@Scheduled działa lokalnie w JVM, która uruchomiła aplikację. Spring tworzy scheduler w każdej replice niezależnie, więc identyczny cron nie jest współdzieloną kolejką. Trzy pody w Kubernetesie, trzy procesy na maszynach wirtualnych albo dwie instancje za load balancerem to trzy niezależne harmonogramy. Każdy z nich odpali metodę, gdy nadejdzie kolejny tick.
Skutek widać szybko przy zadaniach z efektem ubocznym. Trzy repliki wysyłają ten sam raport do skrzynki zespołu, trzy razy odpytują API statusów zamówień i trzy razy próbują oznaczyć te same rekordy jako przetworzone. Równoległość wewnątrz jednej aplikacji to inny problem: pula wątków schedulera decyduje, czy kilka metod w tym samym procesie może iść naraz. Duplikacja między węzłami klastra pozostaje nawet wtedy, gdy w jednej JVM działa tylko jeden wątek schedulera.
Rozróżnienie jest praktyczne przy diagnozie. Jeśli logi z jednego hosta pokazują nakładające się wywołania tej samej metody, winny może być zbyt krótki interwał albo brak @SchedulerLock wewnątrz procesu. Jeśli ten sam komunikat pojawia się równolegle na różnych hostach o tej samej minucie, harmonogram jest poprawny lokalnie, ale brakuje koordynacji klastra.
Skutki braku koordynacji
Powtarzalne skutki biznesowe bolą bardziej niż sam fakt podwójnego startu. Duplikują się e-maile, faktury, powiadomienia push i wpisy w systemach zewnętrznych, które nie mają własnej deduplikacji. Dwa procesy mogą też ścigać się o ten sam wiersz: jeden ustawia status „wysłane”, drugi nadpisuje go starszym odczytem albo tworzy drugi dokument rozliczeniowy.
Obciążenie bazy rośnie liniowo z liczbą replik, a nie z realną ilością pracy. Trzy identyczne zapytania o „nieprzetworzone” rekordy, trzy transakcje i trzy zestawy locków na tych samych wierszach wydłużają czas odpowiedzi także dla ruchu użytkownika. Logi stają się niejednoznaczne: ten sam identyfikator korelacji pojawia się na różnych instancjach, a kolejność zdarzeń zależy od tego, która replika szybciej dostała odpowiedź z sieci.
Ustawienie spring.task.scheduling.pool.size=1 albo własnego TaskScheduler z jednym wątkiem porządkuje tylko kolejkę wewnątrz procesu. Nie przenosi informacji „to zadanie już ktoś wykonuje” do pozostałych węzłów. Do tego potrzebny jest magazyn widoczny dla całego klastra albo scheduler z własną tabelą triggerów, taki jak Quartz w trybie klastrowym.
ShedLock — mechanizm blokady rozproszonej dla zadań Springa
Jak działa blokada oparta na współdzielonym magazynie
ShedLock zapisuje stan blokady w źródle, do którego mają dostęp wszystkie instancje. Najczęściej jest to relacyjna baza, z której aplikacja i tak korzysta. Przy wywołaniu metody oznaczonej @SchedulerLock provider próbuje wstawić albo zaktualizować wiersz blokady w sposób atomowy. Instancja, która pierwsza zdobędzie lock, uruchamia metodę. Pozostałe kończą to wywołanie od razu — nie czekają w kolejce na zwolnienie blokady.
Tabela JDBC trzyma kilka pól, które warto umieć czytać podczas incydentu. name to stabilny identyfikator zadania, nie nazwa metody Javy. lock_until mówi, do kiedy blokada jest ważna. locked_at pokazuje moment jej założenia, a locked_by zwykle nazwę hosta albo identyfikator instancji, co ułatwia powiązanie wiersza z podem. Po poprawnym zakończeniu metody ShedLock skraca lock_until do „teraz”, chyba że ustawiono minimalny czas trzymania blokady.
Brak oczekiwania jest cechą, nie wadą, przy klasycznym cronie. Jeśli trzy węzły budzą się o 02:00, jeden wykonuje synchronizację, dwa logują pominięcie. Następna szansa pojawi się zgodnie z harmonogramem, a nie jako zaległa kolejka trzech identycznych przebiegów.
Co ShedLock robi, a czego nie gwarantuje
Narzędzie ogranicza uruchomienie danej metody do jednej instancji w oknie ważności locka. Nie zastępuje kolejki, silnika workflow ani transakcji rozproszonej. Nie pilnuje też, że skutek biznesowy powstanie dokładnie raz. Jeśli proces padnie w połowie wysyłki faktur, lock wygaśnie, a kolejna instancja może wznowić pracę na tym samym zbiorze — o ile zadanie samo nie zapisze, które pozycje już wyszły.
Stąd idempotencja (bezpieczne ponowne wykonanie) zostaje wymaganiem aplikacji, nie biblioteki. Dobre zadanie sprawdza stan rekordu przed wysyłką, używa klucza idempotencji w systemie zewnętrznym albo zapisuje checkpoint. ShedLock zmniejsza liczbę równoległych startów, ale nie usuwa potrzeby takiego zabezpieczenia przy awarii w trakcie pracy.
Jeśli chcesz pogłębić ten wątek, sprawdź też: Wykrywanie i zapobieganie atakom brute force na logowanie w Spring Security.
Granica „at most one running” różni się od „exactly once delivery”. Pierwsze da się uzyskać blokadą. Drugie wymaga projektu skutku: unikalnych kluczy, transakcji i jawnego stanu „wysłane / częściowo wysłane / do ponowienia”.
ShedLock a Quartz i mechanizmy natywne chmury
ShedLock jest lekką nakładką na istniejące metody @Scheduled. Nie trzeba przenosić harmonogramu do osobnych jobów, definiować triggerów w tabelach Quartza ani uczyć zespołu nowego modelu misfire. Wystarczy współdzielona tabela i adnotacja przy metodzie, która już działa na jednej instancji.

Quartz ma sens, gdy potrzebne są trwałe triggery, kalendarze wykluczeń, złożone polityki po przegapieniu okna albo ręczne wstrzymanie konkretnego joba bez restartu aplikacji. Kolejka albo workflow engine wygrywają, gdy jednostką pracy jest wiadomość z retry, dead-letter i statusem widocznym dla operatora, a nie „funkcja odpalana co pięć minut”.
| Kryterium | ShedLock + @Scheduled | Quartz w klastrze | Kolejka / workflow |
|---|---|---|---|
| Cel | Jedno uruchomienie metody w klastrze | Trwały harmonogram i polityki triggerów | Przetwarzanie jednostek pracy ze statusem |
| Stan | Krótki wiersz blokady | Tabele jobów i triggerów | Wiadomości, checkpointy, historia prób |
| Po awarii w trakcie | Lock wygasa, zadanie musi być idempotentne | Zależy od konfiguracji joba i misfire | Retry i kolejka nieprzetworzonych elementów |
Konfiguracja ShedLock z Spring Boot i PostgreSQL
Zależności Maven lub Gradle
Do integracji ze schedulerem Springa potrzebny jest moduł shedlock-spring. Magazyn JDBC dokłada shedlock-provider-jdbc-template, jeśli aplikacja korzysta z JdbcTemplate albo Spring Data JDBC. Przy JPA bywa używany provider oparty na EntityManager, ale przy samej tabeli blokad JdbcTemplate jest prostszy i nie wciąga encji do modelu domenowego. [3]
Wersję ShedLock dobiera się do używanego Spring Boota i Spring Framework, zgodnie z tabelą zgodności w dokumentacji biblioteki. Mieszanie starego shedlock-spring z nowym providerem kończy się zwykle brakiem beana albo metodą, której dana wersja adnotacji już nie wystawia. Provider nie jest uniwersalny: Redis, MongoDB, DynamoDB, ZooKeeper czy Hazelcast mają osobne artefakty. Wybór wynika z tego, co już jest wspólne dla wszystkich replik i co zespół umie backupować oraz monitorować. [1]
Uwaga: baza aplikacji często jest najlepszym magazynem locków właśnie dlatego, że jej niedostępność i tak zatrzymuje zadanie. Osobny Redis tylko pod ShedLock dodaje komponent, którego awaria blokuje scheduler, mimo że PostgreSQL działa.
Tabela shedlock w bazie danych
Provider JDBC oczekuje tabeli o ustalonych kolumnach. Dla PostgreSQL typowy skrypt wygląda tak:
CREATE TABLE shedlock (
name VARCHAR(64) NOT NULL PRIMARY KEY,
lock_until TIMESTAMP NOT NULL,
locked_at TIMESTAMP NOT NULL,
locked_by VARCHAR(255) NOT NULL
);Nazwa name ma limit 64 znaków w klasycznym schemacie. Dłuższy identyfikator locka zostanie obcięty albo odrzucony zależnie od wersji i bazy, więc lepiej trzymać krótkie, stabilne nazwy. Migrację wdraża się przez Flyway albo Liquibase na każdym środowisku, łącznie z testami integracyjnymi. Ręczne CREATE TABLE na produkcji rozjeżdża się z kontenerem, który startuje wcześniej niż ktokolwiek zdąży wykonać skrypt.
Wszystkie repliki muszą widzieć ten sam logiczny magazyn. Dwie bazy „podobne”, read replica bez zapisu albo lokalny H2 w kontenerze dają iluzję konfiguracji: każda instancja zakłada lock u siebie i i tak wykonuje zadanie. Przy podziale na schematy nazwa tabeli w providerze musi wskazywać ten schemat, do którego aplikacja ma prawo INSERT i UPDATE.
Bean LockProvider i włączenie obsługi blokad
Sam wpis w bazie nic nie zrobi, dopóki Spring nie dostanie LockProvider i dopóki konfiguracja nie włączy aspektu blokad. Minimalna klasa dla PostgreSQL:
@Configuration
@EnableSchedulerLock(defaultLockAtMostFor = "15m")
public class ShedLockConfig {
@Bean
public LockProvider lockProvider(DataSource dataSource) {
return new JdbcTemplateLockProvider(
JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(new JdbcTemplate(dataSource))
.usingDbTime()
.build()
);
}
}defaultLockAtMostFor jest limitem awaryjnym dla metod, które nie podadzą własnego czasu. Piętnaście minut może być za krótkie dla nocnego raportu i za długie dla zadania co minutę. Dlatego zadania o różnym czasie trwania powinny mieć jawne lockAtMostFor na adnotacji, a wartość domyślna ma tylko nie dopuścić do locka bezterminowego po błędzie programisty.
usingDbTime() każe providerowi porównywać ważność blokady z zegarem bazy, nie z zegarem kontenera. Rozjazd NTP między węzłami potrafi inaczej „wygasić” lock na dwóch instancjach i wpuścić drugie wykonanie, mimo że wiersz jeszcze wygląda na zajęty według lokalnego czasu. Tip: zegar bazy też wymaga zdrowej synchronizacji, ale jeden zegar jest lepszy niż N zegarów aplikacyjnych.
Jeśli chcesz pogłębić ten wątek, sprawdź też: 6 taktyk które zmniejszą zużycie tokenów w Claude i zwiększą jakość pracy z AI — także ….
Pierwsze zadanie chronione przez @SchedulerLock
Przykład metody z @Scheduled i @SchedulerLock
Metoda zostaje zwykłą metodą komponentu Springa. ShedLock nie przenosi jej do innego wątku i nie zmienia crona. Dokłada tylko warunek: wejdź do ciała metody tylko wtedy, gdy uda się wziąć nazwany lock.
@Component
public class OrderStatusSyncJob {
@Scheduled(cron = "0 */5 * * * *")
@SchedulerLock(
name = "syncExternalOrderStatuses",
lockAtMostFor = "12m",
lockAtLeastFor = "1m"
)
public void syncStatuses() {
// odczyt partii zamówień, wywołanie API, zapis statusu
}
}Nazwa syncExternalOrderStatuses jest kontraktem operacyjnym. Zmiana nazwy metody w refaktorze nie powinna zmieniać locka, bo stary wiersz w tabeli zostałby porzucony, a nowa nazwa pozwoliłaby drugiej instancji wejść równolegle w trakcie wdrożenia rolling update. Nazwa musi być unikalna w całym klastrze aplikacji współdzielących tabelę. Dwa moduły z lockiem reportJob będą się wzajemnie blokować, mimo że robią różne rzeczy.
Zachowanie ShedLock podczas awarii, restartu i nakładania się harmonogramów
Po wdrożeniu adnotacji najważniejsze przestaje być „czy lock się zakłada”, a to, co dzieje się, gdy węzeł znika w trakcie pracy albo gdy cron odpala się szybciej, niż kończy się poprzedni przebieg. ShedLock nie wznawia przerwanej metody i nie trzyma blokady, dopóki proces żyje. Trzyma ją do lock_until. Od tego pola zależy, czy po awarii druga instancja wejdzie za wcześnie, czy cały klaster będzie czekał na wygaśnięcie martwego wiersza.
Awaria instancji i wygaśnięcie locka
Jeśli pod zniknie w połowie syncStatuses(), aspekt nie zdąży skrócić lock_until. Wiersz zostaje z nazwą padłego hosta w locked_by i czasem ważności wyliczonym z lockAtMostFor. Pozostałe repliki, które obudzą się wcześniej, zobaczą lock jako zajęty i pominą wywołanie. Dopiero po tym czasie provider uzna blokadę za wygasłą i pozwoli komuś innemu ją przejąć.

To świadomy kompromis, nie wyciek na zawsze. Czas „at most” jest górną granicą, po której klaster odzyskuje prawo do startu, nawet jeśli poprzedni proces nie żyje i nie może zwolnić wiersza. Dlatego wartość musi być dłuższa niż najdłuższe poprawne wykonanie, ale nie na tyle długa, żeby awaria oznaczała godzinę ciszy w synchronizacji, która miała chodzić co pięć minut.
W przykładzie ze dwunastoma minutami przy cronie co pięć minut oznacza to konkretny scenariusz. Zadanie startuje o 10:00 i pada o 10:03. Najbliższe ticki o 10:05 i 10:10 zostaną pominięte, bo lock jest ważny mniej więcej do 10:12. O 10:15 inna instancja może wejść. Jeśli w tym oknie część zamówień została już zaktualizowana, wznowienie musi to rozpoznać po stanie rekordu, a nie po tym, że „ShedLock już puścił”.
Przy incydencie warto czytać wiersz, a nie zgadywać z logów jednej repliki. locked_by mówi, który pod założył blokadę, locked_at kiedy, a lock_until do kiedy klaster będzie ją szanował. Jeśli hosta nie ma w klastrze, a czas jeszcze nie minął, ręczne skracanie lock_until jest operacją awaryjną: przyspiesza wznowienie, ale otwiera okno na drugi start, jeśli „martwy” proces jednak dokończy zapis.
Restart i rolling update
Restart samej instancji nie czyści tabeli. Nowy proces z tym samym locked_by nie dostaje locka „z powrotem” tylko dlatego, że to ten sam host. Provider patrzy na ważność wiersza. Jeśli poprzedni przebieg jeszcze nie wygasł, świeżo wystartowana JVM też pominie zadanie, dopóki lock_until nie minie.
Przy rolling update widać to szczególnie wyraźnie. Stary pod może trzymać lock i kończyć pracę, podczas gdy nowy pod już przyjmuje tick crona. Nowy pod nie wejdzie do metody, póki stary nie zwolni blokady albo czas nie wygaśnie. To pożądane, o ile nazwa locka się nie zmieniła między wersjami. Zmiana nazwy w trakcie wdrożenia daje dwa niezależne wiersze i dwa równoległe przebiegi — dokładnie ten efekt, którego biblioteka miała uniknąć.
Graceful shutdown też nie jest magiczny. Jeśli platforma ubije proces przed końcem metody, zachowanie jest takie samo jak przy awarii: lock żyje do lock_until. Warto więc ustawić czas zakończenia poda dłuższy niż typowy przebieg albo pogodzić się z tym, że po restarcie najbliższe okna będą puste.
Jeśli chcesz pogłębić ten wątek, sprawdź też: Usprawnianie pracy z plikami konfiguracyjnymi Spring Boot w terminalu.
Jak dobrać lockAtMostFor i lockAtLeastFor
lockAtMostFor to limit bezpieczeństwa, nie szacunek „ile zwykle trwa job”. Mierz najdłuższe poprawne wykonania z produkcji, dolicz zapas na wolniejsze API i chwilowy wzrost partii, i dopiero tę liczbę wpisz na adnotacji. Dla synchronizacji co pięć minut dwanaście minut ma sens, jeśli p95 mieści się w kilku minutach, a dwanaście jest wyraźnie powyżej najgorszego poprawnego przebiegu. Dla nocnego raportu, który czasem idzie czterdzieści minut, ten sam limit utnie ochronę w trakcie pracy i wpuści drugą instancję na żywy proces.
Zbyt krótki „at most” jest groźniejszy niż zbyt długi. Po przekroczeniu limitu ShedLock uznaje lock za wolny, nawet jeśli pierwsza metoda nadal działa. Dwie repliki mogą wtedy pisać te same rekordy. Zbyt długi limit boli głównie po awarii: klaster czeka, aż martwy wiersz wygaśnie. Lepiej mieć idempotentne wznowienie po kilkunastu minutach niż ciche nakładanie się dwóch żywych przebiegów.
lockAtLeastFor działa w drugą stronę. Po poprawnym zakończeniu ShedLock nie ustawia lock_until na „teraz”, jeśli minimalny czas jeszcze nie minął. Minuta przy zadaniu co pięć minut nie blokuje następnego legalnego ticka, ale odcina natychmiastowe powtórzenie tuż po krótkim przebiegu — przydatne, gdy ktoś odpali metodę ręcznie albo gdy zegary węzłów lekko się rozjadą mimo usingDbTime(). Nie używaj tej wartości do „rozciągania” harmonogramu. Jeśli zadanie ma chodzić rzadziej, zmień cron, a nie trzymaj sztucznie locka przez pół godziny po pracy, która trwała sekundę.
Dla zadań o bardzo różnym czasie trzymaj jawne wartości na metodzie i zostaw defaultLockAtMostFor tylko jako siatkę bezpieczeństwa. Jedna domyślna godzina dla joba co minutę oznacza, że padły przebieg blokuje pięćdziesiąt dziewięć kolejnych ticków.
Długie zadanie i nakładające się ticki
Cron nie czeka, aż poprzednie wywołanie się skończy, a ShedLock nie buduje kolejki zaległych startów. Jeśli synchronizacja startuje o 10:00 i trwa osiem minut, tick o 10:05 zobaczy aktywny lock i zakończy się od razu. Nie dostanie „swojej” kolejki do odrobienia o 10:08. Następna realna szansa to 10:10, o ile pierwsza instancja już zwolniła wiersz.
To samo dotyczy wielu replik i jednej JVM. Druga instancja nie stoi w blokadzie wątku. Kończy wywołanie, zwykle z logiem, że lock nie został zdobyty. Jeśli metoda jest długa, a cron gęsty, w logach pojawi się seria pominięć. To nie jest awaria biblioteki. To sygnał, że harmonogram jest ciaśniejszy niż rzeczywisty czas pracy albo że partia urosła i warto ją ograniczyć, zamiast skracać lockAtMostFor, żeby „jakoś wchodziło częściej”.
Gdy zadanie regularnie nachodzi na własny interwał, są trzy uczciwe wyjścia. Wydłużyć cron do okna, w którym przebieg mieści się z zapasem. Przetwarzać mniejsze partie, tak aby jedno wywołanie kończyło się przed następnym tickiem. Albo przestać modelować tę pracę jako funkcję odpalaną co pięć minut i przenieść jednostki do kolejki, gdzie zaległość jest widoczna jako wiadomości, a nie jako puste przebiegi w logu.
Przy nakładaniu się warto też uważać na wyjątki. Jeśli metoda rzuci błąd po minucie, ShedLock i tak zwolni lock zgodnie z lockAtLeastFor albo od razu, gdy minimum nie jest ustawione. Następny tick może wejść natychmiast i powtórzyć ten sam błąd na tej samej partii. Blokada nie jest backoffem. Odstęp między nieudanymi próbami trzeba zapisać w samym zadaniu albo w harmonogramie, a nie liczyć, że wiersz w shedlock odczeka za aplikację.
Operacyjnie wystarczy więc pilnować trzech rzeczy: lockAtMostFor dłuższego niż najgorszy poprawny przebieg, lockAtLeastFor krótszego niż odstęp crona i logu pominięć, który mówi, że tick się nie zmieścił, a nie że scheduler „zgubił” robotę. Resztę — wznowienie po padłym podzie i brak drugiej żywej instancji w tym samym oknie — daje wygasający wiersz, o ile nazwa locka zostaje stabilna między restartami.
Pytania od czytelników
Czy ShedLock gwarantuje, że zadanie wykona się dokładnie raz?
Co się stanie, gdy instancja przerwie pracę przed zakończeniem zadania?
lockAtMostFor. Kolejna instancja będzie mogła uruchomić zadanie, więc powinno ono obsługiwać ponowienie pracy.Czy wszystkie repliki muszą korzystać z tej samej tabeli ShedLock?
Czy ustawienie jednego wątku schedulera zastępuje ShedLock?
