Три ошибки, когда разносишь пайплайн в очередь
Если коротко
- Один честный сигнал, после которого синхронный пайплайн становится дороже асинхронного.
- Минимальная архитектура «шлюз + очередь + воркер» — три компонента, без героики.
- Три ошибки миграции, которые делал я. Все три — про предположение, что «ничего не пойдёт не так».
Minimalist isometric illustration: HTTP gateway on the left passing a small parcel into a horizontal queue (pneumatic-tube style), the queue feeding two worker servers on the right. One worker glows softly (processing), the other is idle. Calm slate-to-indigo gradient, no logos, no readable text, clean line work, 16:9, 1600x900.
Сначала вы держите весь пайплайн в одном HTTP-обработчике. Это нормально. Это даже правильно — пока работает.
Потом однажды OCR занимает восемь секунд, эмбеддинг ещё четыре, и клиент честно отваливается до ответа. С этого момента синхронный пайплайн обходится дороже асинхронного — даже с учётом всей операционной боли.
Когда пора
Сигнал один: обработка одного запроса длится дольше, чем клиент готов ждать. Для веб-API это 2-3 секунды.
Не миграция ради миграции. Не «у всех очереди — давайте и мы». Раздельные процессы — это операционная сложность. Платите её, когда выгода становится больше боли. Не раньше.
Минимальная архитектура
Три компонента, ничего больше:
- Шлюз (FastAPI). Принимает запрос, валидирует, пишет в БД «задача создана» со статусом
queued, кладёт id в очередь, возвращает 202 с этим id. - Очередь (RabbitMQ). Один durable queue. Сообщение — JSON с
job_idиdocument_id. Никакого крупного контента — он уже в БД. - Воркер. Читает сообщение, по id поднимает данные, прогоняет пайплайн, обновляет статус.
Клиент пингует GET /jobs/{id} или (лучше) дашборд сам опрашивает статус и обновляет UI. WebSocket в первой версии — это понты, отложите.
Ошибка 1. Полезная нагрузка в сообщении
Я начинал с того, что клал в сообщение «удобно всё, что пригодится воркеру».
Сообщение разрастается, RabbitMQ упирается в память, ретраи тащат гигабайты по сети — потому что «удобно». В сообщении должен быть только id. Всё остальное — поднимается из БД.
Ошибка 2. Молчаливое предположение об «exactly once»
Сообщение могут доставить дважды. Это нормально, это by design.
Воркер обязан проверять статус задачи и пропускать уже выполненные. Иначе на каждом ретрае получаете дубликаты в индексе и потом долго объясняете аналитикам, почему документов стало больше, чем было загружено.
Ошибка 3. Воркер падает молча
Если воркер уронился в середине, задача остаётся в статусе running навсегда. Очередь думает, что её взяли, БД — что с ней работают. Все вежливо ждут.
Лечится одним из двух: heartbeat от воркера, либо правило «протухшие running старше N секунд может подобрать другой воркер». Без этого ваш самый страшный баг — отсутствие багов, при котором ничего не происходит.
- Замерьте p95 обработки одного запроса. Меньше 2 секунд — не торопитесь с очередью.
- Больше — добавьте таблицу
processing_jobsс явной state-машиной (queued / running / succeeded / failed). - Дальше — «Когда LLM падает, сервер не должен падать с ним»: про каскадные падения внутри воркера.