Cokoli, co trvá déle, než by měl požadavek snést, do požadavku nepatří: odesílání pošty, změna velikosti obrázků, volání pomalého cizího API, generování reportu. Standardní odpověď je fronta a worker a standardní fronta potřebuje Redis - který třeba nemáš, nechceš za něj platit a první den ho rozhodně nepotřebuješ. Databáze, kterou už provozuješ, unese frontu naprosto bez problémů při objemech, jaké malý server vidí, a pro jedinou plánovanou úlohu je cron záznam lepší než obojí. Tento článek je žebřík od „udělej to po odeslání odpovědi“ po „samostatný worker proces“: u každého schodu, kdy se vyplatí na něj vystoupit, a co stojí, když se spleteš.
Čtyři způsoby, jak spouštět práci mimo požadavek#
| Přístup | Přežije restart | Opakování | Vhodné pro |
|---|---|---|---|
| Po odeslání odpovědi, ve stejném procesu | Ne | Ne | Logování bez čekání na výsledek, analytické pingy |
Plánovač v procesu (node-cron, APScheduler) | Ne | Napíšeš si sám | Jeden proces aplikace, periodické úlohy |
| Fronta v databázi, worker ve stejném procesu | Ano | Ano | Většina malých aplikací |
| Fronta a samostatný worker proces | Ano | Ano | Výpočetně náročná práce, cokoli, co nesmí zpomalit web |
První varianta si zaslouží spravedlivé slovo, protože někdy je opravdu v pořádku:
app.post("/signup", async (req, res) => { const user = await createUser(req.body); res.status(201).json(user); // answer now sendWelcomeEmail(user).catch((error) => log.error(error)); // do this after});Požadavek je rychlý, uživatel je vytvořen a e-mail se odešle potom. Vzdal ses ale všeho, čím fronta fronta je: pokud se proces v příštích 200 milisekundách restartuje, e-mail se nikdy neodešle a nikde po tom nezůstane stopa, není žádné opakování, když poštovní poskytovatel vrátí 503, a nemáš jak zjistit, co čeká. U uvítacího e-mailu je to pokrčení ramen. U faktury je to hlášení chyby, které dostaneš za tři týdny bez jakéhokoli důkazu.
Pravidlo palce: pokud by tě naštvalo zjistit, že se práce potichu neudělala, musí být před odpovědí na požadavek zapsaná někam, kde to vydrží. To je celý argument pro frontu.
Co fronta ve skutečnosti přináší#
Čtyři věci, a stojí za to je pojmenovat, protože jsou to kritéria pro výběr.
- Trvanlivost. Úloha je na disku dřív, než se požadavek vrátí. Restart, deploy nebo zastavení kvůli nedostatku paměti nic neztratí.
- Opakování s backoffem. Poštovní poskytovatel je čtyři minuty mimo; úloha to zkusí znovu po 1 s, 2 s, 4 s, 8 s a uspěje, místo aby jednou selhala a zmizela.
- Backpressure. Deset tisíc příchozích úloh se nestane deseti tisíci souběžných operací. Worker si vezme, co zvládne.
- Viditelnost. Můžeš odpovědět na „co čeká, co selhalo, jak stará je nejstarší úloha“ - a to je jediné monitorování práce na pozadí, které má smysl.
Všimni si, co na seznamu není: rychlost. Fronta zrychlí odpověď tím, že práci přesune, ne tím, že ji udělá rychleji. A každá fronta je ve výchozím stavu at-least-once, což má důsledky, o nichž je řeč níže.
Fronty bez Redisu#
Většina lidí sáhne po Redisu, protože knihovna, kterou našli, ho vyžaduje. Pokud už provozuješ relační databázi, máš všechno, co fronta potřebuje - trvanlivost, transakce a od PostgreSQL 9.5 ten jediný kousek SQL, který ji dělá efektivní:
-- One worker claims one job. SKIP LOCKED means other workers-- step over this row instead of queueing behind the lock.UPDATE jobsSET status = 'running', started_at = now(), attempts = attempts + 1WHERE id = ( SELECT id FROM jobs WHERE status = 'queued' AND run_at <= now() ORDER BY priority DESC, run_at FOR UPDATE SKIP LOCKED LIMIT 1)RETURNING id, kind, payload;Bez SKIP LOCKED se dvacet workerů dotazujících se na stejnou tabulku seřadí za jedním zámkem řádku a tvoje fronta má propustnost jedna. S ním si každý worker vezme jinou úlohu a vzor se škáluje na tisíce úloh za minutu na hardwaru, který nijak nevyniká. To je daleko za tím, co malá aplikace produkuje.
Větší výhrou je transakční zařazení do fronty. Pokud se řádek úlohy zapisuje ve stejné transakci jako byznysová data, je nemožné mít uživatele bez jeho úlohy s uvítacím e-mailem nebo e-mail pro uživatele, jehož vytvoření se vrátilo zpět. Žádný externí broker to nezaručí a odpadne tím celá kategorie chyb typu „jak se to mohlo stát“.
Knihovny, které to dělají pořádně, takže SQL nemusíš psát sám:
| Knihovna | Jazyk | Poznámky |
|---|---|---|
| pg-boss | Node | Jen Postgres, plánování cronem v základu |
| graphile-worker | Node | Používá LISTEN/NOTIFY, takže latence jsou v milisekundách, ne v intervalu dotazování |
| procrastinate | Python | Postgres, integrace s Djangem, podpora async |
| Solid Queue | Ruby | Výchozí volba v nedávných verzích Rails |
Ovladač database v Laravelu | PHP | Už je součástí frameworku, jeden řádek konfigurace |
Kompromisy jsou skutečné a malé: dotazování přidává mírnou stálou zátěž (použij LISTEN/NOTIFY nebo dotaz každou sekundu, ne každých 50 milisekund), tabulka úloh roste, pokud nikdy nemažeš dokončené řádky, a mrtvé řádky je třeba uklízet vacuem - proč je tabulka s vysokou obměnou klasický případ, vysvětluje Postgres vacuum a bloat. Dokončené úlohy maž po jednom dni, neúspěšné drž déle.
Pro opravdu nízké objemy na jednom stroji funguje stejný vzor se SQLite. Zapni režim WAL (PRAGMA journal_mode=WAL) a nastav busy_timeout, aby souběžní zapisovatelé čekali, místo aby házeli chybu. Je to jediný soubor bez služby, kterou by bylo třeba provozovat, a strop má hluboko nad těch pár úloh za minutu, které většina vedlejších projektů generuje.
Jedna věc musí být jasná: RE:NODE Redis neprodává. Databázový hosting je tu PostgreSQL a MongoDB a databázové sloty zahrnuté v plánech pro aplikace a web se vytvářejí v panelu s vygenerovaným hostem, uživatelem a heslem. Cesta přes Postgres výše je tedy ta, která funguje s tím, co si můžeš koupit, a je to při této velikosti opravdu správná výchozí volba. Redis a jestli ho už opravdu potřebuješ vede stejný argument z opačné strany.
Vlastní Redis#
Někdy chceš ekosystém Redisu stejně - zpožděné a opakující se úlohy v BullMQ jsou dobré, Celery je výchozí volba ve světě Pythonu a existující kódová základna to může prostě předpokládat. Máš dvě poctivé možnosti: pronajmout si Redis od poskytovatele, který ho prodává, nebo ho provozovat sám na stroji, který ovládáš. VDS to zvládne s přehledem; na RE:NODE se připravují ručně a dodávají do 24 hodin, ne za minutu, takže ho objednej před víkendem, kdy ho chceš použít.
Když ho provozuješ sám, tři nastavení rozhodují o tom, jestli je to fronta, nebo cache, která se za frontu jen vydává:
maxmemory-policy noevictionappendonly yesappendfsync everysecProč si každé z nich zaslouží své místo, plus pravidlo, které není řádek konfigurace:
- `noeviction` je povinné pro BullMQ a velmi dobrý nápad pro cokoli jiného. Výchozí politiky vyřazování mažou klíče, když se zaplní paměť - což ve frontě znamená tiché mazání úloh.
- `appendonly yes` ti dá append-only log. Jen se snapshoty přijdeš o všechny úlohy od posledního uložení, když proces spadne.
- Připoj ho na localhost nebo soukromou adresu a nastav heslo. Redis otevřený do internetu se najde do několika hodin a snadno se zneužije ke spouštění příkazů.
Minimální worker v BullMQ s volbami, na kterých záleží:
import { Worker, Queue } from "bullmq";const connection = { host: "127.0.0.1", port: 6379 };export const emails = new Queue("emails", { connection });new Worker("emails", async (job) => { await sendEmail(job.data);}, { connection, concurrency: 2, // a hard ceiling, not a suggestion removeOnComplete: { count: 1000 }, // or Redis grows forever removeOnFail: { age: 7 * 24 * 3600 },});await emails.add("welcome", { userId: 42 }, { attempts: 5, backoff: { type: "exponential", delay: 1000 }, jobId: `welcome:42`, // dedupe: same id, one job});removeOnComplete je řádek, který lidé objeví, až se jim zaplní Redis. Dokončené úlohy se ve výchozím stavu uchovávají, aby se daly prohlížet, a na malé instanci se to během týdne stane limitem paměti.
U Pythonu potřebuje Celery skutečný broker: Redis nebo RabbitMQ. Transporty nad databází, které existovaly ve starších verzích, Celery 5 nepodporuje, takže „Celery bez brokeru“ neexistuje - pokud broker nemáš, použij procrastinate nebo APScheduler s databázovým úložištěm úloh. Celery worker dimenzovaný pro malý stroj:
$ celery -A myapp worker --loglevel=info \ --concurrency=2 --prefetch-multiplier=1 \ --max-tasks-per-child=200 --time-limit=600 --soft-time-limit=540--prefetch-multiplier=1 brání workeru, aby si nabral hromadu úloh, které nezačal, což se projeví při restartech. --max-tasks-per-child pravidelně recykluje podřízený proces a je to nejlevnější dostupná odpověď na pomalý únik paměti.
Plánování: cron, beat a záložka Schedules#
Fronty spouštějí práci, když se o ni požádá. Něco ale musí žádat podle rozvrhu.
Plánovače v procesu - node-cron, APScheduler, setInterval - jsou nejjednodušší věc, která funguje, a mají přesně jeden způsob selhání: dva procesy znamenají dva běhy. Pokud někdy spustíš druhou instanci, noční report se vygeneruje dvakrát a odešle dvakrát. Zajisti to zámkem, nespoléhej na sebe:
-- Returns true for exactly one caller; released when the session ends.SELECT pg_try_advisory_lock(hashtext('nightly-report'));Celery beat je stejná myšlenka jako samostatný proces. Spouštěj právě jeden beat, vždy. Dva beaty vytvoří duplicitní rozvrhy, a protože beat jen zařazuje a vykonávají workery, duplicita je neviditelná, dokud někdo nepřečte log.
Záložka Schedules v panelu je možnost, kterou lidé přehlížejí na hostingu postaveném na kontejnerech, kde není systémový crontab k úpravě. Přijímá cron výraz a spouští uspořádané úkoly se zpožděním mezi nimi: příkaz do konzole, backup nebo akci napájení. Na RE:NODE je k dispozici u každého plánu. Dvě věci jí jdou velmi dobře:
- Noční backup, po dvou minutách následovaný restartem. Uspořádané úkoly se zpožděním jsou přesně primitivum pro tohle.
- Spuštění vlastní úlohy. Příkaz do konzole se zapíše na standardní vstup tvé aplikace, takže pár řádků obsluhy stdin promění záložku Schedules v plánovač, kterého tvoje aplikace poslouchá:
process.stdin.on("data", (chunk) => { for (const line of chunk.toString().split("\n")) { if (line.trim() === "jobs:nightly") runNightly().catch((e) => console.error(e)); }});Než se spolehneš na to, že „03:00“ znamená 03:00 tam, kde žiješ, zjisti, ve kterém časovém pásmu se rozvrh vyhodnocuje, a pamatuj, že cron výraz má pět polí bez sekund - syntaxi vysvětluje cron výrazy vysvětlené a které úlohy si své místo opravdu zaslouží, říká plánované úlohy, které stojí za to mít.
Dimenzování workeru na jednom stroji#
Omezením na malém plánu není propustnost úloh, ale paměť a CPU, které worker bere tomu, co obsluhuje uživatele.
| Runtime | Přibližná rezidentní paměť na proces |
|---|---|
| Node worker, skromný strom závislostí | 40-80 MB |
| Python worker s načteným Djangem nebo SQLAlchemy | 80-150 MB |
| Každý prefork potomek Celery | Zhruba totéž znovu |
| Redis držící několik tisíc malých úloh | Desítky MB |
--concurrency=4 u Python workeru jsou čtyři podřízené procesy, takže plán s 1 GB, na kterém běží webový server a tento worker, je už těsný. Začni s concurrency 1 nebo 2 a zvyšuj ji, až když věk nejstarší úlohy ukáže, že je to potřeba.
CPU je přísnější, protože na RE:NODE je podíl CPU tvrdé omezení, ne cíl - server na 100 % je pomalý, ne rozbitý, a nikdy se za to neozastaví, ale vše v tom kontejneru sdílí strop. Worker, který na plný výkon převádí obrázky, zpomalí tvoje webové požadavky a žádná priorita procesu na celkovém součtu nic nezmění. Dvě cesty ven:
- Worker záměrně omez. Concurrency 1 a malé zpoždění mezi úlohami často stačí, aby web zůstal svižný a fronta se vyprazdňovala o něco pomaleji.
- Dej worker na vlastní server. Druhý plán pro aplikace je samostatný kontejner s vlastní pamětí a podílem CPU, takže těžké úlohy nemohou vyhladovět webový proces. Oba servery se připojují ke stejnému plánu PostgreSQL, který je dostupný na vlastním hostu a portu, takže fronta je sdílená bez jakéhokoli důmyslu.
U paměti worker selže nejhůř. Dosažení limitu zastaví kontejner a restartuje ho načisto místo swapování - takže rozpracovaná úloha zemře uprostřed zápisu. Což nás přivádí k části, která rozhoduje, jestli na tom záleží.
Úlohy, které běží dvakrát, a úlohy, které neběží nikdy#
Každá fronta, která za něco stojí, je at-least-once. Worker, který si úlohu vezme a zemře dřív, než ji potvrdí, způsobí její opětovné doručení, protože alternativa - potvrdit nejdřív - úlohy ztrácí. Duplicity jsou cena za to, že se práce nikdy neztratí, a způsob, jak ji zaplatit, je idempotence.
- Udělej operaci bezpečnou k opakování.
INSERT ... ON CONFLICT DO NOTHINGna unikátním klíči, sloupecsent_atzkontrolovaný před odesláním, idempotentní klíč na straně poskytovatele u volání platby. - Používej deterministické id úlohy.
jobIdv BullMQ, unikátní omezení na(kind, entity_id)ve vlastní tabulce. Stejná úloha zařazená dvakrát se stane jednou úlohou. - Potvrzuj pozdě a mysli to vážně. Úlohu označ jako hotovou po vedlejším účinku, ne před ním.
- Omez počet pokusů.
attempts: 5s exponenciálním backoffem a pak přesun do stavu dead-letter, kde se na ni člověk podívá. Úloha, která donekonečna opakuje proti trvalé chybě, je způsob, jak zároveň schovat chybu a spálit CPU. - Nastav časový limit. Celery má
--time-limita--soft-time-limit; BullMQ nemá vlastní tvrdý timeout na úlohu, takže handler obal doPromise.races časovačem. Úloha, která visí na socketu bez timeoutu, obsazuje slot workeru donekonečna a právě tak se fronta zastaví, zatímco všechny dashboardy tvrdí, že worker žije.
Restarty si zaslouží vlastní ošetření. Při SIGTERM přestaň přebírat nové úlohy, dokonči aktuální, pokud se vejde do odkladové lhůty, a skonči - await worker.close() v BullMQ, warm shutdown v Celery. Graceful shutdown a health checky obsahuje celý vzor, včetně toho, proč dlouhá úloha potřebuje checkpointy, a ne delší timeout.
Co sledovat#
Monitorování fronty má jednu metriku, na které záleží, a několik, které tak vypadají.
- Věk nejstarší čekající úlohy. To je ta pravá. Pokud je nejstarší úloha ve frontě čtyři hodiny stará, fronta je rozbitá bez ohledu na ostatní čísla. Alertuj na ni.
- Hloubka fronty je sama o sobě slabý alert: 5 000 rychle mizejících úloh je v pořádku, 12 navždy zaseknutých není.
- Míra selhání podle typu úlohy. Selhává jeden typ - je to chyba; selhává všechno - je to závislost.
- Heartbeat workeru. Worker, který potichu skončil, nechá frontu tiše se plnit, což je nejčastější výpadek úloh na pozadí vůbec.
Zveřejni tyto hodnoty jako počty na stejném místě, kde aplikace hlásí svůj stav, a alerty drž nudné - viz monitoring, který ti něco řekne. Pokud fronta žije v databázi, všechny čtyři jsou jediný SQL dotaz, což je další argument pro to, dát ji tam. Jen si hlídej počet spojení: pool workerů a pool webu proti stejné malé databázi se sčítají a co se stane, když přesáhnou, co server přijme, vysvětluje connection pooly a limity.
FAQ#
Potřebuju Redis pro úlohy na pozadí?
Ne. Tabulka v PostgreSQL s SELECT ... FOR UPDATE SKIP LOCKED zvládne mnohem větší propustnost, než malá aplikace vyprodukuje, a umožní ti zařadit úlohu ve stejné transakci jako data, která ji způsobila. Redis stojí za to přidat, když chceš funkce konkrétní knihovny, ne jako výchozí volbu.
Můžu spustit worker ve stejném procesu jako webovou aplikaci?
Ano, a na malém serveru je to často správná volba - jeden proces, jeden deploy, méně věcí, které se mohou pokazit. Přesuň ho jinam, když jsou úlohy tak náročné na CPU, že zpomalují požadavky, nebo když chceš restartovat jedno bez druhého.
Proč se moje úloha spustila dvakrát?
Protože fronty jsou at-least-once. Worker, který spadne nebo je zabit po zahájení úlohy, ale před jejím potvrzením, způsobí opětovné doručení. Udělej práci idempotentní, místo abys se snažil o doručení exactly-once, které žádná fronta ve skutečnosti neposkytuje.
Jak spustím plánovanou úlohu na hostingu bez crontabu?
Použij záložku Schedules v panelu s cron výrazem, která umí poslat příkaz do konzole, vytvořit backup nebo restartovat server. Pokud úloha patří dovnitř aplikace, nech aplikaci číst řádek ze standardního vstupu a úlohu spustit, když ho uvidí.
Kolik workerů mám spustit?
Začni jedním s concurrency jedna nebo dvě. Zvyšuj ji, jen když roste věk nejstarší úlohy, a přestaň, když je omezujícím faktorem paměť nebo podíl CPU. Víc workerů, než máš podílu CPU, jen zpomalí každou úlohu.
Co se stane s běžící úlohou, když se server restartuje?
S pozdním potvrzením se vrátí do fronty a spustí se znovu - proto musí být bezpečná k opakování. Bez pozdního potvrzení se ztratí. Vyzkoušej to záměrně: zabij worker uprostřed úlohy a ověř, že výsledek je jedna z těchto dvou možností a ne napůl dokončený zápis.




Komentáře
Zcela anonymně: žádný účet, žádný e-mail, žádné cookie. Ukládáme jen jméno, které napíšeš, text a čas - nic dalšího. Počet odkazů je omezený a značkování se nevykresluje.