Tworzenie niezawodnych systemów zadań w tle przy użyciu BullMQ i Redis
Dowiedz się, jak projektować odporne pipeline zadaniów w tle w Node.js przy użyciu BullMQ i Redis, omawiając ponawianie prób, równoczesność, idempotencję oraz monitorowanie.
Wysyłanie e-maila z potwierdzeniem, generowanie raportu, przetwarzanie płatności — wiele zadań w tle nie musi zostać ukończonych, zanim odpowiesz użytkownikowi. Ten przewodnik pokazuje, jak budować niezawodne systemy zadań w tle przy użyciu BullMQ w połączeniu z Redis.
Załóżmy sobie backend, w którym niemal każde zadanie wykonywane jest bezpośrednio w ramach cyklu żądania HTTP. Trzeba wysłać e-mail? Zrób to na miejscu. Potrzebny jest plik PDF? To samo. Musisz przetworzyć jakieś dane w tle? Rób to również bezpośrednio.
Taki podejście na początku działa dobrze. Potem przestaje być skuteczne.
API zaczyna zwalniać. Żądania zaczynają się timeoutować. A jeśli jakiś zewnętrzny serwis przestanie działać, całe żądanie może zawieść się razem z nim.
Dokładnie w tym momencie zadania w tle zaczynają mieć sens.
Zamiast zmuszać API do ukończenia każdego kroku przed udzieleniem odpowiedzi, możesz umieścić tę pracę w kolejce i pozwolić dedykowanemu procesowi zająć się nią osobno.
Dobrą opcją w ekosystemie Node.js jest BullMQ, wspierane przez Redis do przechowywania danych. Oto jak wszystko się łączy.
1. Czym jest zadanie w tle?
Zadanie w tle to każda jednostka pracy, która nie musi być wykonywana synchronicznie jako część żądania HTTP.
Rozważmy typowy proces rejestracji. Gdy ktoś tworzy konto, API może potrzebować:
- Stworzenia rekordu użytkownika
- Wysłania e-maila powitalnego
- Stworzenia PDF powitalnego
- Wysłania powiadomienia
- Zaktualizowania innego systemu
Moglibyś spróbować wykonać to wszystko bezpośrednio przed udzieleniem odpowiedzi:
Client
↓
API
↓
Create User
↓
Send Email
↓
Generate PDF
↓
Send Notification
↓
Response
Ale to zmusza użytkownika do czekania, aż wszystkie te kroki się zakończą.
Lepsze podejście wygląda w ten sposób:
Client
↓
API
↓
Create User
↓
Add Job to Queue
↓
Response
A osobno:
Queue
↓
Worker
↓
Send Email
↓
Done
Ponieważ API nie musi już wykonywać wszystkich zadań przed odpowiedzią, reaguje znacznie szybciej.
2. Dlaczego potrzebujemy kolejki?
Załóżmy, że wysłanie e-maila zajmuje 1 sekundę, generowanie PDF – 2 sekundy, a wywołanie innego API – 1 sekundę. Twój punkt końcowy może być zatrzymany na kilka sekund przed wysłaniem odpowiedzi – co stanowi kiepskie doświadczenie dla użytkownika.
Gorzej jeszcze, co się stanie, jeśli dostawca e-maili będzie niedostępny? Zapytanie może zawieść, mimo że utworzenie użytkownika faktycznie się udało. To niepotrzebna zależność pomiędzy dwoma niespowiązanymi elementami.
Kolejka eliminuje tę powiązanie:
┌──────────────┐
│ Node API │
└──────┬───────┘
↓
Add Job
↓
┌──────────────┐
│ Redis │
│ Queue │
└──────┬───────┘
↓
┌──────────────┐
│ Worker │
└──────┬───────┘
↓
Email / PDF / API / etc.
Dzięki takiej separacji API i zadania w tle mają po osobnej odpowiedzialności.
3. Czym jest BullMQ?
BullMQ to biblioteka do zarządzania kolejkami w Node.js, która wykorzystuje Redis do przechowywania i koordynacji zadań. Jej architektura, na wysokim poziomie, wygląda w ten sposób:
Producer
↓
Queue
↓
Worker
↓
Job Processing
Producer to element, który tworzy zadania. Kolejka przechowuje je. Worker to element, który faktycznie je przetwarza.
Na przykład:
await emailQueue.add("welcome-email", {
userId: user.id,
email: user.email
});
W istocie API mówi coś w stylu:
"Oto prace, które muszą zostać wykonane."
Sam nie musi wykonywać tych zadań.
4. Tworzenie kolejki
Oto, jak wygląda minimalna konfiguracja kolejki BullMQ:
import { Queue } from "bullmq";
const connection = {
host: "localhost",
port: 6379
};const emailQueue = new Queue("email", {
connection
});
Stamtąd można do niej dodawać zadania:
await emailQueue.add("welcome-email", {
userId: "123",
email: "user@example.com"
});
Redis zajmuje się w tle przechowywaniem całego stanu związanego z kolejką. Koncepcyjnie można to przedstawić w ten sposób:
email queue
Job 1
Job 2
Job 3
Job 4
Job 5
Pracownik następnie pobiera i przetwarza te zadania.
5. Tworzenie pracownika
Pracownik to element, który faktycznie wykonywa pracę:
import { Worker } from "bullmq";
const worker = new Worker(
"email",
async (job) => {
console.log("Processing:", job.name); await sendWelcomeEmail(
job.data.email
);
},
{
connection
}
);
Podsumowując, przepływ wygląda teraz w ten sposób:
API
↓
emailQueue.add()
↓
Redis
↓
Worker
↓
sendWelcomeEmail()
API nie musi czekać, aż e-mail zostanie wysłany – to właśnie jest główną zaletą zadań w tle.
6. Co się dzieje, gdy zadanie zawodzi?
Dokładnie w tym momencie kolejka pokazuje swoją prawdziwą przewagę nad zwykłą prośbą o usługę.
Pomyśl o takiej konfiguracji:
API
↓
Email Service
↓
ERROR
Gdy dzwonisz bezpośrednio do API, jesteś zmuszony natychmiast podjąć decyzję dotyczącą awarii.
Kolejka daje ci inną opcję: zadanie można po prostu spróbować ponownie.
Oto przykład:
await emailQueue.add(
"welcome-email",
{
email: "user@example.com"
},
{
attempts: 3
}
);
Dzięki tej konfiguracji zadanie może zostać wykonyane kilka razy, zanim podda się.
Wizualnie przepływ wygląda w ten sposób:
Attempt 1
↓
Failed
↓
Attempt 2
↓
Failed
↓
Attempt 3
↓
Success
Ten wzorzec jest niezwykle przydatny, gdy mamy do czynienia z niestabilnymi usługami third-party.
Mimo to próby ponownego wykonania nie powinny być nieskończone ani prowadzone bezrefleksyjnie.
Należy unikać sytuacji, w której zadanie bez końca próbuje się wykonać bez żadnych ograniczeń.
7. Ponowne próby z opóźnieniem
Załóżmy, że usługa zewnętrzna tymczasowo przestaje działać.
Należy unikać sytuacji podobnych do tej:
FAIL
RETRY IMMEDIATELY
FAIL
RETRY IMMEDIATELY
FAIL
RETRY IMMEDIATELY
Nagłe próby ponownego wykonania zadania wobec usługi, która ma problemy, mogą w rzeczywistości pogorszyć sytuację.
Rozwiązaniem jest wprowadzenie opóźnienia między poszczególnymi próbami.
Na przykład:
await emailQueue.add(
"welcome-email",
{
email: "user@example.com"
},
{
attempts: 5,
backoff: {
type: "exponential",
delay: 5000
}
}
);
Oto, jak to wygląda koncepcyjnie:
Attempt 1 → Fail
↓
5 sec
↓
Attempt 2 → Fail
↓
10 sec
↓
Attempt 3 → Fail
↓
20 sec
↓
Attempt 4 → Success
Dokładny moment wykonania zależy od sposobu konfiguracji strategii ponownych prób i opóźnień.
Ale podstawowa idea pozostaje ta sama:
Daj błędom tymczasowym szansę na naprawienie się przed ponowną próbą.
8. Zadania odroczone
Nie każde zadanie musi zostać wykonyane od razu po jego stworzeniu.
Naprzимер:
Wyślij przypomnienie 24 godziny po rejestracji.
BullMQ umożliwia planowanie wykonyania zadania później.
await emailQueue.add(
"reminder",
{
userId: "123"
},
{
delay: 24 * 60 * 60 * 1000
}
);
Koncepcyjnie:
Create Job
↓
Wait 24 hours
↓
Worker processes job
Ten wzorzec występuje w sytuacjach takich jak:
- E-maile z przypomnieniami
- Uprzedzenia zaplanowane w czasie
- Wygaśnięcie okresu próbnego
- Przypomnienia o płatnościach
- Wiadomości follow-up
9. Wielu pracowników
Załóż teraz, że system otrzymuje tysiące zadań na minutę.
Jeden proces pracownika może nie nadążyć.
Można zwiększyć wydajność, uruchamiając kilku pracowników jednocześnie:
Redis Queue
↓
┌──────────┼──────────┐
↓ ↓ ↓
Worker 1 Worker 2 Worker 3
↓ ↓ ↓
Jobs Jobs Jobs
Każdy z nich pobiera zadania z kolejki niezależnie.
Na przykład, jeśli mamy:
1000 email jobs
Można zobaczyć coś takiego:
Worker 1 → Job 1, 4, 7...
Worker 2 → Job 2, 5, 8...
Worker 3 → Job 3, 6, 9...
Dodawanie więcej pracowników to jeden ze sposobów zwiększenia przepustowości.
Ale bądź ostrożny:
Dodawanie więcej pracowników do rozwiązania problemu nie zawsze przynosi korzyści.
Twoja baza danych, dostawca usług e-mail, procesor, pamięć oraz wszelkie kolejne usługi mają swoje własne limity pojemności.
10. Konkurencja
Oprócz uruchamiania wielu procesów pracowników, BullMQ pozwala również skonfigurować, ile zadań może jednocześnie obsługiwać jeden pracownik.
Na przykład:
const worker = new Worker(
"email",
async (job) => {
await sendEmail(job.data.email);
},
{
connection,
concurrency: 5
}
);
To umożliwia jednemu pracownikowi przetwarzanie kilku zadań równolegle.
Koncepcyjnie:
Worker
├── Job 1
├── Job 2
├── Job 3
├── Job 4
└── Job 5
Wyższa konkurencja może zwiększyć przepustowość.
Ale nie zwiększaj konwergencji do 100 bez wcześniejszego przemyślenia tego kroku.
Jeśli każde zadanie dostępuje do twojej bazy danych, wysoka konwergencja może ją łatwo przeciążyć.
Ustawienia konwergencji powinny być dostosowywane tak, aby odpowiadały temu, co faktycznie może obsłużyć twoja obciążenie.
11. Ograniczanie szybkości
Czasami wąskie gardło wcale nie znajduje się w twoim własnym systemie — jest to usługa third-party, od której zależysz.
Załóżmy, że twój dostawca usług e-mail ogranicza liczbę zapytań na sekundę stałą wartością.
Jeśli nagle masz:
10,000 jobs
nie chcesz wysyłać ich wszystkich naraz.
Kolejka może ograniczyć tempo przetwarzania zadań.
Otrzymana architektura wygląda tak:
10,000 Jobs
↓
Queue
↓
Rate Limit
↓
Worker
↓
External API
To jest o wiele bezpieczniejsze niż wysyłanie tysięcy jednoczesnych zapytań do dostawcy.
12. Idempotencja zadań ma znaczenie
Następna koncepcja to jeden z najważniejszych elementów w przetwarzaniu zadań w tle.
Rozważmy zadanie przetwarzania płatności:
Process Payment
Procesor je uruchamia.
Płatność przebiega pomyślnie.
Jednak tuż przed tym, jak procesor oznaczy je jako zakończone, proces się zawiesza.
Kolejka, robiąc dokładnie to, do czego została zaprojektowana, próbuje ponownie wykonać to zadanie.
Bez zabezpieczeń można by ponownie naliczyć opłatę klientowi.
To poważny i kosztowny problem.
Aby temu zapobiec, zadania powinny być napisane w taki sposób, aby były idempotentne, tam gdzie to możliwe.
W praktyce oznacza to, że dwukrotne uruchomienie tego samego zadania nie powinno powodować niepożądanych efektów ubocznych.
Powszechnym rozwiązaniem jest używanie unikalnego identyfikatora płatności:
payment:order_123
Następnie, zanim rozpocznie się jakakolwiek praca, należy sprawdzić:
Has this payment already been completed?
↓
Yes → Don't charge again
↓
No → Process payment
Sam BullMQ nie posiada wbudowanego mechanizmu do tego celu.
To kod aplikacji musi zapewnić idempotencję.
13. Nieudane zadania wymagają strategii
Nie wszystkie błędy są takie same, a nie każdy z nich warto próbować ponownie.
Rozważmy kilka przykładów:
Invalid email
Invalid user ID
Missing database record
Invalid payment information
Ponowne uruchomienie tych zadań pięć razy nic nie naprawi.
Pomaga podzielenie błędów na dwie kategorie:
Tymczasowe błędy
Należą do nich m.in.:
- Czas wygaśnięcia połączenia sieciowego
- Zależność, która jest chwilowo niedostępna
- Przerwane połączenie z bazą danych
To są takie problemy, gdzie próba ponownego działania później rzeczywiście ma sens.
Trwałe błędy
Należą do nich m.in.:
- Błędne dane wejściowe
- Zasób, do którego jest odniesienie, który już nie istnieje
W takich przypadkach ponawianie prób jest bezcelowe — zadaanie powinno zostać od razu skierowane na ścieżkę obsługi błędów.
Dobrze zaprojektowana struktura kolejki nie polega jedynie na stosowaniu ogólnej zasady:
Retry everything
Zamiast tego stosuje bardziej przemyślany tok działania:
Understand why it failed
↓
Temporary?
/ \
YES NO
↓ ↓
Retry Handle failure
14. Obsługa zadań nieudanych lub nierozstrzygniętych
Niezależnie od tego, jak bardzo jesteś ostrożny, niektóre zadania mogą zawieść w sposób, którego nie da się naprawić poprzez ponowne próby. Potrzebujesz możliwości śledzenia tych zadań, aby nie zniknęły bez śladu.
Na przykład możesz mieć coś takiego:
Failed Jobs
──────────────
Job 101 → Email invalid
Job 102 → Payment failed
Job 103 → API timeout
Gdy już możesz zobaczyć te błędy, masz kilka opcji:
- Zapisz błąd do późniejszej analizy
- Poinformuj swoją zespół
- Daj komuś możliwość ręcznego ponownego spróbowania
- Napraw błędne dane, które to spowodowały
- Przekieruj zadanie do dedykowanego przepływu pracy do obsługi błędów
Sposób budowy tego rozwiązania zależy od potrzeb twojego systemu. Najważniejsza jest jedna zasada:
Zadania, które zawiodły, nigdy nie powinny zniknąć bez śladu.
15. Kolejka vs Zadanie Cron
Latwo pomylić te dwa elementy, ale rozwiązują one różne problemy.
Zadanie Cron ma za zadanie powiedzieć:
"Wykonaj to zadanie o określonej godzinie."
Kolejka ma za zadanie powiedzieć:
"Przetwórz tę jednostkę pracy."
Cron
↓
Find users whose trial expires today
↓
Create jobs
↓
Queue
↓
Workers
↓
Send emails
To pozwala oddzielić logikę planowania od logiki przetwarzania. Zazwyczaj jest to czystszy projekt niż sytuacja, w której pojedynczy proces Cron próbuje sam wykonać całą pracę.
16. Zdarzenia w kolejce i monitorowanie
Gdy już uruchomisz to w środowisku produkcyjnym, potrzebujesz możliwości obserwacji tego, co faktycznie dzieje się w kolejce.
Metryki, które warto śledzić, to:
- Zadania czekające na obsługę
- Zadania aktualnie przetwarzane
- Zadania, które zakończyły się pomyślnie
- Zadania, które zawiodły
- Czas trwania przetwarzania
- Liczba prób ponownych
- Ogólna wielkość kolejki
Wyobraź sobie panel sterowania, który nagle pokazuje coś takiego:
Waiting Jobs
Normal: 50
Current: 25,000
Taki skok jest sygnałem ostrzegawczym. Może oznaczać:
- Twoje procesory przestały działać
- Zewnętrzna API zwolniła pracę
- Twoja baza danych jest obciążona
- Ruch internetowy gwałtownie wzrósł
- Niedawna aktualizacja wprowadziła błąd
Jeśli nie monitorujesz swojej kolejki, te problemy mogą się niewidocznie gromadzić, aż użytkownicy zaczną zauważać, że coś jest nie tak.
17. Nie wkładaj wszystkiego do kolejki
To, że masz dostęp do BullMQ, nie oznacza, że każda operacja musi być wykonywana w tle.
Weźmy na przykład:
GET /profile
Tutaj użytkownik oczekuje natychmiastowo na dane swojego profilu. Odkładanie tego do tła nie miałoby sensu — doprowadziłoby jedynie do niepotrzebnego opóźnienia.
Kolejka ma sens wtedy, gdy:
- Wykonanie zadania zajmuje trochę czasu
- Zadanie może być wykonywane asynchronicznie
- Zadanie może wymagać ponownej próby
- Zadanie jest wymagające pod względem zasobów
- Zadanie zależy od usług zewnętrznych, które nie są w 100% niezawodne
- Wynik nie musi być częścią natychmiastowej odpowiedzi
Cenne pytanie, które warto zadać, to:
Czy użytkownik naprawdę potrzebuje tego wyniku, zanim odeślesz odpowiedź HTTP?
Jeśli nie, warto rozważyć przeniesienie tej pracy do zadania w tle.
18. Architektura w stylu produkcyjnym
Łącząc to wszystko, typowa konfiguracja wygląda następująco:
Client
↓
Node.js API
↓
┌──────┴──────┐
↓ ↓
PostgreSQL Redis
↓
Queue
↓
┌──────────┼──────────┐
↓ ↓ ↓
Worker 1 Worker 2 Worker 3
↓ ↓ ↓
Email PDF Notifications
Szyna API zajmuje się tym, co musi zostać wykonane natychmiast. PostgreSQL (lub wybrana przez Ciebie baza danych) przechowuje trwałe dane biznesowe. Redis wspiera infrastrukturę kolejek oraz inne krótkotrwałe obciążenia, w których jest odpowiedni. Procesory zajmują się wszystkim, co może zostać zrealizowane asynchronicznie.
Takie rozdzielenie obowiązków znacznie ułatwia skalowanie całego systemu.
19. Błędy, których należy unikać
Błąd 1: Wykonywanie wszystkiego w ramach żądania HTTP
Powoduje to API, które są zarówno powolne, jak i kruche.
Błąd 2: Próby ponawiane bez ograniczeń
Część niepowodzeń po prostu się nie rozwiąże, bez względu na to, ile razy spróbujesz.
Błąd 3: Pomijanie zasady idempotencji
Jeśli zadanie zostanie wykonyane dwa razy, może to spowodować niepożądane efekty uboczne w postaci duplikatów.
Błąd 4: Dozwalanie nieograniczonej równoczesności
Bez ograniczeń istnieje ryzyko przeładowania systemów, od których zależą twoje zadania.
Błąd 5: Pomijanie monitoringu
Kolejka, która stale się powiększa bez kontroli, stanowi problem operacyjny, który wkrótce się ujawni.
Błąd 6: Używanie Redis jako systemu rejestrowania danych
Stan kolejki i podstawowe dane biznesowe służą różnym celom i nie powinny być utożsamiane.
Błąd 7: Robienie wszystkiego asynchronicznym
Część operacji rzeczywiście musi zostać zakończona, zanim można będzie odesłać odpowiedź.
20. Lepszy model mentalny
Zanim zrozumiemy kolejki, naturalnym odruchem jest myślenie o obsłudze żądań w ten sposób:
Request
↓
Do everything
↓
Response
Bardziej przydatny model wygląda natomiast tak:
Request
↓
Do what must happen immediately
↓
Queue what can happen later
↓
Response
Następnie:
Queue
↓
Worker
↓
Process
↓
Retry if appropriate
↓
Complete / Fail
To rozdzielenie – oddzielenie tego, co musi się wydarzyć teraz, od tego, co może nastąpić później – stanowi podstawową ideę wszystkiego tego.
Ostateczny wniosek
BullMQ jest przydatne nie tylko dlatego, że jest powszechnie używaną biblioteką Node.js. Jest przydatne, ponieważ przetwarzanie zadań w tle odpowiada na rzeczywistą potrzebę architektoniczną.
Jeśli jakieś zadanie jest:
- wolne
- czymś, co można spróbować ponownie
- czymś, co nie musi blokować odpowiedzi
- Zależne od zewnętrznej usługi
- wymagające dużych zasobów
to prawdopodobnie nie powinno znajdować się w cyklu żądań HTTP.
Kolejka zapewnia miejsce na przetwarzanie zadań. Redis dostarcza podstawową infrastrukturę. BullMQ zajmuje się zarządzaniem zadaniami. Procesory wykonują faktyczne przetwarzanie. Powtórzenia prób radzą sobie z tymczasowymi awariami. Ustawienia dotyczące równoczesności pozwalają kontrolować przepustowość. Monitorowanie informuje o problemach. A staranne projektowanie na poziomie aplikacji gwarantuje, że zadania mogą być bezpiecznie wykonywane kilka razy, gdy jest to konieczne.
Główna lekcja jest następująca:
Nie wszystko musi zostać rozwiązane w ramach cyklu żądanie-odpowiedź.
Czasami właściwą odpowiedzią do wysłania jest po prostu:
"Przyjąłem zadanie. My zajmiemy się resztą."
Literatura pokrewna
- Projektowanie backendów do rozmów w rzeczywistym czasie: pomieszczenia, trwałość danych i skalowanie — Dowiedz się, jak zaprojektować backend do rozmów w czasie rzeczywistym przy użyciu Socket.IO, PostgreSQL i Redis, omawiając tematy takie jak pomieszczenia, kolejność przechowywania wiadomości, status obecności użytkowników oraz skalowanie między wieloma serwerami.
- Zasady cacheowania w Redis: wzory, błędy i kwestie z rozmów rekrutacyjnych — Poznaj, jak działa cacheowanie w Redis w aplikacjach Node.js, od strategii typu cache-aside i TTL po mechanizmy ochrony przed przeciążeniem, zasady usuwania danych z pamięci cache oraz typowe pytania zadawane podczas rozmów rekrutacyjnych.