Перейти до вмісту
51studio
Туторіали

Фонові задачі без черги

Автор: Riley Cain10 хв читання

Засновник описує вимогу: «Нам треба слати листи після реєстрації, генерувати звіти щовечора і викликати external API, коли стріляє webhook».

Кімната кивнула. Хтось сказав: «Потрібна черга. RabbitMQ? SQS? BullMQ?».

Для 80% фонових задач правильна відповідь — жодне з цього. Таблиця jobs у Postgres, воркер, що її опитує, і cron-розклад для повторюваного. Та сама база, яку ви вже запускаєте. Та сама авторизація. Ті самі бекапи.

Цей пост — про той паттерн. Що він уміє, коли його достатньо, що ви втрачаєте порівняно зі справжньою чергою, і як його підключити.

Паттерн

Три елементи:

  1. Таблиця jobs у Postgres зі status-колонкою і payload.
  2. Воркер-процес, що опитує таблицю на задачі для виконання.
  3. Cron-розклад (або webhook), що створює нові рядки задач, коли є робота.

Це вся архітектура. Можливо, 200 рядків коду для воркера. Жодної нової інфри, жодного нового вендора, жодної нової авторизаційної моделі.

Чому це працює

У Postgres є кожен примітив, потрібний для черги:

  • `SELECT ... FOR UPDATE SKIP LOCKED` — атомарний клейм рядка задачі. Кілька воркерів можуть опитувати одночасно, не клеймлячи ту саму задачу.
  • JSON-колонки — зберігайте довільну структуру payload без міграції схеми на кожен тип задачі.
  • Транзакції — клейм задачі, виконання роботи, помітка completed — усе в одній транзакції. Якщо щось провалюється, клейм звільняється.
  • Індекси — order by created_at чи priority — це просто index scan.

Те, чим люди хвилюються («Postgres — не справжня черга»), реальне на високому масштабі. Не на масштабах, на яких більшість додатків насправді працюють. Нижче 10 000 задач на хвилину (а більшість додатків глибоко нижче 100/хв) Postgres справляється без поту.

Робоча імплементація

Схема:

sql
CREATE TABLE jobs (
  id BIGSERIAL PRIMARY KEY,
  kind TEXT NOT NULL,
  payload JSONB NOT NULL,
  status TEXT NOT NULL DEFAULT 'pending',
  attempts INT NOT NULL DEFAULT 0,
  max_attempts INT NOT NULL DEFAULT 3,
  run_after TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  started_at TIMESTAMPTZ,
  completed_at TIMESTAMPTZ,
  error TEXT
);

CREATE INDEX idx_jobs_pending ON jobs (run_after) WHERE status = 'pending';

Воркер-цикл (TypeScript):

ts
async function processNextJob(db) {
  const job = await db.transaction(async (tx) => {
    const [claimed] = await tx.execute(sql`
      UPDATE jobs SET status = 'running', started_at = NOW(), attempts = attempts + 1
      WHERE id = (
        SELECT id FROM jobs
        WHERE status = 'pending' AND run_after <= NOW()
        ORDER BY run_after
        LIMIT 1
        FOR UPDATE SKIP LOCKED
      )
      RETURNING *
    `);
    return claimed;
  });

  if (!job) return false;

  try {
    await handlers[job.kind](job.payload);
    await db.execute(sql`
      UPDATE jobs SET status = 'completed', completed_at = NOW() WHERE id = ${job.id}
    `);
  } catch (err) {
    const next = job.attempts >= job.max_attempts
      ? { status: 'failed' }
      : { status: 'pending', run_after: new Date(Date.now() + 60_000 * 2 ** job.attempts) };
    await db.execute(sql`
      UPDATE jobs SET status = ${next.status}, run_after = ${next.run_after ?? job.run_after}, error = ${err.message}
      WHERE id = ${job.id}
    `);
  }
  return true;
}

async function workerLoop(db) {
  while (true) {
    const worked = await processNextJob(db);
    if (!worked) await new Promise((r) => setTimeout(r, 1000));
  }
}

Це воркер. Запустіть як окремий процес (або машину fly.io, або сервіс Railway, або long-lived функцію Vercel). Один воркер обробляє throughput, потрібний більшості додатків; підіймайте двох-трьох для паралельної роботи.

Щоб додати задачу будь-звідки у застосунку:

ts
await db.insert(jobs).values({
  kind: 'send-welcome-email',
  payload: { userId, email },
});

Готово. Воркер підхопить її при наступному опитуванні.

Що отримуєте

Цей паттерн включає більшість того, що пропонує «справжня» черга:

  • Ретраї з експоненційним бекофом — див. 2 ** attempts у гілці помилки.
  • Dead letter handling — задачі, що провалюються max_attempts разів, переходять у статус 'failed'. Запит до них: SELECT * FROM jobs WHERE status = 'failed'.
  • Заплановані задачі — поставте run_after на майбутній timestamp; воркер не підхопить, поки час не настане.
  • Пріоритизація задач — додайте колонку priority, змініть ORDER BY.
  • Атомарний клеймSKIP LOCKED запобігає виконанню тієї самої задачі двома воркерами.
  • СпостереженістьSELECT status, COUNT(*) FROM jobs GROUP BY status — ваш дашборд.

Чого не отримуєте з коробки:

  • Push-нотифікації воркерам. Воркери опитують. 1-секундний sleep у циклі додає ~1с латентності в середньому. Для більшості use cases це нормально; для sub-second response використайте Postgres LISTEN/NOTIFY, щоб будити воркера.
  • Географічно розподілені черги. Усі воркери б'ють у той самий Postgres. Cross-region налаштування потребують справжньої черги.
  • Масовий fan-out на високому QPS. SQS обробляє десятки тисяч на секунду; Postgres job-таблиці спадають на сотнях за секунду на воркера.

Cron-розклад для повторюваних задач

Для «вечірніх звітів» або «кожні 5 хвилин» задач не потрібен окремий cron-сервіс. Дві опції:

Опція A: Запланована функція на вашій платформі.

Vercel Cron, Cloudflare Cron Triggers, Fly Machines з cron-style тригером. Викликає endpoint за розкладом. Endpoint просто вставляє рядок задачі.

ts
// /api/cron/nightly-reports
export async function GET() {
  await db.insert(jobs).values({ kind: 'generate-nightly-report', payload: {} });
  return Response.json({ ok: true });
}

Опція B: розширення pg_cron.

У Postgres є розширення pg_cron, що планує SQL напряму:

sql
SELECT cron.schedule('nightly-reports', '0 2 * * *', $$
  INSERT INTO jobs (kind, payload) VALUES ('generate-nightly-report', '{}')
$$);

Доступне на Supabase, Neon (у beta) і self-hosted. Уникайте на RDS Postgres (не підтримується на момент написання).

Будь-яка працює. Ми за замовчуванням беремо platform cron, бо він універсальний; pg_cron гарніший, якщо ви вже на базі, що його підтримує.

Коли цього достатньо

Конкретні сигнали, що Postgres-паттерн — правильний вибір:

  • Ваша швидкість задач — під кілька сотень за секунду на піку.
  • Більшість задач завершується менше ніж за хвилину (довгі теж нормально, але треба обробляти рестарти воркера).
  • Воркери запускаються в одному регіоні (або двох, з Postgres-репліками у кожному).
  • Ви вже використовуєте Postgres для даних застосунку.
  • Команда воліла б не вчити API і дашборд нового вендора.

Більшість додатків відповідає усім п'яти. Сумарна операційна складність — це «у нас ще один long-running процес під назвою воркер». Усе.

Коли потрібна справжня черга

Сигнали для переходу:

  • Throughput задач понад ~5 000/секунду стабільно. Postgres не встигає на цій швидкості. Час для SQS, Kafka або NATS.
  • Потрібен cross-region fan-out. Кілька дата-центрів споживають ту саму чергу. Реплікація Postgres не побудована під це.
  • Потрібна семантика at-most-once delivery. Postgres дає at-least-once (у поєднанні з ідемпотентними хендлерами). Якщо потрібно exactly-once для білінгу чи подібного — черга з дедуплікацією.
  • Потрібен pub-sub (кілька споживачів отримують кожне повідомлення). Postgres job-таблиці — це work-queue, не pub-sub. Використайте Kafka, NATS, Redis Streams.
  • Потрібна потокова обробка з часовими вікнами. Агрегації над rolling 60-секундними вікнами. Stream-процесори — правильний інструмент.

Якщо нічого з цього не застосовно — черга як сервіс зайва.

Конкретний приклад

B2B SaaS, з яким ми працювали, мав саме цю розмову «нам потрібна черга». Реальні задачі:

  1. Слати welcome-лист після реєстрації. ~50/день.
  2. Обробляти завантажений CSV (парс, валідація, вставка). ~10/день, кожен може зайняти 30 секунд.
  3. Слати щоденні digest-листи. ~2 000/день, батч в одному 30-хвилинному проганянні.
  4. Синхронізувати з third-party CRM при зміні запису клієнта. ~100/день.
  5. Генерувати тижневий аналітичний звіт. ~10/тиждень.

Усього: ~3 000 задач/день у середньому. Пік: 100/хв під час digest-вікна.

Команда чернеткувала архітектуру з SQS для задач, Lambda для обробки, DynamoDB для стану задач і CloudWatch для моніторингу. П'ять AWS-сервісів.

Ми побудували Postgres-паттерн натомість. Одна таблиця, один воркер-процес, що працює на Fly. Через три місяці обробляє усі п'ять воркфлоу надійно. Команда жодного разу не думала про job-інфру.

Економія: ~$80/місяць на AWS-рахунках, плюс інженерний час, що пішов би на AWS-сантехніку.

Ідемпотентність, недооцінена дисципліна

Яку б чергу ви не використовували (Postgres чи іншу), робіть хендлери задач ідемпотентними. Та сама задача, виконана двічі = той самий результат. Це не обговорюється.

Як: використайте natural key задачі для виявлення дублікатів.

ts
async function sendWelcomeEmail({ userId }) {
  const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
  if (user.welcomeEmailSent) return; // ідемпотентно
  await emailService.send({ to: user.email, ... });
  await db.update(users).set({ welcomeEmailSent: true }).where(eq(users.id, userId));
}

З ідемпотентними хендлерами ретраї безпечні, і at-least-once гарантія черги достатня. Без ідемпотентності ретрай може двічі надіслати welcome-лист. Не відвантажуйте фонові задачі без цієї дисципліни.

Підсумок

Для більшості додатків з фоновими задачами:

  • Postgres jobs-таблиця + воркер-процес + cron-розклад.
  • 200 рядків коду всього.
  • Ідемпотентні хендлери.
  • Переоцінити на 5 000 задач/секунду або коли fan-out стане реальною потребою.

Рефлекс «вам потрібна черга» — з іншої епохи. У 2026 правильний дефолт для більшості додатків — використати базу, яку ви вже запускаєте.

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

Схожі статті