Засновник описує вимогу: «Нам треба слати листи після реєстрації, генерувати звіти щовечора і викликати external API, коли стріляє webhook».
Кімната кивнула. Хтось сказав: «Потрібна черга. RabbitMQ? SQS? BullMQ?».
Для 80% фонових задач правильна відповідь — жодне з цього. Таблиця jobs у Postgres, воркер, що її опитує, і cron-розклад для повторюваного. Та сама база, яку ви вже запускаєте. Та сама авторизація. Ті самі бекапи.
Цей пост — про той паттерн. Що він уміє, коли його достатньо, що ви втрачаєте порівняно зі справжньою чергою, і як його підключити.
Паттерн
Три елементи:
- Таблиця
jobsу Postgres зі status-колонкою і payload. - Воркер-процес, що опитує таблицю на задачі для виконання.
- Cron-розклад (або webhook), що створює нові рядки задач, коли є робота.
Це вся архітектура. Можливо, 200 рядків коду для воркера. Жодної нової інфри, жодного нового вендора, жодної нової авторизаційної моделі.
Чому це працює
У Postgres є кожен примітив, потрібний для черги:
- `SELECT ... FOR UPDATE SKIP LOCKED` — атомарний клейм рядка задачі. Кілька воркерів можуть опитувати одночасно, не клеймлячи ту саму задачу.
- JSON-колонки — зберігайте довільну структуру payload без міграції схеми на кожен тип задачі.
- Транзакції — клейм задачі, виконання роботи, помітка completed — усе в одній транзакції. Якщо щось провалюється, клейм звільняється.
- Індекси — order by
created_atчиpriority— це просто index scan.
Те, чим люди хвилюються («Postgres — не справжня черга»), реальне на високому масштабі. Не на масштабах, на яких більшість додатків насправді працюють. Нижче 10 000 задач на хвилину (а більшість додатків глибоко нижче 100/хв) Postgres справляється без поту.
Робоча імплементація
Схема:
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):
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, потрібний більшості додатків; підіймайте двох-трьох для паралельної роботи.
Щоб додати задачу будь-звідки у застосунку:
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 просто вставляє рядок задачі.
// /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 напряму:
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, з яким ми працювали, мав саме цю розмову «нам потрібна черга». Реальні задачі:
- Слати welcome-лист після реєстрації. ~50/день.
- Обробляти завантажений CSV (парс, валідація, вставка). ~10/день, кожен може зайняти 30 секунд.
- Слати щоденні digest-листи. ~2 000/день, батч в одному 30-хвилинному проганянні.
- Синхронізувати з third-party CRM при зміні запису клієнта. ~100/день.
- Генерувати тижневий аналітичний звіт. ~10/тиждень.
Усього: ~3 000 задач/день у середньому. Пік: 100/хв під час digest-вікна.
Команда чернеткувала архітектуру з SQS для задач, Lambda для обробки, DynamoDB для стану задач і CloudWatch для моніторингу. П'ять AWS-сервісів.
Ми побудували Postgres-паттерн натомість. Одна таблиця, один воркер-процес, що працює на Fly. Через три місяці обробляє усі п'ять воркфлоу надійно. Команда жодного разу не думала про job-інфру.
Економія: ~$80/місяць на AWS-рахунках, плюс інженерний час, що пішов би на AWS-сантехніку.
Ідемпотентність, недооцінена дисципліна
Яку б чергу ви не використовували (Postgres чи іншу), робіть хендлери задач ідемпотентними. Та сама задача, виконана двічі = той самий результат. Це не обговорюється.
Як: використайте natural key задачі для виявлення дублікатів.
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 правильний дефолт для більшості додатків — використати базу, яку ви вже запускаєте.
Якщо хочете веб-застосунок із цим паттерном зашитим з початку, подивіться, як ми працюємо з веб-застосунками.