Oban - dlaczego kolejka zadań na PostgreSQL to najlepsza decyzja architektoniczna w Elixirze
Każdy system, który robi cokolwiek poza wyświetlaniem stron, potrzebuje kolejki zadań. Wyślij email po złożeniu zamówienia. Wygeneruj fakturę PDF. Zsynchronizuj dane z magazynem. Przelicz raport nocny. Wyślij powiadomienie push. Zaimportuj plik CSV z 500 000 wierszami.
Te operacje muszą dziać się w tle - użytkownik nie może czekać 30 sekund na wygenerowanie PDF-a. Muszą być niezawodne - jeśli serwer padnie w trakcie wysyłania emaila, email musi zostać wysłany po restarcie. I muszą być kontrolowane - nie możesz wysłać 10 000 emaili naraz, bo dostawca poczty Cię zablokuje.
W świecie Java to Celery albo Bull. W Pythonie - Celery z Redisem albo RabbitMQ. W Elixirze to Oban - i to jest jedyna kolejka, której potrzebujesz.
Czym jest Oban
Oban to biblioteka Elixira do przetwarzania zadań w tle. Ale "biblioteka do przetwarzania zadań" to jak powiedzenie, że PostgreSQL to "program do przechowywania danych". Oban to kompletny system, który obejmuje:
- Kolejkowanie zadań z priorytetami i limitem współbieżności
- Gwarancja dostarczenia - zadanie nie zaginie, nawet jeśli serwer padnie
- Retry z backoff - automatyczne ponawianie przy błędach
- Harmonogramy (cron) - cykliczne zadania o ustalonej porze
- Unikalne zadania - "nie twórz duplikatu, jeśli takie zadanie już czeka"
- Historyczność - pełna historia wykonań, błędów, czasów
- Monitorowanie - wbudowany dashboard (Oban Web)
I robi to wszystko na jednej technologii: PostgreSQL. Nie potrzebujesz Redisa. Nie potrzebujesz RabbitMQ. Nie potrzebujesz Kafki. Twoja baza danych, którą i tak masz, jest jednocześnie Twoim brokerem wiadomości.
Dlaczego PostgreSQL, a nie Redis
To jest kluczowa decyzja architektoniczna Obana i powód, dla którego go wybieramy.
Transakcyjna spójność
Oto scenariusz, który zdarza się w każdym systemie e-commerce:
- Użytkownik składa zamówienie
- System zapisuje zamówienie do bazy
- System dodaje zadanie "wyślij email z potwierdzeniem" do kolejki
- System dodaje zadanie "powiadom magazyn" do kolejki
Co się stanie, jeśli krok 2 się uda, ale serwer padnie przed krokiem 3? Zamówienie istnieje w bazie, ale email nigdy nie zostanie wysłany. Klient nie dostanie potwierdzenia. Magazyn nie wie o zamówieniu.
W systemie z Redisem jako kolejką musisz to rozwiązać sam. Transakcja do bazy i wrzucenie do Redisa to dwie osobne operacje. Nie ma gwarancji, że obie się udadzą. To jest problem znany jako "dual write" i jest koszmarnie trudny do rozwiązania poprawnie.
W Obanie zamówienie i zadania powstają w jednej transakcji PostgreSQL:
Ecto.Multi.new()
|> Ecto.Multi.insert(:zamowienie, zamowienie_changeset)
|> Oban.insert(:email, WyslijPotwierdzenie.new(%{zamowienie_id: id}))
|> Oban.insert(:magazyn, PowiadomMagazyn.new(%{zamowienie_id: id}))
|> Repo.transaction()Jeśli INSERT zamówienia się nie powiedzie - zadania nie powstają. Jeśli którekolwiek zadanie nie może być zakolejkowane - zamówienie nie powstaje. Albo wszystko albo nic. To nie jest feature - to jest gwarancja, którą daje ACID w PostgreSQL.
Żaden system oparty na Redisie nie może dać tej gwarancji, bo Redis i PostgreSQL to dwie osobne bazy danych bez wspólnej transakcji.
Trwałość danych
Redis domyślnie trzyma dane w pamięci RAM. Restart serwera = utrata zadań. Tak, Redis ma RDB i AOF (mechanizmy persistencji), ale nawet z nimi istnieje okno czasowe, w którym dane mogą zaginąć.
PostgreSQL zapisuje dane na dysk z pełną gwarancją trwałości (WAL + fsync). Serwer może paść w dowolnym momencie - po restarcie wszystkie zakolejkowane zadania nadal czekają. Nie ma okna utraty danych.
Jedno źródło prawdy
Z Obanem Twoje dane biznesowe i Twoje zadania są w tej samej bazie danych. To oznacza:
- Jeden backup - backup PostgreSQL obejmuje i dane, i kolejkę
- Jedna replikacja - replika bazy ma i dane, i historię zadań
- Jedno monitorowanie -
pg_stat_statementspokazuje zarówno zapytania biznesowe, jak i operacje kolejki - Jedna migracja -
mix ecto.migrateustawia i schemat danych, i tabelę Obana - Jeden punkt awarii mniej - nie ma sytuacji "baza działa, ale Redis padł"
Wystarczająca wydajność
"Ale Redis jest szybszy!" - owszem, Redis jest szybszy w operacjach klucz-wartość. Ale pytanie brzmi: czy potrzebujesz tej szybkości?
Oban na PostgreSQL obsługuje dziesiątki tysięcy zadań na minutę na typowym serwerze. Dla porównania:
- 10 000 emaili na minutę? Twój dostawca emaili (SendGrid, Mailgun) ma limit 100-1000/minutę
- 1 000 PDF-ów na minutę? Generowanie jednego PDF-a trwa 200ms
- 5 000 synchronizacji z magazynem na minutę? API magazynu ma rate limit 100/minutę
W praktyce wąskie gardło to nigdy Oban. To zewnętrzne API, I/O, czas generowania. Oban na PostgreSQL jest szybszy niż cokolwiek, co za nim stoi.
Anatomia zadania w Obanie
Zadanie w Obanie to moduł Elixira z jedną funkcją perform/1:
defmodule MojSystem.Workers.WyslijPotwierdzenie do
use Oban.Worker,
queue: :email,
max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"zamowienie_id" => zamowienie_id}}) do
zamowienie = Zamowienia.get!(zamowienie_id)
klient = Klienci.get!(zamowienie.klient_id)
case Mailer.send_potwierdzenie(klient.email, zamowienie) do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
end
endTyle. Zwrócenie :ok oznacza sukces. Zwrócenie {:error, reason} oznacza błąd - Oban ponowi zadanie zgodnie z konfiguracją. Jeśli perform/1 rzuci wyjątek - Oban złapie go, zapisze stack trace i ponowi.
Tworzenie zadania
# Natychmiast
%{zamowienie_id: 123}
|> MojSystem.Workers.WyslijPotwierdzenie.new()
|> Oban.insert()
# Z opóźnieniem - wyślij za 30 minut
%{zamowienie_id: 123}
|> MojSystem.Workers.WyslijPotwierdzenie.new(scheduled_at: DateTime.add(DateTime.utc_now(), 30, :minute))
|> Oban.insert()
# Z priorytetem (0 = najwyższy, 3 = domyślny)
%{zamowienie_id: 123}
|> MojSystem.Workers.WyslijPotwierdzenie.new(priority: 0)
|> Oban.insert()Funkcje, które eliminują potrzebę zewnętrznych narzędzi
Kolejki z limitem współbieżności
Konfiguracja kolejek w Obanie jest deklaratywna:
config :moj_system, Oban,
repo: MojSystem.Repo,
queues: [
email: 10, # Max 10 emaili jednocześnie
pdf: 3, # Max 3 PDF-y (ciężkie obliczeniowo)
import: 1, # Importy jeden po drugim (żeby nie zabić bazy)
webhooks: 20, # Webhooki mogą iść równolegle
default: 5 # Reszta
]Każda kolejka to osobna pula workerów z osobnym limitem. Ciężki import CSV z milionem wierszy nie zablokuje wysyłki emaili. Generowanie 50 PDF-ów nie spowolni webhooków. Izolacja jest wbudowana.
Zmiana limitów? Zmiana jednej liczby w konfiguracji i restart aplikacji. W systemie z RabbitMQ to zmiana konfiguracji brokera, restartowanie konsumerów, sprawdzanie czy nic nie zaginęło.
Retry z exponential backoff
Zewnętrzne API padają. Serwery mailowe odrzucają połączenia. Bazy danych mają momenty przeciążenia. Oban obsługuje to automatycznie:
use Oban.Worker,
queue: :email,
max_attempts: 10 # Ponów do 10 razy
# Domyślny backoff: attempt^4 sekund
# Próba 1: natychmiast
# Próba 2: za 16 sekund
# Próba 3: za 81 sekund
# Próba 4: za 256 sekund (~4 minuty)
# Próba 5: za 625 sekund (~10 minut)
# ...
# Własna strategia backoff
@impl Oban.Worker
def backoff(%Oban.Job{attempt: attempt}) do
# Liniowy backoff: 60 sekund * numer próby
attempt * 60
endPo wyczerpaniu prób zadanie trafia do stanu discarded z pełnym logiem błędów. Możesz je przejrzeć, naprawić problem i ponowić ręcznie.
Unikalne zadania
Klasyczny problem: użytkownik klika "Wyślij" 5 razy, bo strona nie reaguje. Bez ochrony - 5 emaili. Z Obanem:
use Oban.Worker,
queue: :email,
unique: [period: 300, fields: [:args, :queue]]
# Przez 5 minut nie zostanie utworzone drugie zadanie
# z tymi samymi argumentami w tej samej kolejceAlbo inny scenariusz: cron generuje raport co godzinę, ale raport trwa 2 godziny. Bez ochrony nakładają się na siebie. Z unique: [states: [:available, :executing]] - drugie zadanie nie powstanie, dopóki pierwsze nie skończy.
Harmonogramy (cron)
Nie potrzebujesz systemowego crona, który jest niewidoczny w kodzie i ginie przy wymianie serwera. Oban ma wbudowany cron:
config :moj_system, Oban,
repo: MojSystem.Repo,
queues: [email: 10, reports: 2],
plugins: [
{Oban.Plugins.Cron, crontab: [
{"0 8 * * 1", MojSystem.Workers.RaportTygodniowy}, # Poniedziałki o 8:00
{"0 0 1 * *", MojSystem.Workers.FakturyMiesieczne}, # 1. dzień miesiąca
{"*/15 * * * *", MojSystem.Workers.SynchronizujMagazyn}, # Co 15 minut
{"0 3 * * *", MojSystem.Workers.CzyscStareDane}, # Codziennie o 3:00
]}
]Harmonogram jest w kodzie. Jest w repozytorium. Jest w code review. Jest w historii git. Nie na karteczce, nie w głowie Tomka, nie ręcznie wpisany na serwerze.
Przycięcie historii
Oban zapisuje historię wszystkich zadań. Bez czyszczenia tabela rosłaby w nieskończoność. Plugin Pruner rozwiązuje to automatycznie:
plugins: [
{Oban.Plugins.Pruner, max_age: 60 * 60 * 24 * 30} # Czyść po 30 dniach
]Zadania starsze niż 30 dni są automatycznie usuwane. Ale zanim zostaną usunięte, masz pełną historię: kiedy zadanie powstało, kiedy było wykonane, ile prób zajęło, jakie błędy wystąpiły, ile trwało. To jest audytowalność, której nie ma w Redisie.
Realne scenariusze w systemach biznesowych
E-commerce: cykl życia zamówienia
Jedno zamówienie generuje kaskadę zadań w tle:
def handle_event("zloz_zamowienie", params, socket) do
Ecto.Multi.new()
|> Ecto.Multi.insert(:zamowienie, build_zamowienie(params))
|> Oban.insert(:potwierdzenie, WyslijPotwierdzenie.new(%{...}))
|> Oban.insert(:magazyn, RezerwujStan.new(%{...}))
|> Oban.insert(:platnosc, InicjujPlatnosc.new(%{...}))
|> Repo.transaction()
endPo udanej płatności:
def perform(%{args: %{"zamowienie_id" => id}}) do
zamowienie = Zamowienia.potwierdz_platnosc!(id)
# Kaskada kolejnych zadań
WygenerujFakture.new(%{zamowienie_id: id}) |> Oban.insert()
WyslijDoKuriera.new(%{zamowienie_id: id}) |> Oban.insert()
AktualizujStatystyki.new(%{zamowienie_id: id}) |> Oban.insert()
PowiadomKlienta.new(%{zamowienie_id: id, status: "oplacone"}) |> Oban.insert()
:ok
endKażde zadanie jest niezależne. Jeśli kurier nie odpowiada - Oban ponowi. Jeśli generator PDF padnie - email z potwierdzeniem i tak poszedł. Izolacja awarii na poziomie zadań, nie na poziomie zamówienia.
ERP: nocna synchronizacja danych
System ERP musi co noc zsynchronizować dane z 5 zewnętrznymi systemami:
# Cron odpala o 2:00 w nocy
{"0 2 * * *", SynchronizacjaNocna}
# Worker orkiestrator
def perform(_job) do
SyncKontrahenci.new(%{}) |> Oban.insert()
SyncProdukty.new(%{}) |> Oban.insert()
SyncCeny.new(%{}) |> Oban.insert()
SyncStanyMagazynowe.new(%{}) |> Oban.insert()
SyncKursy.new(%{}) |> Oban.insert()
:ok
endKażda synchronizacja to osobne zadanie w osobnej kolejce. Jeśli API dostawcy cen padło - reszta synchronizacji idzie normalnie. Rano administrator widzi w dashboardzie: 4 synchro OK, 1 failed (ceny - timeout API). Ponawia jednym kliknięciem.
SaaS: onboarding nowego klienta
Rejestracja klienta w systemie SaaS to sekwencja zadań:
# Synchronicznie - to musi być szybkie
{:ok, user} = Accounts.create_user(params)
# Asynchronicznie - to może trwać
UtworzOrganizacje.new(%{user_id: user.id}) |> Oban.insert()
WyslijEmailPowitalny.new(%{user_id: user.id}) |> Oban.insert()
ProvisionDatabase.new(%{user_id: user.id}) |> Oban.insert()
ImportujDaneDemowe.new(%{user_id: user.id}) |> Oban.insert()
PowiadomZespol.new(%{user_id: user.id, channel: "sales"}) |> Oban.insert()Użytkownik widzi stronę "Konfigurujemy Twoje konto..." z postępem. LiveView aktualizuje status w czasie rzeczywistym, gdy kolejne zadania się kończą. Po 30 sekundach - konto gotowe. Bez Obana użytkownik czekałby te 30 sekund na białej stronie.
Import danych: duże pliki
Import CSV z 500 000 wierszami nie może zablokować systemu:
# Worker dzieli plik na paczki po 1000 wierszy
def perform(%{args: %{"file_path" => path}}) do
path
|> File.stream!()
|> CSV.decode(headers: true)
|> Stream.chunk_every(1000)
|> Stream.with_index()
|> Enum.each(fn {chunk, batch_number} ->
ImportujPaczke.new(%{
rows: chunk,
batch: batch_number
})
|> Oban.insert()
end)
:ok
end500 paczek po 1000 wierszy. Każda paczka to osobne zadanie. Mogą być przetwarzane równolegle (limit kolejki kontroluje ile naraz). Jeśli paczka nr 347 się nie uda - tylko ona jest ponawiana. Postęp importu jest widoczny w czasie rzeczywistym.
Oban vs alternatywy
Oban vs Celery (Python + Redis/RabbitMQ)
| Aspekt | Oban | Celery |
|---|---|---|
| Infrastruktura | PostgreSQL (masz już) | Redis lub RabbitMQ (dodatkowy serwer) |
| Transakcyjność | Pełna (wspólna transakcja z danymi) | Brak (dual write problem) |
| Monitorowanie | Oban Web (wbudowany) | Flower (osobna aplikacja) |
| Konfiguracja | Elixir config | Osobny plik + konfiguracja brokera |
| Retry | Wbudowane z backoff | Wbudowane, ale konfiguracja złożona |
| Cron | Plugin, jedna linia | Celery Beat (osobny proces) |
| Unikalne zadania | Wbudowane | Wymaga dodatkowej logiki |
| Niezawodność | PostgreSQL WAL (zero data loss) | Redis: okno utraty danych |
Oban vs Sidekiq (Ruby + Redis)
Sidekiq to złoty standard w świecie Ruby. Oban jest jawnie inspirowany Sidekiqiem - ale rozwiązuje jego fundamentalny problem: zależność od Redisa. Sidekiq Pro (płatna wersja) dodaje reliability features, które Oban ma za darmo dzięki PostgreSQL (transakcyjność, trwałość).
Oban vs Bull/BullMQ (Node.js + Redis)
Ten sam wzorzec: świetna biblioteka, ale wymaga Redisa. I ten sam problem: brak transakcyjności z bazą danych aplikacji. W Node.js nie masz też izolacji procesów BEAM, więc zadanie, które zużywa dużo CPU, blokuje event loop.
Czego Oban nie robi (i co jest OK)
Pub/sub w czasie rzeczywistym - Oban to kolejka zadań, nie broker wiadomości. Do pub/sub w Elixirze masz Phoenix PubSub + LISTEN/NOTIFY w PostgreSQL.
Event streaming - jeśli potrzebujesz Kafka-style event log z wielodniową retencją i consumer groups, Oban nie jest do tego. Ale pytanie brzmi: czy naprawdę tego potrzebujesz?
Komunikacja między serwisami - Oban działa w ramach jednej aplikacji. Do komunikacji między mikroserwisami potrzebujesz HTTP/gRPC lub message brokera. Ale... czy na pewno potrzebujesz mikroserwisów?
Oban Pro i Oban Web
Oban ma wersję open source (darmową) i komercyjną (Pro). Różnice:
Oban (darmowy):
- Kolejki, retry, cron, unikalne zadania
- Wszystko, co opisaliśmy wyżej
- Wystarczający dla 90% projektów
Oban Pro (płatny):
- Workflows - zależności między zadaniami ("wygeneruj PDF po opłaceniu zamówienia")
- Batch processing - grupowanie zadań z callbackiem po zakończeniu całej grupy
- Chunk - przetwarzanie dużych zbiorów partiami z kontrolą współbieżności
- Smart Engine - zaawansowane strategie rate limiting i partycjonowania
- Oban Web - dashboard do monitorowania, ponownego uruchamiania, przeglądania historii
Dla systemu ERP z setkami typów zadań Oban Pro się zwraca w pierwszym tygodniu - same Workflows eliminują godziny kodu koordynacyjnego.
Testowanie zadań
Oban ma wbudowane wsparcie dla testów - bez uruchamiania prawdziwej kolejki:
# W test_helper.exs
config :moj_system, Oban, testing: :inline # Zadania wykonują się synchronicznie
# W teście
test "złożenie zamówienia tworzy zadanie emaila" do
assert {:ok, %{zamowienie: zamowienie}} =
Zamowienia.zloz(%{produkt_id: 1, ilosc: 2})
# Sprawdź, że zadanie zostało zakolejkowane
assert_enqueued worker: WyslijPotwierdzenie,
args: %{zamowienie_id: zamowienie.id}
end
test "worker emaila wysyła potwierdzenie" do
zamowienie = insert(:zamowienie)
# Wykonaj zadanie bezpośrednio
assert :ok = perform_job(WyslijPotwierdzenie, %{zamowienie_id: zamowienie.id})
# Sprawdź efekt
assert_email_sent to: zamowienie.klient.email
endTesty są szybkie (milisekundy), deterministyczne (brak race conditions) i nie wymagają uruchomionej bazy danych ani brokera. Tryb :inline wykonuje zadania synchronicznie w procesie testowym - pełna kontrola.
Dlaczego Oban w każdym naszym projekcie
Oban jest częścią naszego standardowego stacku nie dlatego, że jest modny. Dlatego, że eliminuje całą kategorię problemów architektonicznych:
- Nie zarządzamy Redisem - bo go nie mamy
- Nie debugujemy dual write - bo transakcja jest jedna
- Nie tracimy zadań - bo PostgreSQL nie traci danych
- Nie piszemy kodu retry - bo Oban robi to za nas
- Nie zarządzamy cronem na serwerze - bo harmonogram jest w kodzie
- Nie szukamy zduplikowanych zadań - bo unikalność jest wbudowana
Jedna zależność Hex. Jedna tabela w PostgreSQL. Zero dodatkowej infrastruktury. System kolejek gotowy na produkcję w 10 minut od dodania do projektu.
Budujesz system, który wymaga przetwarzania w tle, i nie chcesz zarządzać Redisem ani RabbitMQ? Porozmawiajmy - pokażemy, jak Oban upraszcza architekturę i redukuje koszty operacyjne.