Дизайн async очередей и воркеров
At-least/exactly-once, idempotency, retry, DLQ, ordering, observability — очередь, которая не теряет и не дублирует.
Спроектируй async очередь для {{use_case}}. Throughput / SLA: {{throughput}}.
Базовая аксиома: distributed системы — это про "хотя бы один раз с дубликатами" и "что мы делаем, когда сломалось". Сначала отвечаешь на эти вопросы, потом выбираешь технологию.
1. Решаем — нужна ли вообще очередь
Очередь добавляет: extra component, eventual consistency, debugging сложнее, latency. Оправдано если:
- Нужна изоляция от падения downstream (webhook recipient down — не валим request)
- Resource-intensive job не должен блокировать HTTP request
- Batch / scheduled обработка
- Smoothing нагрузки (burst → steady)
- Retry с backoff невозможен синхронно
Иначе — синхронный вызов проще и надёжнее.
2. Семантика доставки
| Семантика | Что значит | Цена |
|---|---|---|
| At-most-once | Может потеряться, не дублируется | Простота, но потеря недопустима для billing |
| At-least-once | Не теряется, может дублироваться | Дефолт. Требует idempotency |
| Exactly-once | Ровно один раз | Дорого, ограниченно; обычно достижимо как at-least-once + idempotency на consumer |
Главный совет: проектируй под at-least-once + idempotent consumer. "Exactly-once delivery" — маркетинговое обещание; реально работает только end-to-end через idempotency.
3. Idempotency
Каждое сообщение имеет idempotency key — стабильный id, не меняющийся между retry.
async function handleMessage(msg) {
const seen = await db.tx(async (tx) => {
const inserted = await tx.insertOrIgnore('processed_messages',
{ id: msg.idempotency_key, processed_at: now() });
if (!inserted) return true; // уже обрабатывали
await applyEffect(tx, msg); // эффект и запись о нём в одной tx
return false;
});
if (seen) metrics.inc('duplicate');
}
Ключевые правила:
- Idempotency check + side effect в одной транзакции (или с помощью outbox для распределённого случая)
- TTL для записей: храним столько, сколько максимально может задержаться дубль (часы/дни)
- Не путать idempotency key с message id (id может меняться при republish; ключ — нет)
4. Retry стратегия
Каждый failure → класс:
- Transient (network blip, 5xx upstream): retry с exponential backoff + jitter
- Permanent (4xx, validation error, malformed payload): сразу в DLQ, без retry — иначе вечный цикл
- Throttled (429): respect
Retry-After, не делай свой backoff поверх
attempt N: delay = min(base * 2^N, max) ± jitter
- base 1-5s, max ~1h, attempts 5-10
- jitter обязателен (без него thundering herd при массовом failure upstream)
- Visibility timeout у очереди должен превышать max processing time + retry-delay, иначе сообщение получит другой worker до завершения
5. Dead letter queue (DLQ)
После N неудачных попыток — сообщение уходит в DLQ.
DLQ — не "место где умирают сообщения". Это очередь, которую кто-то обязан смотреть:
- Алерт на любую запись в DLQ (или > threshold)
- Tooling: показать payload, причину последней неудачи, число попыток
- Re-drive: вернуть в основную очередь после фикса
- Owner: команда, которая отвечает за consumer
Без процесса разбора DLQ — это просто медленная потеря данных.
6. Ordering
Большинство задач не нужен strict ordering. Если кажется что нужен — проверь дважды.
Когда нужен (per-entity):
- Изменения статуса заказа должны применяться в порядке
- Per-user updates в чате
Решения:
- Partitioning по entity key (Kafka, SQS FIFO с MessageGroupId) — гарантия порядка внутри партиции, не глобально
- Single-consumer per partition
Цена:
- Slow consumer блокирует всю partition
- Re-balance при scale up/down
- Нельзя просто "удвоить воркеров"
Альтернатива: stateful consumer, который сам сортирует/применяет (с version в сообщении). Сложнее, но избегаешь partition lock.
7. Outbox pattern
Проблема: записал в DB + опубликовал в очередь — две операции, между ними можно упасть. Получаем либо потерю, либо дубль.
Решение:
В одной транзакции:
INSERT INTO orders (...);
INSERT INTO outbox (event_type, payload, status='pending');
COMMIT;
Отдельный publisher poll'ит outbox → публикует → отмечает как sent.
- Никаких 2PC
- At-least-once гарантия из коробки
- Альтернативы: change data capture (Debezium), transactional outbox
8. Backpressure и шейпинг
Что делать, когда producer быстрее consumer:
- Bounded queue + reject новых сообщений (429 к producer) — лучше потерять один запрос, чем уронить систему
- Drop по priority — low-priority выкидывается первым
- Autoscaling consumer'ов по lag (Kafka consumer lag, SQS ApproximateNumberOfMessages)
- Circuit breaker на downstream — если 50% запросов к upstream падают, временно сбросить нагрузку
Анти-паттерн: unbounded очередь "потому что в memory дёшево". Через час будет OOM.
9. Выбор технологии
- SQS — managed, простой, at-least-once, no ordering (стандарт). FIFO вариант для ordering. Дёшев на старте
- RabbitMQ — гибкий routing, хорош для work distribution, требует ops
- Kafka — высокий throughput, log retention, нужен ordering per partition. Сложнее в эксплуатации
- Redis Streams / BullMQ — простой стек, но Redis HA — отдельная боль
- Cloud Tasks / EventBridge — для cron / scheduled / простого fan-out
- Temporal / DBOS — workflow engine, поверх абстракция (durable execution) если задача — оркестрация
Не выбирай Kafka "на вырост". Стартуй с SQS/Rabbit, мигрируй если упрёшься.
10. Observability
Минимум на каждую очередь:
- Producer rate, consumer rate, lag
- Median / p99 processing time
- Retry rate, DLQ inflow
- Error breakdown (by error class)
- Trace context передаётся в message (W3C traceparent в headers) — иначе невозможно связать сценарий end-to-end
Алерты:
- Lag > threshold (определяет SLA)
- DLQ inflow > 0 (любой попавший — повод смотреть)
- Consumer crash loop
- Spike in retry rate
11. Тестирование
- Local: in-memory queue mock для unit (testcontainers для integration)
- Property test: "обработка дубля даёт тот же эффект, что одного"
- Chaos: убей consumer в середине обработки, проверь что сообщение появится снова
- Load: проверь под 2x ожидаемого throughput с graceful degradation
Анти-паттерны
- Idempotency через "флаг в payload" вместо ключа в storage — race
- Retry без jitter / без потолка — thundering herd
- Долгая обработка внутри одной задачи (часы) — taймауты, перепубликации. Разбей на шаги
- Хранить payload в очереди размером в мегабайты — выноси в blob storage, в сообщении ссылка
- DLQ без monitoring и без процесса разбора
- Publish после commit'а транзакции (на ровном месте получаешь loss): используй outbox
- "Просто положим в Redis list" в продакшен — без persistence, без HA, без retry semantic
В конце
- Семантика доставки выбранная и почему
- Idempotency-схема (ключ, хранилище, TTL)
- Retry policy с числами
- DLQ и кто его смотрит
- Outbox или нет — обоснование
- Метрики и алерты
- Технология и почему именно она
Multi-agent: координатор и специалисты
Архитектура из координатора и специализированных агентов: передача контекста, дедупликация, race conditions.
Новый subagent или новый skill: что выбрать
Decision tree: создавать ли отдельного агента или достаточно skill. Критерии — контекст, переиспользование, frequency, complexity.
Architecture Decision Record (ADR)
Зафиксировать архитектурное решение: контекст, варианты, выбор и trade-offs.