Очереди и фоновые задачи: когда пора выносить работу из запроса
Отчёт на пять минут, рассылка на десять тысяч писем, выгрузка в 1С — всё это не должно жить в запросе пользователя. Разбираем, как переносить работу в фон.

Симптом всегда один: страница «думает» и падает по таймауту. Внутри — генерация отчёта, отправка сотни писем или выгрузка в стороннюю систему. Пользователь ждёт, браузер обрывает соединение, работа прерывается на середине, а данные остаются в непонятном состоянии. Лечится это не увеличением таймаута, а переносом работы в фон.
Что обязательно выносить из запроса
| Операция | Где выполнять | Почему |
|---|---|---|
| Отправка письма | Очередь | Почтовый сервис может отвечать секундами или не ответить |
| Выгрузка в стороннюю систему | Очередь | Чужая доступность не должна ломать ваш запрос |
| Генерация отчёта или документа | Очередь | Время зависит от объёма данных и непредсказуемо |
| Обработка загруженного файла | Очередь | Разбор большой таблицы не укладывается в лимит запроса |
| Пересчёт агрегатов | Расписание | Нужен результат к моменту просмотра, а не по запросу |
| Запись в свою базу | Запрос | Быстро и должно быть согласовано с ответом |
Минимальная очередь
Отдельный брокер сообщений нужен не сразу. На старте достаточно таблицы задач в той же базе: она даёт транзакционность, простую отладку и понятную выборку. Переходить на специализированное решение стоит, когда задач становится десятки тысяч в сутки.
create table jobs (
id bigserial primary key,
kind text not null,
payload jsonb not null,
run_at timestamptz not null default now(),
attempts int not null default 0,
max_attempts int not null default 5,
locked_at timestamptz,
finished_at timestamptz,
last_error text
);
create index jobs_pending on jobs (run_at)
where finished_at is null;
-- skip locked позволяет запустить несколько обработчиков:
-- каждый берёт свои задачи и они не конфликтуют
update jobs set locked_at = now(), attempts = attempts + 1
where id = (
select id from jobs
where finished_at is null and locked_at is null and run_at <= now()
order by run_at
limit 1
for update skip locked
)
returning *;Ключевая конструкция здесь — `for update skip locked`. Она позволяет держать несколько обработчиков одновременно: каждый забирает свою задачу, и две копии одной работы не запускаются.
Повторы и предел попыток
Задачи падают, это нормально. Важно, чтобы падение не превращалось ни в бесконечный цикл, ни в тихую потерю. Схема, которая работает: экспоненциальная задержка, ограниченное число попыток, отдельная таблица окончательно проваленных задач с возможностью перезапуска руками.
async function runJob(job: Job) {
try {
await handlers[job.kind](job.payload)
await db.update(jobs).set({ finishedAt: new Date() }).where(eq(jobs.id, job.id))
} catch (error) {
const failedForGood = job.attempts >= job.maxAttempts
if (failedForGood) {
// Не удаляем и не прячем: задача должна попасть человеку на разбор
await moveToDeadLetter(job, error)
reportMetric("jobs.dead_letter", 1, { kind: job.kind })
return
}
// 1 мин, 2, 4, 8... — даём внешней системе шанс восстановиться
const delayMinutes = 2 ** job.attempts
await db.update(jobs)
.set({ lockedAt: null, runAt: minutesFromNow(delayMinutes), lastError: String(error) })
.where(eq(jobs.id, job.id))
}
}За чем следить
- Длина очереди: устойчивый рост означает, что обработчики не успевают
- Возраст самой старой незавершённой задачи — точнее длины показывает задержку
- Число окончательно проваленных задач по типам
- Время выполнения по типам: рост обычно предшествует отказу
Из этих четырёх показателей самый полезный — возраст старейшей задачи. Длина очереди может быть большой при нормальной работе, а вот задача, которая висит час, почти всегда означает проблему.
Очередь не ускоряет работу. Она делает срок выполнения независимым от терпения пользователя и от доступности чужих систем.
Частые вопросы
- Нужен ли Redis или RabbitMQ для очередей
- На старте нет: таблица задач в PostgreSQL закрывает потребности при объёмах до нескольких десятков тысяч задач в сутки и проще в отладке. Специализированный брокер оправдан при высоком темпе или когда нужна маршрутизация между несколькими сервисами.
- Как показать пользователю результат фоновой задачи
- Возвращаем идентификатор задачи и показываем состояние: в работе, готово, ошибка. Дальше либо периодический опрос состояния, либо уведомление по готовности. Для отчётов достаточно письма со ссылкой на файл.
- Что делать с задачами, которые провалились окончательно
- Складывать в отдельную таблицу с текстом ошибки и данными задачи, показывать в админке и давать кнопку перезапуска. Автоматическое удаление таких задач — гарантированный способ потерять данные незаметно.
Похожая задача в вашем проекте?
Разберём вашу ситуацию и пришлём оценку по этапам — без обязательств и общих слов.
Обсудить задачу

