Poznaj wzorzec cron + baza danych do uruchamiania zaplanowanych zadań w tle z retry, blokowaniem i idempotencją — bez wdrażania pełnego systemu kolejek.

Większość aplikacji potrzebuje pracy, która wykona się później lub według harmonogramu: wysyłanie maili przypominających, nocne sprawdzenia rozliczeń, czyszczenie starych rekordów, przebudowa raportu czy odświeżanie cache.
Na początku kuszące jest dodanie pełnego systemu kolejek, bo wydaje się to „właściwym” sposobem na zadania w tle. Ale kolejki dodają kolejne elementy: dodatkowy serwis do uruchamiania, monitorowania, wdrażania i debugowania. Dla małego zespołu (albo jednego założyciela) ten dodatkowy ciężar może spowalniać pracę.
Prawdziwe pytanie brzmi więc: jak uruchamiać zaplanowaną pracę niezawodnie, bez stawiania kolejnej infrastruktury?
Częstym pierwszym podejściem jest proste rozwiązanie: wpis cron, który wywołuje endpoint, a ten wykonuje zadanie. Działa — dopóki nie przestanie. Gdy masz więcej niż jeden serwer, deployment w niewłaściwym momencie albo zadanie zajmuje dłużej niż oczekiwano, zaczynają się tajemnicze awarie.
Zaplanowana praca zwykle psuje się w kilku przewidywalnych sposóbów:
Wzorzec cron + baza danych to droga pośrodku. Nadal używasz crona do „budzenia” według harmonogramu, ale zamiast tego przechowujesz intencję zadania i jego stan w bazie danych, dzięki czemu system może koordynować, retryować i zapisywać, co się wydarzyło.
To dobre rozwiązanie, gdy masz jedną bazę danych (często PostgreSQL), niewiele typów zadań i chcesz przewidywalnego zachowania przy minimalnej pracy operacyjnej. To też naturalny wybór dla aplikacji zbudowanych szybko na nowoczesnych stackach (na przykład React + Go + PostgreSQL).
Nie nadaje się, gdy potrzebujesz bardzo dużej przepustowości, długotrwałych zadań strumieniujących postęp, ścisłego porządku wykonania między wieloma typami zadań albo dużego fan-outu (tysiące podzadań na minutę). W takich przypadkach dedykowane kolejki i workerzy zwykle się opłacają.
Wzorzec cron + baza danych uruchamia zadania w tle według harmonogramu bez wdrażania pełnego systemu kolejek. Nadal używasz crona (lub dowolnego harmonogramu), ale to cron nie decyduje, co ma być wykonane — on tylko często „budzi” workera (często co minutę). To baza danych decyduje, która praca jest gotowa do wykonania i pilnuje, żeby tylko jeden worker zabrał dane zadanie.
Pomyśl o tym jak o wspólnym checklistie na tablicy. Cron to osoba, która co minutę wchodzi do pokoju i pyta „Kto ma coś do zrobienia?”. Baza danych to tablica, która pokazuje, co jest do zrobienia, co już zajęte i co zrobione.
Elementy są proste:
Przykład: chcesz wysyłać przypomnienia o fakturach każdego ranka, odświeżać cache co 10 minut i czyścić stare sesje w nocy. Zamiast trzech osobnych wpisów w cronie (każdy z własnymi problemami nakładania się i awarii), zapisujesz wpisy zadań w jednym miejscu. Cron uruchamia ten sam proces workera. Worker pyta Postgresa „Co jest teraz należne?” i Postgres pozwala workerowi bezpiecznie zająć dokładnie jedno zadanie na raz.
To się skaluje stopniowo. Możesz zacząć z jednym workerem na jednym serwerze. Później uruchomić pięć workerów na kilku serwerach. Kontrakt pozostaje ten sam: tabela jest kontraktem.
Zmiana myślenia jest prosta: cron to tylko budzik. Baza to policjant ruchu, który decyduje, co można uruchomić, zapisuje, co się stało i daje jasną historię, gdy coś idzie nie tak.
Wzorzec działa najlepiej, gdy baza staje się źródłem prawdy o tym, co powinno się uruchomić, kiedy i co się stało ostatnim razem. Schemat nie jest skomplikowany, ale drobne detale (pola blokady i właściwe indeksy) robią dużą różnicę wraz ze wzrostem obciążenia.
Dwa popularne podejścia:
Jeśli spodziewasz się częstego debugowania awarii, zachowaj historię. Jeśli chcesz jak najprostsze ustawienie, zacznij od jednej tabeli i dodaj historię później.
Poniżej układ przyjazny PostgreSQL. Jeśli budujesz w Go z PostgreSQL, te kolumny mapują się czysto na struktury.
-- What should exist (the definition)
create table job_definitions (
id bigserial primary key,
job_type text not null,
payload jsonb not null default '{}'::jsonb,
schedule text, -- optional: cron-like text if you store it
max_attempts int not null default 5,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- What should run (each run / attempt group)
create table job_runs (
id bigserial primary key,
definition_id bigint references job_definitions(id),
job_type text not null,
payload jsonb not null default '{}'::jsonb,
run_at timestamptz not null,
status text not null, -- queued | running | succeeded | failed | dead
attempts int not null default 0,
max_attempts int not null default 5,
locked_by text,
locked_until timestamptz,
last_error text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
Kilka szczegółów, które oszczędzą bólu później:
send_invoice_emails).jsonb, aby móc go ewoluować bez migracji.Bez indeksów workerzy będą przeszukiwać zbyt dużo wierszy. Zacznij od:
(status, run_at)(locked_until)queued i failed)To utrzyma zapytanie „znajdź następne wykonalne zadanie” szybkim nawet przy rosnącej tabeli.
Cel jest prosty: wiele workerów może działać, ale tylko jeden powinien zgarnąć konkretny task. Jeśli dwóch workerów przetworzy ten sam wiersz, masz podwójne maile, podwójne opłaty albo bałagan w danych.
Bezpieczne podejście to traktować rezerwację zadania jak „leasing”. Worker oznacza zadanie jako zablokowane na krótki czas. Jeśli worker padnie, leasing wygaśnie i inny worker może je pobrać. Do tego służy locked_until.
Bez lease worker może zarezerwować zadanie i nigdy go nie odblokować (proces zabity, reboot serwera, źle poszedł deploy). Z locked_until zadanie staje się ponownie dostępne po upływie czasu.
Typowa zasada: zadanie można zarezerwować, gdy locked_until jest NULL lub locked_until <= now().
Kluczowa rzecz to rezerwacja zadania w jednym zapytaniu (lub jednej transakcji). Chcesz, żeby baza była sędzią.
Oto popularny wzorzec PostgreSQL: wybierz jedno zaległe zadanie, zablokuj je i zwróć workerowi. (Przykład używa pojedynczej tabeli jobs; ta sama idea działa dla job_runs.)
WITH next_job AS (
SELECT id
FROM jobs
WHERE status = 'queued'
AND run_at <= now()
AND (locked_until IS NULL OR locked_until <= now())
ORDER BY run_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
UPDATE jobs j
SET status = 'running',
locked_until = now() + interval '2 minutes',
locked_by = $1,
attempts = attempts + 1,
updated_at = now()
FROM next_job
WHERE j.id = next_job.id
RETURNING j.*;
Dlaczego to działa:
FOR UPDATE SKIP LOCKED pozwala wielu workerom rywalizować bez blokowania się nawzajem.RETURNING przekazuje wiersz workerowi, który wygrał wyścig.Ustaw lease dłuższy niż normalne wykonanie, ale wystarczająco krótki, żeby awaria szybko się odzyskała. Jeśli większość zadań kończy się w 10 sekund, leasing 2 minut wystarczy.
Dla długotrwałych zadań odnawiaj lease w trakcie pracy (heartbeat). Proste podejście: co 30 sekund przedłuż locked_until, jeśli nadal jesteś właścicielem zadania.
WHERE id = $job_id AND locked_by = $worker_idTen ostatni warunek ma znaczenie. Zapobiega on przedłużaniu lease na zadanie, którego worker już nie posiada.
Retry to moment, w którym ten wzorzec albo działa spokojnie, albo zamienia się w hałaśliwy bałagan. Cel jest prosty: gdy zadanie się nie powiedzie, spróbuj ponownie później w sposób, który potrafisz wyjaśnić, zmierzyć i zatrzymać.
Zacznij od jawnego i skończonego stanu zadania: queued, running, succeeded, failed, dead. W praktyce większość zespołów używa failed jako „nie powiodło się, ale będzie retry” i dead jako „nie powiodło się i rezygnujemy”. Ta jedna różnica zapobiega nieskończonym pętlom.
Liczenie prób to drugi hamulec bezpieczeństwa. Przechowuj attempts (ile razy próbowałeś) i max_attempts (ile razy pozwalasz). Gdy worker złapie błąd, powinien:
attemptsfailed jeśli attempts < max_attempts, w przeciwnym razie deadrun_at dla następnej próby (tylko dla failed)Backoff to reguła decydująca o następnym run_at. Wybierz jedną, udokumentuj i trzymaj się jej:
Jitter ma znaczenie, gdy zależność padnie i wróci. Bez niego setki zadań mogą spróbować jednocześnie i znów zawieść.
Przechowuj wystarczająco szczegółów błędu, by awarie były widoczne i dały się debugować. Nie potrzebujesz pełnego systemu logów, ale podstawy są niezbędne:
last_error (krótka wiadomość, bezpieczna do pokazania w panelu admina)error_code lub error_type (pomaga grupować)failed_at i next_run_atlast_stack (tylko jeśli kontrolujesz rozmiar)Konkretna zasada, która dobrze działa: oznaczaj zadania jako dead po 10 próbach i stosuj wykładniczy backoff z jitterem. To pozwala transientnym błędom się powtarzać, ale zatrzymuje popsute zadania przed pochłanianiem CPU bez końca.
Idempotencja oznacza, że zadanie może zostać wykonane dwa razy i wciąż dać ten sam wynik końcowy. W tym wzorcu ma to znaczenie, bo ten sam wiersz może zostać wybrany ponownie po awarii, timeoutcie albo retry. Jeśli zadanie to „wyślij mail z fakturą”, jego dwukrotne wykonanie może być szkodliwe.
Praktyczny sposób myślenia: rozdziel każde zadanie na (1) wykonywanie pracy i (2) zastosowanie efektu. Chcesz, żeby efekt wykonał się raz, nawet jeśli praca była podejmowana wielokrotnie.
Klucz idempotencji powinien pochodzić z tego, co zadanie reprezentuje, nie z próby workera. Dobre klucze są stabilne i łatwe do wyjaśnienia, np. invoice_id, user_id + day albo report_name + report_date. Jeśli dwie próby odnoszą się do tego samego zdarzenia, powinny mieć ten sam klucz.
Przykład: „Wygeneruj dzienny raport sprzedaży dla 2026-01-14” może używać sales_report:2026-01-14. „Pobierz opłatę za fakturę 812” może używać invoice_charge:812.
Najprostsza bariera to pozwolić PostgreSQL odrzucać duplikaty. Przechowaj klucz idempotencji w polu, które można zindeksować, a potem dodaj unikalne ograniczenie.
-- Example: ensure one logical job/effect per business key
ALTER TABLE jobs
ADD COLUMN idempotency_key text;
CREATE UNIQUE INDEX jobs_idempotency_key_uniq
ON jobs (idempotency_key)
WHERE idempotency_key IS NOT NULL;
To zapobiega istnieniu dwóch wierszy z tym samym kluczem jednocześnie. Jeśli projekt pozwala na wiele wierszy (dla historii), nałóż unikalność na tabelę efektów, np. sent_emails(idempotency_key) lub payments(idempotency_key).
Typowe skutki uboczne, które warto chronić:
sent_emails z unikalnym kluczem przed wysłaniem, albo zapisz identyfikator wiadomości dostawcy po wysłaniu.delivered_webhooks(event_id) i pomiń, jeśli już istnieje.file_generated kluczowany przez (type, date).Jeśli budujesz na stacku z Postgresem (np. Go + PostgreSQL), te sprawdzenia unikalności są szybkie i łatwe do trzymania blisko danych. Kluczowa idea: retryy są normalne, duplikaty niepożądane.
Wybierz jedno nudne runtime i trzymaj się go. Sens wzorca cron + baza danych to mniejsza liczba ruchomych części, więc mały proces w Go, Node czy Pythonie, który rozmawia z PostgreSQL, zwykle wystarczy.
Utwórz tabele i indeksy. Dodaj tabelę jobs (plus ewentualne tabele pomocnicze), zaindeksuj run_at i dodaj indeks, który pomoże workerowi szybko znaleźć dostępne zadania (np. na (status, run_at)).
Napisz małą funkcję enqueue. Aplikacja powinna wstawiać wiersz z run_at ustawionym na „teraz” lub na przyszły czas. Trzymaj payload mały i przewidywalny (ID i typ zadania, nie ogromne bloby).
INSERT INTO jobs (type, payload, status, run_at, attempts, max_attempts)
VALUES ($1, $2::jsonb, 'queued', $3, 0, 10);
running.WITH picked AS (
SELECT id
FROM jobs
WHERE status = 'queued' AND run_at <= now()
ORDER BY run_at
FOR UPDATE SKIP LOCKED
LIMIT 10
)
UPDATE jobs
SET status = 'running', started_at = now()
WHERE id IN (SELECT id FROM picked)
RETURNING *;
Przetwórz i zakończ. Dla każdego zarezerwowanego zadania wykonaj pracę, a potem zaktualizuj na done z finished_at. Jeśli się nie powiedzie, zapisz wiadomość o błędzie i przenieś z powrotem do queued z nowym run_at (backoff). Finalizacje powinny być krótkie i zawsze wykonywane, nawet jeśli proces się zamyka.
Dodaj reguły retry, które możesz wytłumaczyć. Użyj prostego wzoru jak run_at = now() + (attempts^2) * interval '10 seconds' i zatrzymaj po max_attempts przez ustawienie status = 'dead'.
Na dzień dobry nie potrzebujesz pełnego dashboardu, ale musisz widzieć problemy.
Jeśli już używasz Go + PostgreSQL, to ładnie mapuje się na jeden binarny worker plus cron.
Wyobraź sobie małą aplikację SaaS z dwoma zadaniami zaplanowanymi:
Prosto: jedna tabela w PostgreSQL trzyma zadania i jeden worker uruchamiany co minutę (wyzwalany przez cron). Worker rezerwuje zaległe zadania, wykonuje je i zapisuje sukces lub porażkę.
Możesz enqueue'ować zadania z kilku miejsc:
cleanup_nightly dla „dzisiaj”.send_weekly_report dla użytkownika na następny poniedziałek.send_weekly_report, które uruchamia się od razu dla konkretnego zakresu dat.Payload to tylko minimum, czego worker potrzebuje. Trzymaj go małym, by łatwo było retryować.
{
"type": "send_weekly_report",
"payload": {
"user_id": 12345,
"date_range": {
"from": "2026-01-01",
"to": "2026-01-07"
}
}
}
Worker może paść w najgorszym momencie: tuż po wysłaniu maila, ale przed oznaczeniem zadania jako „zrobione”. Po restarcie może wziąć to samo zadanie ponownie.
Aby zapobiec podwójnemu wysyłaniu, nadaj pracy naturalny klucz deduplikacji i zapisz go tam, gdzie baza może wymusić unikalność. Dla raportów tygodniowych dobry klucz to (user_id, week_start_date). Przed wysłaniem worker zapisuje „zamierzam wysłać raport X”. Jeśli zapis już istnieje, pomija wysyłkę.
To może być prosta tabela sent_reports z unikalnym ograniczeniem na (user_id, week_start_date), albo unikalny idempotency_key w samej tabeli zadań.
Powiedzmy, że dostawca maili timeoutuje. Zadanie się nie powiedzie, więc worker:
attemptsJeśli dalej zawodzi po limicie (np. 10 prób), oznacz je jako „dead” i przestań retryować. Zadanie albo wykona się raz, albo będzie retryowane według jasnego harmonogramu, a idempotencja sprawia, że retry jest bezpieczny.
Wzorzec cron + baza danych jest prosty, ale małe pomyłki mogą doprowadzić do duplikatów, zablokowanej pracy czy zaskakującego obciążenia. Większość problemów wychodzi po pierwszej awarii, deployu albo skoku ruchu.
Większość incydentów bierze się z kilku pułapek:
locked_until. Jeśli worker padnie po rezerwacji zadania, wiersz może pozostać „w toku” na zawsze. Timestamp lease pozwala innemu workerowi bezpiecznie je ponownie pobrać później.user_id, invoice_id lub klucz pliku) i pobieraj resztę podczas wykonania.Przykład: wysyłasz cotygodniowy mail z fakturą. Jeśli worker timeoutuje po wysłaniu, ale przed oznaczeniem zadania jako zakończone, to samo zadanie może zostać ponownie wykonane i wysłać duplikat. To normalne w tym wzorcu, jeśli nie dodasz zabezpieczeń (np. zapisu unikalnego zdarzenia „email wysłany” kluczowanego po id faktury).
Unikaj mieszania harmonogramowania i wykonania w tej samej długiej transakcji. Jeśli trzymasz transakcję otwartą podczas wywołań sieciowych, przytrzymujesz blokady dłużej niż potrzeba i blokujesz innych workerów.
Uważaj na różnice czasu między maszynami. Używaj czasu z bazy (NOW() w PostgreSQL) jako źródła prawdy dla run_at i locked_until, a nie zegara serwera aplikacji.
Ustal jasny maksymalny czas wykonania. Jeśli zadanie może trwać 30 minut, ustaw lease dłuższy niż to i odnawiaj go w razie potrzeby. W przeciwnym razie inny worker może je podjąć środku wykonania.
Dbaj o tabelę zadań. Jeśli ukończone zadania będą gromadzić się bez końca, zapytania spowolnią się, a kontencja blokad wzrośnie. Ustal prostą politykę retencji (archiwum lub usuwanie starych wierszy), zanim tabela urośnie za bardzo.
Zanim wdrożysz ten wzorzec, sprawdź podstawy. Małe pominięcia tu zwykle powodują zablokowane zadania, niespodziewane duplikaty lub worker, który zalewa bazę.
run_at, status, attempts, locked_until i max_attempts (plus last_error lub podobne, żeby widzieć, co się stało).invoice_id).max_attempts.Jeśli to prawda, wzorzec cron + baza danych jest zwykle wystarczająco stabilny dla rzeczywistych obciążeń.
Gdy lista kontrolna jest w porządku, skup się na operacjach dnia codziennego.
run_at = now() i czyści blokadę) oraz „cancel” (przenosi do stanu terminalnego). To oszczędza czas podczas incydentów.status, run_at).Jeśli chcesz szybko zbudować takie rozwiązanie, Koder.ai (koder.ai) może pomóc przejść od schematu do wdrożonej aplikacji Go + PostgreSQL z mniejszą ilością manualnej roboty, podczas gdy ty skupisz się na zasadach blokowania, retry i idempotencji.
Jeśli później przerastasz to rozwiązanie, nadal będziesz rozumieć cykl życia zadania — te same ideje dobrze mapują się na pełny system kolejek.