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

Problem: zaplanowane zadania bez dodatkowej infrastruktury
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:
- Podwójne uruchomienia: dwa serwery wykonują to samo zadanie — faktury albo maile wysyłane są dwa razy.
- Utracone uruchomienia: wywołanie crona kończy się błędem podczas deploymentu i nikt tego nie zauważa, aż użytkownicy zgłoszą problem.
- Ciche błędy: zadanie raz się wywali i już nigdy nie zostanie uruchomione, bo nie ma planu retry.
- Częściowa praca: zadanie przerywa się w połowie i zostawia dane w dziwnym stanie.
- Brak śladu audytu: nie potrafisz odpowiedzieć „kiedy to ostatnio działało?” albo „co się stało wczoraj wieczorem?”.
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ą.
Główna idea prostym językiem
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:
- Jeden trigerring harmonogram uruchamia się często.
- Tabela jobs przechowuje „co” i „kiedy” (czas wykonania), plus status i liczbę prób.
- Jeden lub więcej workerów sprawdza tabelę, rezerwuje zadanie i wykonuje pracę.
- Rezerwacja używa blokady w bazie, żeby dwa workery nie wzięły tego samego wiersza.
- Baza pozostaje źródłem prawdy o tym, co wykonało się, co się nie powiodło i co powinno być ponowione.
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.
Projektowanie tabeli jobs (praktyczne schema)
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.
Jedna tabela czy dwie?
Dwa popularne podejścia:
- Jedna połączona tabela, gdy zależy ci tylko na najnowszym stanie każdego zadania (prostsze, mniej joinów).
- Dwie tabele, gdy chcesz oddzielić „czym jest to zadanie” od „każdego uruchomienia” (lepsza historia, łatwiejsze debugowanie).
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.
Praktyczne schema (wersja z dwiema tabelami)
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:
- Trzymaj job_type jako krótki string, po którym będziesz routować (np.
send_invoice_emails). - Przechowuj payload jako
jsonb, aby móc go ewoluować bez migracji. - run_at to „następny termin wykonania”. Cron (lub skrypt harmonogramu) go ustawia, worker go konsumuje.
- locked_by i locked_until pozwalają workerom rezerwować zadania bez wchodzenia sobie w drogę.
- last_error powinien być krótki i czytelny dla człowieka. Stack trace'y trzymaj gdzie indziej, jeśli ich potrzebujesz.
Indeksy, które będziesz chciał mieć
Bez indeksów workerzy będą przeszukiwać zbyt dużo wierszy. Zacznij od:
- Indeksu do szybkiego znajdowania zaległej pracy:
(status, run_at) - Indeksu pomagającego wykrywać wygasłe blokady:
(locked_until) - Opcjonalnie: indeksu częściowego dla aktywnej pracy (np. status w
queuedifailed)
To utrzyma zapytanie „znajdź następne wykonalne zadanie” szybkim nawet przy rosnącej tabeli.
Blokowanie i bezpieczne rezerwowanie zadań
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.
Użyj lease, żeby awarie nie blokowały pracy na zawsze
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().
Rezerwuj zadania jednym atomicznym update'em
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 LOCKEDpozwala wielu workerom rywalizować bez blokowania się nawzajem.- Leasing ustawia się w momencie rezerwacji, więc inni workerzy go ignorują, dopóki nie wygaśnie.
RETURNINGprzekazuje wiersz workerowi, który wygrał wyścig.
Jak długi powinien być lease i jak go odnawiać?
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.
- Długość lease: 5x–20x typowego czasu zadania
- Interwał heartbeat: 1/4 do 1/2 lease
- Aktualizacja odnowienia powinna zawierać
WHERE id = $job_id AND locked_by = $worker_id
Ten ostatni warunek ma znaczenie. Zapobiega on przedłużaniu lease na zadanie, którego worker już nie posiada.
Ponawianie prób i backoff, które działają przewidywalnie
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:
- zwiększyć
attempts - ustawić stan na
failedjeśliattempts < max_attempts, w przeciwnym raziedead - obliczyć
run_atdla następnej próby (tylko dlafailed)
Backoff to reguła decydująca o następnym run_at. Wybierz jedną, udokumentuj i trzymaj się jej:
- Stałe opóźnienie: zawsze czekaj 1 minutę
- Wykładnicze: 1m, 2m, 4m, 8m
- Wykładnicze z limitem: wykładnicze, ale nigdy więcej niż np. 30m
- Dodaj jitter: losuj niewielkie przesunięcie, by zadania nie retryowały się dokładnie w tym samym momencie
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_codeluberror_type(pomaga grupować)failed_atinext_run_at- opcjonalnie
last_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: zapobieganie duplikatom nawet gdy zadanie się powtarza
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.
Użyj klucza idempotencji powiązanego z wydarzeniem biznesowym
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.
Wymuś „tylko raz” za pomocą ograniczeń bazy danych
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ć:
- Maile: utwórz wiersz
sent_emailsz unikalnym kluczem przed wysłaniem, albo zapisz identyfikator wiadomości dostawcy po wysłaniu. - Webhooki: zapisz
delivered_webhooks(event_id)i pomiń, jeśli już istnieje. - Płatności: zawsze korzystaj z idempotencji dostawcy płatności oraz własnego unikalnego klucza w bazie.
- Zapis plików: zapisuj tymczasowo, a potem zmieniaj nazwę, lub przechowuj rekord
file_generatedkluczowany 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.
Krok po kroku: zbuduj minimalnego workera i scheduler
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.
Zbuduj to w pięciu małych krokach
-
Utwórz tabele i indeksy. Dodaj tabelę
jobs(plus ewentualne tabele pomocnicze), zaindeksujrun_ati 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_atustawionym 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);
- Zaimplementuj pętlę rezerwacji. Uruchamiaj ją w transakcji. Wybierz kilka zaległych zadań, zablokuj je tak, by inni workerzy je pominęli, i ustaw je w tym samym czasie jako
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
donezfinished_at. Jeśli się nie powiedzie, zapisz wiadomość o błędzie i przenieś z powrotem doqueuedz nowymrun_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 pomax_attemptsprzez ustawieniestatus = 'dead'.
Dodaj podstawową widoczność
Na dzień dobry nie potrzebujesz pełnego dashboardu, ale musisz widzieć problemy.
- Loguj jedną linię na zadanie: zarezerwowane, zakończone, nieudane, ponowione, martwe.
- Stwórz proste zapytanie lub widok dla „martwych zadań” i „starych uruchomień w stanie running”.
- Alertuj na rosnącą liczbę błędów (np. więcej niż N martwych zadań w ciągu ostatniej godziny).
Jeśli już używasz Go + PostgreSQL, to ładnie mapuje się na jeden binarny worker plus cron.
Realistyczny przykład, który możesz skopiować
Wyobraź sobie małą aplikację SaaS z dwoma zadaniami zaplanowanymi:
- Nocne czyszczenie, które usuwa wygasłe sesje i stare pliki tymczasowe.
- Cotygodniowy mail „twój raport aktywności” wysyłany do każdego użytkownika w poniedziałek rano.
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ę.
Co jest enqueued i kiedy
Możesz enqueue'ować zadania z kilku miejsc:
- Codziennie o 02:00: enqueue jedno zadanie
cleanup_nightlydla „dzisiaj”. - Przy rejestracji: enqueue
send_weekly_reportdla użytkownika na następny poniedziałek. - Po zdarzeniu (np. „użytkownik kliknął Eksportuj raport”): enqueue
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"
}
}
}
Jak idempotencja zapobiega podwójnemu wysyłaniu
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ń.
Jak wygląda awaria (i jak się odzyskuje)
Powiedzmy, że dostawca maili timeoutuje. Zadanie się nie powiedzie, więc worker:
- zwiększa
attempts - zapisuje wiadomość o błędzie do debugowania
- planuje następną próbę z backoffem (np. +1 min, +5 min, +30 min, +2 godziny)
Jeś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.
Częste błędy i pułapki
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.
Błędy powodujące duplikaty lub zablokowane zadania
Większość incydentów bierze się z kilku pułapek:
- Uruchamianie tego samego zadania z wielu wpisów w cronie bez lease. Jeśli dwa serwery „tykają” w tej samej minucie, oba mogą chcieć wykonać pracę, jeśli krok rezerwacji nie jest atomowy i nie ustawia blokady (lub lease) w tej samej transakcji.
- Pomijanie
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. - Retryowanie natychmiast po błędzie. Gdy API jest niedostępne, natychmiastowe retryy tworzą falę żądań, zużywają limity i ciągle zawodzą. Zawsze planuj następną próbę w przyszłości.
- Traktowanie „at least once” jak „exactly once”. Zadanie może uruchomić się dwa razy (timeouty, restarty workerów, problemy sieciowe). Jeśli powtarzanie jest szkodliwe, zabezpiecz efekty.
- Przechowywanie ogromnych payloadów w wierszu zadania. Duże JSONy rozdmuchują tabelę, spowalniają indeksy i utrudniają blokowanie. Przechowuj referencję (np.
user_id,invoice_idlub 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).
Mniej oczywiste pułapki
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.
Szybka lista kontrolna i następne kroki
Szybka lista kontrolna
Zanim wdrożysz ten wzorzec, sprawdź podstawy. Małe pominięcia tu zwykle powodują zablokowane zadania, niespodziewane duplikaty lub worker, który zalewa bazę.
- Twoja tabela jobs ma niezbędne pola:
run_at,status,attempts,locked_untilimax_attempts(pluslast_errorlub podobne, żeby widzieć, co się stało). - Każde zadanie może bezpiecznie wykonać się dwa razy. Jeśli nie jesteś pewien, dodaj klucz idempotencji lub regułę unikalności wokół efektu (np. jedna faktura na
invoice_id). - Jest jasne miejsce do obserwowania błędów i decyzji: przegląd martwych zadań, ponowne uruchomienie zadania lub oznaczenie jako dead, gdy ma przestać retryować.
- Timeout lease jest rozsądny dla pracy. Powinien być dłuższy niż normalne wykonania, ale na tyle krótki, że awarie nie blokują postępu przez godziny.
- Backoff retryów jest przewidywalny. Powinien spowalniać powtarzające się błędy i zatrzymywać się po
max_attempts.
Jeśli to prawda, wzorzec cron + baza danych jest zwykle wystarczająco stabilny dla rzeczywistych obciążeń.
Następne kroki
Gdy lista kontrolna jest w porządku, skup się na operacjach dnia codziennego.
- Dodaj dwie proste akcje administracyjne: „retry now” (ustawia
run_at = now()i czyści blokadę) oraz „cancel” (przenosi do stanu terminalnego). To oszczędza czas podczas incydentów. - Spraw, by worker logował jedną linię na zadanie: typ, id zadania, numer próby i wynik. Dodaj alert na rosnące liczby błędów.
- Przetestuj obciążenie realistycznym spike'em: wiele zadań zaplanowanych na tę samą minutę. Jeśli rezerwacja zadań się spowalnia, dodaj właściwy indeks (często na
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.
Często zadawane pytania
Kiedy warto użyć crona z bazą danych do zadań w tle?
Stosuj ten wzorzec, gdy masz już bazę danych, potrzebujesz tylko umiarkowanej liczby typów zadań i chcesz planować pracę bez utrzymywania osobnej kolejki. Sprawdza się w zadaniach takich jak przypomnienia e-mailowe, czyszczenie, raporty i odświeżanie pamięci podręcznej.
Co robi cron w tej konfiguracji?
Cron powinien jedynie uruchamiać worker w regularnych odstępach, często raz na minutę. Baza danych decyduje, które zadania są już wymagalne, zapisuje ich stan i pozwala jednemu workerowi przejąć każde z nich.
Jakie pola powinna zawierać tabela zadań?
Przechowuj typ zadania, niewielki ładunek JSON, termin wykonania, status, liczbę prób, limit ponowień, właściciela blokady, czas wygaśnięcia blokady oraz krótki komunikat o błędzie. Te pola dają workerom wystarczające informacje, aby bezpiecznie wykonywać i odzyskiwać zadania.
Jak wielu workerów unika uruchomienia tego samego zadania?
Przejmuj zadania w jednej transakcji bazy danych z blokowaniem wierszy, na przykład PostgreSQL FOR UPDATE SKIP LOCKED. Zaktualizuj wiersz do stanu wykonywania i ustaw jego dzierżawę, zanim worker rozpocznie właściwe zadanie.
Dlaczego zadania w tle potrzebują limitu czasu dzierżawy?
Dzierżawa to tymczasowa blokada zapisana w polu locked_until. Jeśli worker ulegnie awarii, dzierżawa wygasa i inne worker może przejąć zadanie, zamiast pozostawić je zablokowane na zawsze.
Jak ponawiać nieudane zadania?
Ponawiaj próbę później według jasnej reguły opóźnienia, na przykład po 1 minucie, 2 minutach, 4 minutach, a następnie z ograniczonym opóźnieniem i odrobiną losowości. Po wykorzystaniu dozwolonej liczby prób oznacz zadanie jako martwe, aby przestało zużywać czas workera.
Czym jest idempotencja i dlaczego ma znaczenie?
Zadbaj o to, by efekt uboczny można było bezpiecznie powtórzyć. Na przykład użyj stabilnego klucza idempotencji, takiego jak identyfikator faktury albo identyfikator użytkownika wraz z datą raportu, a następnie wymuś unikalność w PostgreSQL lub po stronie zewnętrznego dostawcy.
Czy ten wzorzec gwarantuje przetwarzanie dokładnie raz?
Nie. Worker może ulec awarii po wysłaniu e-maila lub wywołaniu dostawcy płatności, ale przed zapisaniem sukcesu. Projektuj pod kątem przetwarzania co najmniej raz, a następnie używaj idempotencji, by zapobiegać zduplikowanym efektom.
Czy worker powinien utrzymywać transakcję bazy danych podczas wykonywania zadania?
Utrzymuj krótkie transakcje bazy danych. Przejmij zadanie, zatwierdź transakcję, wykonaj wywołania sieciowe poza transakcją, a potem zapisz sukces lub porażkę w osobnej aktualizacji. Długie transakcje utrzymują blokady i spowalniają inne workery.
Jak monitorować zadania crona i bazy danych?
Śledź martwe zadania i stare zadania w trakcie wykonywania, rejestruj każde przejęcie i wynik oraz wysyłaj alerty, gdy liczba błędów rośnie. Dodaj proste działania administracyjne, aby od razu ponowić zadanie, anulować je lub sprawdzić jego najnowszy błąd.