7 min

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.

Wzorzec cron + baza danych: zadania w tle bez kolejki

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 queued i failed)

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 LOCKED pozwala wielu workerom rywalizować bez blokowania się nawzajem.
  • Leasing ustawia się w momencie rezerwacji, więc inni workerzy go ignorują, dopóki nie wygaśnie.
  • RETURNING przekazuje 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

Dodaj ekran administracyjny zadań
Wygeneruj prostą wewnętrzną stronę do bezpiecznego przeglądania nieudanych i martwych zadań.

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 failed jeśli attempts < max_attempts, w przeciwnym razie dead
  • obliczyć run_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:

  • 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_code lub error_type (pomaga grupować)
  • failed_at i next_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_emails z 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_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.

Krok po kroku: zbuduj minimalnego workera i scheduler

Dodaj ponawianie prób, które łatwo wyjaśnić
Użyj Koder.ai, aby wdrożyć backoff, martwe zadania i pola audytu w bazie danych.

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

  1. 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)).

  2. 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);
  1. 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 *;
  1. 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.

  2. 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'.

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_nightly dla „dzisiaj”.
  • Przy rejestracji: enqueue send_weekly_report dla 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

Zbuduj worker z Postgres
Opisz tabelę zadań i pętlę workera w czacie, a wygenerujemy szkielet działającej aplikacji.

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_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).

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_until i max_attempts (plus last_error lub 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.

Related posts