Cron nie przeciąża serwera dlatego, że działa źle. Przeciążenie pojawia się wtedy, gdy harmonogram bez kontroli uruchamia ciężkie skrypty, poprzednie wykonanie jeszcze się nie zakończyło, a kolejne procesy zaczynają walczyć o procesor, pamięć, dysk i połączenia z bazą danych. Najgorszy wariant wygląda niewinnie: zadanie ustawione co minutę wykonuje się przez trzy minuty. Po kwadransie na serwerze może działać kilkanaście kopii tego samego procesu.
Najbezpieczniejszy model jest prosty: Cron decyduje, kiedy rozpocząć proces, ale nie powinien wykonywać całej ciężkiej pracy. Jego zadaniem może być sprawdzenie stanu systemu, pobranie niewielkiej partii rekordów albo dodanie zadań do kolejki. Faktyczne przetwarzanie przejmują workery z ustawionymi limitami równoległości, czasu wykonania i liczby prób.
Cron powinien uruchamiać proces, a nie zastępować system kolejkowy
Klasyczny wpis w crontabie składa się z pięciu pól czasu oraz polecenia:
minuta godzina dzień_miesiąca miesiąc dzień_tygodnia polecenie
Przykładowe zadanie uruchamiane codziennie o 2:30 może wyglądać tak:
30 2 * * * /usr/bin/php /var/www/app/bin/console app:prepare-reports
Zakresy pól są następujące:
- minuta: 0–59,
- godzina: 0–23,
- dzień miesiąca: 1–31,
- miesiąc: 1–12,
- dzień tygodnia: najczęściej 0–7, przy czym 0 i 7 oznaczają niedzielę.
Problem nie leży zwykle w samej składni. Znacznie częściej zadanie działa ręcznie, ale nie uruchamia się z Crona, ponieważ środowisko procesu jest inne. Cron może mieć ograniczoną zmienną PATH, innego użytkownika, inny katalog roboczy oraz brak zmiennych środowiskowych dostępnych w interaktywnej powłoce.
Dlatego w produkcji należy używać:
- pełnych ścieżek do interpretera i skryptu,
- jawnego katalogu roboczego,
- osobnego użytkownika systemowego z minimalnymi uprawnieniami,
- przekierowania standardowego wyjścia i błędów do logów,
- limitu czasu wykonania,
- blokady zapobiegającej nakładaniu się procesów.
Bezpieczniejszy wpis może wyglądać tak:
*/5 * * * * cd /var/www/app && /usr/bin/flock -n /run/lock/import.lock /usr/bin/timeout 240 /usr/bin/php bin/console app:queue-import >> /var/log/app/import-cron.log 2>&1
Ten zapis uruchamia polecenie co pięć minut, ale wprowadza dwie ważne bariery. flock -n kończy próbę natychmiast, gdy poprzednia instancja nadal trzyma blokadę. timeout 240 zatrzymuje proces po 240 sekundach. Limit powinien być krótszy niż odstęp, po którym mechanizm kolejkowy ponownie udostępnia niepotwierdzone zadanie.
Sama blokada nie wystarczy. Jeżeli proces zostanie zabity w połowie operacji, może pozostawić częściowo zmienione dane. Skrypt powinien być idempotentny, czyli ponowne wykonanie tej samej operacji nie może powodować podwójnego obciążenia klienta, powtórnej wysyłki wiadomości ani ponownego utworzenia dokumentu.
W praktyce oznacza to między innymi:
- nadawanie zadaniom unikalnych identyfikatorów,
- sprawdzanie statusu przed rozpoczęciem operacji,
- używanie unikalnych indeksów w bazie danych,
- wykonywanie zmian w transakcjach tam, gdzie jest to możliwe,
- rozdzielanie pobrania danych od ich przetwarzania.
Nie należy też ustawiać wszystkich procesów na pełną godzinę. Kopie zapasowe o 2:00, raporty o 2:00, importy o 2:00 i czyszczenie logów o 2:00 tworzą lokalny szczyt obciążenia. Lepszy harmonogram to na przykład backup o 2:07, raporty o 2:25, porządki o 3:10 i synchronizacja o 3:40. Rozsunięcie zadań nie rozwiąże problemu zbyt ciężkiego kodu, ale ograniczy przypadkowe kumulowanie operacji na dysku i bazie danych.
Na serwerach opartych na systemd warto rozważyć timery systemd zamiast klasycznego Crona. Ułatwiają one kontrolę zależności, logowanie w journalctl, uruchamianie zadań pominiętych podczas wyłączenia serwera oraz ustawianie losowego opóźnienia. Cron nadal jest dobrym wyborem dla prostych harmonogramów, lecz nie powinien być traktowany jako pełny system nadzorowania procesów.
Kolejka przejmuje kontrolę nad tempem i liczbą wykonywanych zadań
Jeżeli proces obejmuje tysiące wiadomości e-mail, generowanie plików PDF, przetwarzanie zdjęć, synchronizację z API albo aktualizację dużej liczby rekordów, wykonywanie wszystkiego w jednym skrypcie Crona jest błędem konstrukcyjnym.
Lepszy przepływ wygląda następująco:
- Cron uruchamia krótki proces planujący.
- Proces wybiera rekordy wymagające obsługi.
- Dla każdego rekordu lub niewielkiej partii tworzy osobne zadanie.
- Zadania trafiają do brokera albo trwałego magazynu kolejki.
- Workery pobierają je w kontrolowanym tempie.
- Po poprawnym wykonaniu worker potwierdza zadanie.
- Błąd powoduje ponowienie, opóźnienie albo przeniesienie zadania do kolejki błędów.
Do obsługi kolejek można wykorzystać między innymi Redis, RabbitMQ, bazę danych lub zarządzane usługi chmurowe. Frameworki oferują gotowe warstwy abstrakcji: Laravel Queue i Horizon, Symfony Messenger, Celery dla aplikacji Pythonowych czy BullMQ dla Node.js.
Baza danych jest najprostsza operacyjnie, ponieważ nie wymaga kolejnej usługi. Przy niewielkim ruchu, na przykład kilku lub kilkunastu zadaniach na sekundę, często okazuje się wystarczająca. Jej wadą jest dodatkowe obciążenie tabel, indeksów i mechanizmu blokad. Przy intensywnym przetwarzaniu kolejka nie powinna konkurować z ruchem użytkowników o te same zasoby bazy.
Redis jest szybki i popularny, ale wymaga poprawnego ustawienia trwałości danych, limitu pamięci oraz polityki usuwania kluczy. Konfiguracja przeznaczona do zwykłego cache nie zawsze nadaje się do kolejki. Utrata wpisów z cache bywa akceptowalna. Utrata zleceń wystawienia faktur już nie.
RabbitMQ lepiej pasuje do rozbudowanego routingu, potwierdzeń, wielu typów konsumentów i kolejek błędów. Jest jednak trudniejszy w utrzymaniu. Trzeba kontrolować zużycie pamięci i dysku, czas przechowywania wiadomości, liczbę niepotwierdzonych komunikatów oraz stan klastra. W małej aplikacji może być niepotrzebnym kosztem operacyjnym.
Najważniejszym parametrem nie jest liczba zadań w kolejce, lecz kontrolowana równoległość. Cztery workery nie zawsze wykonają pracę dwa razy szybciej niż dwa. Jeżeli każde zadanie intensywnie korzysta z tej samej bazy albo zapisuje duże pliki na jednym dysku, zwiększanie liczby workerów może tylko wydłużyć czas odpowiedzi całej aplikacji.
Punkt startowy trzeba dobrać do rodzaju obciążenia:
- dla zadań intensywnie używających CPU należy zaczynać ostrożnie, zwykle od liczby procesów nieprzekraczającej liczby dostępnych rdzeni;
- dla zadań oczekujących głównie na odpowiedź z zewnętrznego API można uruchomić więcej równoległych operacji, ale tylko w granicach limitów dostawcy;
- dla zadań mocno obciążających bazę bezpieczniej zacząć od 1–2 workerów i mierzyć liczbę zapytań, czas blokad oraz użycie puli połączeń;
- dla generowania PDF-ów i obróbki obrazów trzeba obserwować przede wszystkim pamięć, ponieważ pojedynczy proces potrafi chwilowo zużyć setki megabajtów RAM.
Kolejki należy podzielić według charakteru pracy. Pilna wysyłka kodu logowania nie powinna czekać za dwoma tysiącami miniaturek zdjęć. Rozsądny podział może obejmować:
critical— płatności, logowanie, operacje wymagające szybkiej reakcji,default— standardowe zadania aplikacji,mail— wiadomości e-mail,imports— synchronizacje i importy,media— obrazy, wideo i dokumenty,reports— raporty zbiorcze wykonywane z niskim priorytetem.
Nie wystarczy nadać kolejkom nazw. Każda z nich powinna mieć przypisaną liczbę workerów, limit czasu i zasady ponawiania. W przeciwnym razie jedno wadliwe zadanie będzie wracało bez końca, generując kolejne zapytania i wpisy w logach.
Limity, retry i monitoring chronią serwer przed efektem domina
Najczęstszy błąd polega na skalowaniu kolejki wyłącznie przez dodawanie workerów. Gdy czas oczekiwania rośnie, administrator uruchamia dziesięć kolejnych procesów. Kolejka maleje, ale baza danych zaczyna odrzucać połączenia, aplikacja zwalnia, a zewnętrzne API odpowiada kodem 429 Too Many Requests.
Najpierw trzeba ustawić budżet zasobów. Worker nie może mieć prawa zużyć całej mocy serwera tylko dlatego, że w kolejce pojawiło się więcej zadań.
Podstawowa konfiguracja powinna obejmować:
- maksymalną liczbę procesów lub wątków,
- limit pamięci pojedynczego workera,
- limit czasu pojedynczego zadania,
- maksymalną liczbę prób,
- opóźnienie między próbami,
- ograniczenie szybkości dla zewnętrznych usług,
- osobną kolejkę na zadania trwale błędne,
- automatyczny restart workerów po wdrożeniu nowej wersji aplikacji.
Czas wykonania workera musi być skoordynowany z czasem ponownego udostępnienia zadania. W Laravelu parametr --timeout powinien być ustawiony co najmniej kilka sekund poniżej retry_after. Przykładowo przy retry_after równym 120 sekund sensowny --timeout to około 105–110 sekund. Gdy timeout jest dłuższy niż czas ponowienia, drugie wykonanie może rozpocząć się, zanim pierwsze zostanie zakończone.
To szczególnie niebezpieczne przy płatnościach, fakturach i wysyłkach. Systemy kolejkowe najczęściej zapewniają dostarczenie zadania co najmniej raz, a nie dokładnie raz. Duplikaty nie są anomalią, którą można całkowicie wykluczyć. Aplikacja musi umieć je bezpiecznie rozpoznać.
Ponawianie powinno korzystać z rosnącego opóźnienia, na przykład:
- pierwsza próba po 10 sekundach,
- druga po 30 sekundach,
- trzecia po 2 minutach,
- czwarta po 10 minutach,
- ostatnia po 30–60 minutach.
Dodanie niewielkiego losowego przesunięcia, czyli jittera, zapobiega jednoczesnemu powrotowi tysięcy zadań po awarii zewnętrznej usługi. Stałe ponawianie co pięć sekund jest prostą drogą do samodzielnie wywołanego ataku na API lub własną bazę danych.
Nie każdy błąd zasługuje na retry. Przekroczenie czasu połączenia, chwilowy kod 503 albo blokada transakcji mogą uzasadniać kolejną próbę. Błędny adres e-mail, brak wymaganego identyfikatora czy odrzucony format dokumentu nie naprawią się po pięciu minutach. Takie zadanie należy oznaczyć jako trwale błędne i przenieść do dead-letter queue albo tabeli błędów.
Równie ważny jest limit pobieranych zadań. Worker nie powinien rezerwować dużej liczby komunikatów na zapas, jeżeli pojedyncze operacje trwają długo. Zbyt wysoki prefetch prowadzi do nierównego podziału pracy: jeden proces trzyma wiele zadań, podczas gdy pozostałe czekają bezczynnie. Przy długich i nierównych zadaniach bezpiecznym punktem startowym jest prefetch zbliżony do jednego zadania na proces wykonawczy.
Monitoring nie może ograniczać się do sprawdzania, czy proces workera istnieje. Proces może działać, a mimo to nie wykonywać zadań. Trzeba mierzyć co najmniej:
- liczbę zadań oczekujących,
- wiek najstarszego zadania,
- czas od utworzenia do rozpoczęcia pracy,
- czas wykonania,
- procent błędów,
- liczbę ponowień,
- liczbę zadań trwale odrzuconych,
- użycie CPU i RAM przez workery,
- liczbę aktywnych połączeń z bazą,
- odpowiedzi
429,502,503i timeouty z usług zewnętrznych.
Sama długość kolejki bywa myląca. Tysiąc zadań wykonywanych po 20 milisekund może nie stanowić problemu. Sto raportów generowanych po pięć minut oznacza już wielogodzinne opóźnienie. Dlatego praktyczniejszym alarmem jest wiek najstarszego zadania. Dla wiadomości transakcyjnych granicą może być 30–60 sekund, dla importu produktowego 15 minut, a dla nocnego raportu nawet kilka godzin. Limit wynika z wymagań biznesowych, nie z możliwości narzędzia.
FAQ: Cron i kolejki bez przeciążania
Czy Cron może bezpośrednio wykonywać ciężkie zadania?
Może, ale jest to rozsądne tylko wtedy, gdy zadanie ma przewidywalny czas działania, nie nakłada się na kolejne uruchomienie i nie wymaga rozbudowanych mechanizmów retry. Długie importy, masowa wysyłka i przetwarzanie plików powinny trafiać do kolejki.
Jak często można uruchamiać zadanie Cron?
Standardowy Cron pracuje z dokładnością do jednej minuty. Zadanie ustawione co minutę musi kończyć się przed kolejnym uruchomieniem albo korzystać z blokady. Procesy wymagające krótszych interwałów lepiej obsługiwać przez stale działający worker lub timer systemowy.
Ile workerów uruchomić na początek?
Dla ciężkich operacji na bazie zacznij od jednego lub dwóch. Dla zadań CPU-bound nie przekraczaj bez pomiarów liczby dostępnych rdzeni. Liczbę zwiększaj dopiero po sprawdzeniu CPU, pamięci, puli połączeń i czasu oczekiwania w kolejce.
Czy Redis zawsze jest najlepszym brokerem?
Nie. Jest szybki i łatwy do wdrożenia, ale trzeba zadbać o trwałość danych i limit pamięci. Prosta aplikacja może korzystać z bazy danych, a system wymagający rozbudowanego routingu i potwierdzeń częściej skorzysta z RabbitMQ.
Co zrobić z zadaniem, które ciągle kończy się błędem?
Ustawić maksymalną liczbę prób, zapisać przyczynę błędu i przenieść zadanie do kolejki błędów. Nieskończone retry zwykle zwiększa awarię zamiast ją naprawiać.
Pierwszym krokiem nie powinno być instalowanie RabbitMQ ani zwiększanie liczby workerów. Najpierw sprawdź, czy istniejące zadania Cron mogą uruchamiać się równolegle. Dodaj blokady, limity czasu i pomiar rzeczywistego czasu wykonania. Następnie przenieś do kolejki jedno najcięższe zadanie, podziel je na małe, idempotentne operacje i uruchom maksymalnie dwa workery. Dopiero po zebraniu danych zwiększaj równoległość. Najpilniejszym błędem do usunięcia jest zawsze proces, który może uruchomić drugą kopię samego siebie bez żadnej kontroli.
