Fronty úloh na pozadí: BullMQ v Node.js a Celery v Pythonu
Proč posílat e-maily a generovat PDF mimo HTTP požadavek. Praktické nastavení BullMQ a Celery s Redisem, opakování s backoffem, idempotence a provoz workerů.
Uživatel klikne na „Objednat“ a čeká, než server odešle e-mail, vygeneruje fakturu a zavolá účetní systém. Pokud jedna z těch služeb zrovna zlobí, požadavek spadne a objednávka je v nejasném stavu. Fronta úloh tohle řeší: HTTP požadavek jen zapíše „co je potřeba udělat“ a samostatný worker to zpracuje na pozadí, s opakováním při chybě.
Kdy frontu použít
- Odesílání e-mailů a notifikací.
- Generování PDF, zpracování obrázků a videí, importy CSV.
- Volání pomalých nebo nespolehlivých externích API.
- Zpracování webhooků (rychle potvrdit, práci udělat později).
- Plánované úlohy (denní reporty, úklid).
Pravidlo: co trvá déle než pár stovek milisekund nebo závisí na cizí službě, patří do fronty.
Architektura
[ Web / API ] --přidá úlohu--> [ Redis (broker) ] <--vyzvedne-- [ Worker 1..N ]
Web a workery jsou samostatné procesy, často na jiných serverech. Workerů můžete spustit víc a škálovat je nezávisle na webu.
BullMQ (Node.js)
BullMQ je knihovna nad Redisem, nástupce původního Bull.
npm install bullmq
Přidání úlohy
// queue.js
import { Queue } from "bullmq";
export const connection = { host: "127.0.0.1", port: 6379 };
export const emailQueue = new Queue("email", { connection });
// v route handleru po vytvoření objednávky:
await emailQueue.add(
"order-confirmation",
{ orderId: order.id },
{
jobId: `order-confirmation-${order.id}`, // deduplikace
attempts: 5,
backoff: { type: "exponential", delay: 2000 },
removeOnComplete: 1000,
removeOnFail: 5000,
}
);
jobId– úloha se stejným ID se znovu nepřidá, dokud existuje (ochrana proti dvojímu kliknutí).attempts+backoff– při chybě se úloha zopakuje po 2, 4, 8… sekundách.removeOnComplete/removeOnFail– kolik hotových či neúspěšných úloh si Redis drží. Bez toho paměť Redisu jen roste.
Do dat úlohy dávejte ID, ne celé objekty. Worker si aktuální data načte z databáze.
Worker
// worker.js
import { Worker } from "bullmq";
import { connection } from "./queue.js";
const worker = new Worker(
"email",
async (job) => {
const order = await db.orders.find(job.data.orderId);
if (order.confirmationSentAt) return; // už odesláno – idempotence
await sendEmail(order.customerEmail, "Potvrzení objednávky", render(order));
await db.orders.markConfirmationSent(order.id);
},
{ connection, concurrency: 5 }
);
worker.on("failed", (job, err) => {
console.error(`Úloha ${job?.id} selhala (pokus ${job?.attemptsMade}):`, err.message);
});
process.on("SIGTERM", async () => {
await worker.close(); // dokončí rozpracované úlohy
process.exit(0);
});
Pokud předáváte vlastní instanci ioredis místo konfiguračního objektu, musí mít pro Worker nastaveno maxRetriesPerRequest: null, jinak BullMQ vypíše chybu.
Celery (Python)
Celery je zavedený standard v Pythonu, často spolu s Djangem. Jako broker může použít Redis nebo RabbitMQ.
pip install "celery[redis]"
# tasks.py
from celery import Celery
app = Celery("tasks", broker="redis://localhost:6379/0")
app.conf.update(
task_acks_late=True,
worker_prefetch_multiplier=1,
task_time_limit=300,
)
@app.task(
bind=True,
autoretry_for=(ConnectionError, TimeoutError),
retry_backoff=True, # exponenciální čekání
retry_jitter=True, # náhodný rozptyl
max_retries=5,
)
def send_order_confirmation(self, order_id: int) -> None:
order = Order.objects.get(pk=order_id)
if order.confirmation_sent_at:
return
send_email(order.customer_email, render(order))
order.mark_confirmation_sent()
Zařazení úlohy:
send_order_confirmation.delay(order.id)
V Djangu volejte delay až po úspěšném commitu transakce, jinak může worker hledat objednávku, která ještě není uložená:
from django.db import transaction
transaction.on_commit(lambda: send_order_confirmation.delay(order.id))
Spuštění workeru:
celery -A tasks worker --loglevel=INFO --concurrency=4
Pro plánované úlohy slouží celery -A tasks beat s nastavením beat_schedule.
Nastavení task_acks_late=True znamená, že úloha se potvrdí až po dokončení – pokud worker spadne uprostřed, úloha se zpracuje znovu. Proto musí být úlohy idempotentní.
Idempotence je nutnost
Fronty garantují doručení alespoň jednou. Úloha může proběhnout dvakrát: worker spadne po odeslání e-mailu, ale před potvrzením; síť vypadne; vyprší timeout. Proto:
- před akcí kontrolujte, jestli už neproběhla (příznak v databázi),
- u externích API posílejte idempotency key,
- stav nastavujte, nepřičítejte.
Provoz workerů
Workery musí běžet trvale a po pádu se restartovat. Příklad unitu pro systemd (/etc/systemd/system/celery-worker.service):
[Unit]
Description=Celery worker
After=network.target redis-server.service
[Service]
User=app
WorkingDirectory=/srv/app
ExecStart=/srv/app/venv/bin/celery -A tasks worker --loglevel=INFO
Restart=always
KillSignal=SIGTERM
TimeoutStopSec=60
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now celery-worker
journalctl -u celery-worker -f
Pro Node.js workery stejně dobře poslouží PM2 nebo obdobný systemd unit.
Monitoring
- BullMQ: dashboard Bull Board (
@bull-board/express), metodygetJobCounts()pro alerting. - Celery: Flower (
pip install flower,celery -A tasks flower), příkazcelery -A tasks inspect active.
Sledujte délku fronty (roste, když workery nestíhají), počet neúspěšných úloh a dobu zpracování.
Časté chyby
- Úloha se zařadí před commitem transakce.
- Do úlohy se serializují celé objekty, které mezitím zastarají.
- Chybí limit opakování – nekonečně padající úloha zahltí frontu.
- Redis pro frontu má nastavenou eviction politiku (
allkeys-lru) a tiše maže úlohy. Pro frontu používejtenoevictiona ideálně jinou instanci Redisu než pro cache. - Worker se při nasazení zabije bez dokončení rozpracovaných úloh.