Головна / Статті / Створення надійних систем фонових завдань за допомогою BullMQ та Redis

Створення надійних систем фонових завдань за допомогою BullMQ та Redis

Дізнайтеся, як проєктувати стійкі потоки завдань у фоновому режимі для Node.js за допомогою BullMQ та Redis, з урахуванням повторних спроб, конкурентності, ідемпотентності та моніторингу.

2933 слів

Надсилання підтверджувального електронного листа, створення звіту, обробка платежу — багато завдань на серверному рівні не потребують завершення перед тим, як ви відповісте користувачеві. У цьому посібнику розглядається створення надійних систем фонових завдань з використанням BullMQ у поєднанні з Redis.

фонові завдання починають мати сенс.

Замість того, щоб змушувати API виконувати кожен крок перед відповіддю, ви можете покласти цю роботу у чергу та доручити її окремому працівнику обробляти окремо.

Хорошим варіантом у екосистемі Node.js є BullMQ, який використовує Redis для зберігання даних. Ось як усе це поєднується.

1. Що таке фонова робота?

Фонова робота — це будь-яка одиниця роботи, яку не потрібно виконувати синхронно як частину HTTP-запиту.

Розгляньмо типовий процес реєстрації. Коли хтось створює обліковий запис, API може потребувати:

  • Створити запис користувача
  • Надіслати листа з привітанням
  • Сгенерувати PDF-документ з привітанням
  • Надіслати сповіщення
  • Оновити іншу систему

Ви могли б спробувати виконати все це безпосередньо перед відповіддю:

Client
  ↓
API
  ↓
Create User
  ↓
Send Email
  ↓
Generate PDF
  ↓
Send Notification
  ↓
Response

Але це змушує користувача чекати, поки завершаться усі ці кроки.

Кращий підхід виглядає ось так:

Client
  ↓
API
  ↓
Create User
  ↓
Add Job to Queue
  ↓
Response

І окремо:

Queue
  ↓
Worker
  ↓
Send Email
  ↓
Done

Оскільки API більше не повинно виконувати всі завдання перед тим, як відповісти, воно реагує набагато швидше.

2. Чому нам потрібна черга?

Припустимо, надсилання електронного листа займає 1 секунду, створення PDF — 2 секунди, а виклик іншого API — ще 1 секунду. Ваш ендпоїнт може застрягати на кілька секунд, перш ніж надіслати відповідь — що є поганим досвідом для користувача.

Ще гірше: що станеться, якщо постачальник електронної пошти буде недоступний? Запит може зазнати невдачі, навіть якщо створення користувача насправді вдалося. Це зайва залежність між двома нерелевантними аспектами.

Черга усуває цю залежність:

┌──────────────┐
                    │   Node API   │
                    └──────┬───────┘
                           ↓
                     Add Job
                           ↓
                    ┌──────────────┐
                    │    Redis     │
                    │    Queue     │
                    └──────┬───────┘
                           ↓
                    ┌──────────────┐
                    │    Worker    │
                    └──────┬───────┘
                           ↓
                Email / PDF / API / etc.

Завдяки такому розділенню API та фонове завдання мають окремі обов’язки.

3. Що таке BullMQ?

BullMQ — це бібліотека черг для Node.js, яка використовує Redis для зберігання та координації завдань. Її архітектура на високому рівні виглядає так:

Producer
   ↓
Queue
   ↓
Worker
   ↓
Job Processing

Продуцент — це той, хто створює завдання. Черга зберігає їх. Робот — це той, хто насправді їх обробляє.

Наприклад:

await emailQueue.add("welcome-email", {
  userId: user.id,
  email: user.email
});

По суті, API формулює це так:

"Ось деяка робота, яку потрібно виконати."

Він не повинен виконувати цю роботу сам.

4. Створення черги

Ось як виглядає мінімальна налаштування черги BullMQ:

import { Queue } from "bullmq";
const connection = {
  host: "localhost",
  port: 6379
};const emailQueue = new Queue("email", {
  connection
});

Звідти ви можете додавати завдання до неї:

await emailQueue.add("welcome-email", {
  userId: "123",
  email: "user@example.com"
});

Redis опікується зберіганням усього стану, пов’язаного з чергою, на фоні. Концептуально можна уявити це так:

email queue
Job 1
Job 2
Job 3
Job 4
Job 5

Потім працівник бере ці завдання та виконує їх.

5. Створення працівника

Працівник – це та частина, яка фактично виконує роботу:

import { Worker } from "bullmq";
const worker = new Worker(
  "email",
  async (job) => {
    console.log("Processing:", job.name);    await sendWelcomeEmail(
      job.data.email
    );
  },
  {
    connection
  }
);

Якщо об’єднати все це, процес тепер виглядає ось так:

API
 ↓
emailQueue.add()
 ↓
Redis
 ↓
Worker
 ↓
sendWelcomeEmail()

API не повинен чекати, поки електронний лист буде надісланий – і це основна перевага фонових завдань.

6. Що відбувається, коли завдання зазнає невдачі?

Саме тут черга починає демонструвати свою справжню перевагу перед звичайним викликом сервісу.

API
 ↓
Email Service
 ↓
ERROR

Коли ви безпосередньо викликаєте API, ви змушені негайно приймати рішення щодо невдачі.

Черга пропонує інший варіант: завдання можна просто спробувати виконати знову.

Ось приклад:

await emailQueue.add(
  "welcome-email",
  {
    email: "user@example.com"
  },
  {
    attempts: 3
  }
);

За такої конфігурації завданню дозволяється кілька спроб перед тим, як воно здасться.

Візуально цей процес виглядає так:

Attempt 1
   ↓
Failed
   ↓
Attempt 2
   ↓
Failed
   ↓
Attempt 3
   ↓
Success

Ця схема є надзвичайно корисною, коли ви працюєте з ненадійними сторонніми сервісами.

Проте повторні спроби не повинні бути безмежними чи недбалими.

Уникайте ситуацій, коли завдання постійно повторює спроби без жодних обмежень.

7. Повторні спроби з відстрочкою

Припустимо, що зовнішній сервіс тимчасово не працює.

Вам потрібно уникнути ситуації, подібної до цієї:

FAIL
RETRY IMMEDIATELY
FAIL
RETRY IMMEDIATELY
FAIL
RETRY IMMEDIATELY

Негайні повторні спроби можуть лише погіршити ситуацію з несправним сервісом.

Рішенням є введення відстрочки між спробами.

Наприклад:

await emailQueue.add(
  "welcome-email",
  {
    email: "user@example.com"
  },
  {
    attempts: 5,
    backoff: {
      type: "exponential",
      delay: 5000
    }
  }
);

Ось як це виглядає концептуально:

Attempt 1 → Fail
       ↓
     5 sec
       ↓
Attempt 2 → Fail
       ↓
    10 sec
       ↓
Attempt 3 → Fail
       ↓
    20 sec
       ↓
Attempt 4 → Success

Точний час виконання залежить від того, як ви налаштуєте стратегію повторних спроб та відстрочок.

Але основна ідея залишається незмінною:

Дайте тимчасовим невдачам можливість відновитися перед новою спробою.

8. Задачі з відстрочкою

Не кожну задачу потрібно виконувати одразу після створення.

Наприклад:

Надіслати нагадування через 24 години після реєстрації.

BullMQ дозволяє запланувати виконання задачі на пізніший час.

await emailQueue.add(
  "reminder",
  {
    userId: "123"
  },
  {
    delay: 24 * 60 * 60 * 1000
  }
);

Концептуально:

Create Job
    ↓
Wait 24 hours
    ↓
Worker processes job

Ця схема використовується у таких ситуаціях:

  • Електронні нагадування
  • Плановані сповіщення
  • Кінець пробної періоду
  • Нагадування про оплату
  • Повідомлення для підтримки

9. Кілька працівників

Уявіть собі систему, яка отримує тисячі задач на хвилину.

Один процес-працівник може не встигати.

Ви можете розширити потужності, запускаючи кількох працівників одночасно:

Redis Queue
                     ↓
          ┌──────────┼──────────┐
          ↓          ↓          ↓
       Worker 1   Worker 2   Worker 3
          ↓          ↓          ↓
        Jobs       Jobs       Jobs

Кожен з них окремо бере завдання з черги.

Наприклад, якщо маємо:

1000 email jobs

Ви можете побачити щось на кшталт:

Worker 1 → Job 1, 4, 7...
Worker 2 → Job 2, 5, 8...
Worker 3 → Job 3, 6, 9...

Додавання більшої кількості працівників — це один із способів підвищення продуктивності.

Але будьте обережні:

Додавання більшої кількості працівників до проблеми не завжди призводить до позитивних результатів.

У вашій базі даних, у вашому постачальнику електронної пошти, у вашому процесорі, у вашій пам’яті та в будь-якому наступному сервісі є свої обмеження на пропускну здатність.

10. Конкурентність

Окрім запуску кількох процесів-працівників, BullMQ також дозволяє налаштувати кількість завдань, якими один працівник може займатися одночасно.

Наприклад:

const worker = new Worker(
  "email",
  async (job) => {
    await sendEmail(job.data.email);
  },
  {
    connection,
    concurrency: 5
  }
);

Це дозволяє одному працівнику обробляти кілька завдань паралельно.

Концептуально:

Worker
 ├── Job 1
 ├── Job 2
 ├── Job 3
 ├── Job 4
 └── Job 5

Вища конкурентність може підвищити продуктивність.

Але не варто просто підвищувати рівень конкурентності до 100 без ретельного обмірковування.

Якщо кожне завдання звертається до вашої бази даних, висока конкурентність може легко її перевантажити.

Налаштування конкурентності слід підбирати так, щоб вони відповідали тому, що насправді може витримати ваша навантажувальна ситуація.

11. Обмеження частоти запитів

Іноді проблема зовсім не полягає у вашій власній системі — це стороння послуга, від якої ви залежите.

Припустимо, ваш постачальник електронної пошти обмежує кількість запитів на секунду фіксованою цифрою.

Якщо у вас раптово з’явиться:

10,000 jobs

ви не хочете надсилати їх усі одночасно.

Очередь може стримувати швидкість обробки завдань.

Отримана архітектура виглядає так:

10,000 Jobs
     ↓
Queue
     ↓
Rate Limit
     ↓
Worker
     ↓
External API

Це набагато безпечніше, ніж надсилати тисячі одночасних запитів до постачальника.

12. Ідемпотентність завдань має значення

Ця наступна концепція є однією з найважливіших ідей у обробці завдань у фоновому режимі.

Розглянемо завдання з обробки платежів:

Process Payment

Робот виконує його.

Платіж проходить успішно.

Але безпосередньо перед тим, як робот позначить його як завершений, процес зупиняється.

Очередь, роблячи саме те, для чого вона призначена, повторює спробу виконання завдання.

Без захисних механізмів може статися, що клієнту буде стягнуто плату вдруге.

Це справжня та дорога проблема.

Щоб уникнути цього, завдання слід розробляти так, щоб вони були ідемпотентними, де це можливо.

На практиці це означає, що подвійне виконання одного й того ж завдання не повинно створювати небажаних дублікатних наслідків.

Один з поширених підходів — використання унікального ідентифікатора платежу:

payment:order_123

Потім, перш ніж розпочинати виконання, перевірте:

Has this payment already been completed?
       ↓
     Yes → Don't charge again
       ↓
      No → Process payment

Сам BullMQ не має вбудованого механізму для цього.

Ваш код додатку має забезпечувати ідемпотентність.

13. Неуспішні завдання потребують стратегії

Не всі збої є однаковими, і не кожен з них вартий повторної спроби.

Розгляньмо кілька прикладів:

Invalid email
Invalid user ID
Missing database record
Invalid payment information

Повторне виконання цих завдань п’ять разів нічого не виправить.

Корисно розділити збої на дві категорії:

Тимчасові збої

До них належать такі ситуації:

  • Тайм-аут мережі
  • Залежність, яка на короткий час недоступна
  • Втрата з’єднання з базою даних

Саме у таких випадках має сенс спробувати знову пізніше.

Постійні збої

До них належать такі ситуації:

  • Некоректні дані вхіду
  • Ресурс, на який посилаються, більше не існує
  • Порушення бізнес-правила
  • У таких випадках повторна спроба є марною — завдання потрібно відразу направити на шлях обробки помилок.

    Добре спроєктована система черг не просто дотримується загального правила:

    Retry everything
    

    Натомість вона слідує більш продуманому алгоритму:

    Understand why it failed
           ↓
    Temporary?
       /       \
     YES        NO
     ↓           ↓
    Retry       Handle failure
    

    14. Обробка завдань, що зазнали невдачі

    Як би ви не були обережні, деякі завдання можуть зазнати невдачі, яку неможливо виправити шляхом повторної спроби. Вам потрібен огляд цих завдань, щоб вони не просто зникали.

    Наприклад, у вас може виникнути щось на кшталт:

    Failed Jobs
    ──────────────
    Job 101 → Email invalid
    Job 102 → Payment failed
    Job 103 → API timeout
    

    Як тільки ви зможете бачити ці невдачі, у вас будуть варіанти:

    • Записати інформацію про невдачу для подальшого аналізу
    • Повідомити вашу команду
    • Надати комусь можливість вручну спробувати ще раз
    • Виправити некоректні дані, що спричинили проблему
    • Надішліть завдання у спеціалізований робочий процес обробки несправностей

    Спосіб створення цього механізму залежить від потреб вашої системи. Найважливішим є один принцип:

    Неуспішні завдання ніколи не повинні зникати без сліду.

    15. Очередь проти завдання Cron

    Легко плутати ці два підходи, але вони вирішують різні проблеми.

    Завдання Cron полягає у тому, щоб сказати:

    "Виконати це завдання у певний час."

    Завдання очереді полягає у тому, щоб сказати:

    "Обробити цю одиницю роботи."

    На практиці ці два інструменти часто добре працюють разом. Наприклад:

    Cron
     ↓
    Find users whose trial expires today
     ↓
    Create jobs
     ↓
    Queue
     ↓
    Workers
     ↓
    Send emails
    

    Це дозволяє розділити логіку планування від логіки обробки. Це зазвичай є більш чистим підходом, ніж коли один процес Cron намагається виконати всю роботу самостійно.

    16. Події в очереді та моніторинг

    Як тільки ви почнете використовувати цей механізм у продакшені, вам потрібна можливість бачити, що насправді відбувається всередині черги.

    Показники, які варто відстежувати, включають:

    • Завдання, які чекають на обробку
    • Завдання, які зараз обробляються
    • Завдання, які були успішно завершені
    • Завдання, які зазнали невдачі
    • Час, необхідний для обробки
    • Кількість спроб повторної обробки
    • Загальний розмір черги

    Waiting Jobs
    
    Normal: 50
    Current: 25,000
    

    Такий стрибок є ознакою попередження. Це може означати:

    • Ваші процеси припинили свою роботу
    • Зовнішня API уповільнилася
    • Ваша база даних перевантажена
    • Обсяг трафіку різко зріс
    • Нещодавня інсталяція призвела до появи помилки

    Якщо ви не стежите за своєю чергою, ці проблеми можуть непомітно накопичуватися, поки користувачі не почнуть помічати, що щось не так.

    17. Не кладіть усе в чергу

    Наявність BullMQ не означає, що кожна операція мусить виконуватися у фоновому режимі.

    Візьмемо, наприклад:

    GET /profile
    

    Тут користувач очікує на дані свого профілю негайно. Відкладання цього у фонову чергу не має сенсу — це лише додасте зайву затримку.

    Черга має сенс у таких випадках:

    • Робота потребує певного часу для виконання
    • Роботу можна виконати асинхронно
    • Роботу може знадобитися повторити
    • Робота є ресурсоємною
    • Робота залежить від зовнішніх сервісів, які не є повністю надійними
    • Результат не мусить бути частиною негайної відповіді

    Корисне запитання, яке варто поставити:

    Чи справді користувачу потрібен цей результат перед тим, як ви надсилатимете HTTP-відповідь?

    Якщо ні, варто розглянути можливість перенесення цієї роботи у фонове завдання.

    18. Архітектура у стилі продакшну

    Якщо об’єднати все це, типова конфігурація виглядає так:

    Client
                           ↓
                      Node.js API
                           ↓
                    ┌──────┴──────┐
                    ↓             ↓
                PostgreSQL      Redis
                                  ↓
                                Queue
                                  ↓
                       ┌──────────┼──────────┐
                       ↓          ↓          ↓
                    Worker 1   Worker 2   Worker 3
                       ↓          ↓          ↓
                    Email      PDF       Notifications
    

    Шар API обробляє все, що потрібно здійснити негайно. PostgreSQL (або інша база даних на ваш вибір) зберігає стабільні бізнес-дані. Redis підтримує інфраструктуру черг та інші короткострокові завдання, де це доцільно. Роботи відповідають за все, що може відбуватися асинхронно.

    Таке розподілення обов’язків значно полегшує масштабування всієї системи.

    19. Помилки, яких варто уникати

    Помилка 1: Виконання всього всередині HTTP-запиту

    Це призводить до API, які є одночасно повільними та крихкими.

    Помилка 2: Повторні спроби без обмежень

    Деякі проблеми просто не вирішаться самі по собі, незалежно від кількості спроб.

    Помилка 3: Ігнорування ідемпотентності

    Якщо завдання виконується двічі, це може спричинити дублювання побічних ефектів, яких ви не планували.

    Помилка 4: Дозвіл на необмежену конкурентність

    Без обмежень існує ризик перевантаження систем, від яких залежать ваші завдання.

    Помилка 5: Ігнорування моніторингу

    Очередь, яка продовжує зростати без контролю, є проблемою експлуатації, яка рано чи пізно проявиться.

    Помилка 6: Використання Redis як системи запису даних

    Стан очереді та основні бізнес-дані виконують різні функції та не повинні змішуватися.

    Помилка 7: Роблення всього асинхронним

    Деякі операції справді потребують завершення перед тим, як можна буде надіслати відповідь.

    20. Краща ментальна модель

    Перш ніж зрозуміти черги, природним бажанням є уявляти обробку запитів ось так:

    Request
       ↓
    Do everything
       ↓
    Response
    

    Більш корисна модель виглядає ось так:

    Request
       ↓
    Do what must happen immediately
       ↓
    Queue what can happen later
       ↓
    Response
    

    Далі йде:

    Queue
       ↓
    Worker
       ↓
    Process
       ↓
    Retry if appropriate
       ↓
    Complete / Fail
    

    Саме ця зміна — розділення того, що має відбутися зараз, від того, що може відбутися пізніше — є основною ідеєю всього цього.

    Остаточний висновок

    BullMQ є корисним не просто тому, що це широко використовувана бібліотека Node.js. Він корисний тому, що обробка фонових завдань вирішує справжню архітектурну проблему.

    Якщо певна робота є:

    • Повільною
    • Чимось, що можна спробувати знову
    • Чимось, що не повинно блокувати відповідь
    • Залежною від зовнішнього сервісу
    • Вимогливою до ресурсів

    тоді її, ймовірно, не варто виконувати в межах циклу HTTP-запитів.

    Очерга забезпечує місце для виконання цієї роботи. Redis надає базову інфраструктуру. BullMQ керує управлінням завданнями. Роботи виконують фактичну обробку даних. Повторні спроби допомагають подолати тимчасові збої. Налаштування конкурентності дозволяють контролювати пропускну здатність. Моніторинг повідомляє про те, коли щось йде не так. А ретельний дизайн на рівні додатку гарантує, що завдання можуть безпечно виконуватися більше одного разу, якщо це стане необхідним.

    Основний урок тут такий:

    Не все має вирішуватися протягом циклу запит-відповідь.

    Іноді правильною відповіддю є просто:

    "Я прийняв роботу. Ми подбаємо про решту."

    Пов’язана література