Skip to content
PПромтбук
RUEN
04Архитектура

Дизайн 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 или нет — обоснование
  • Метрики и алерты
  • Технология и почему именно она
К подразделу «Архитектура»
Похожие промты