Модуль: System Design · Уровень: Senior

TL;DR#

Order Service — это «оркестратор истины» о заказе: он владеет состоянием заказа (state machine), координирует резервирование товара (inventory), оплату (payment) и доставку (shipping). Главная сложность — не CRUD, а согласованность между сервисами без распределённой транзакции 2PC. Решение: state machine + saga (orchestration) + outbox + идемпотентность + reserve-with-TTL для inventory. Ключевые враги: oversell на hot SKU, дубли заказов при ретраях, «застрявшие» саги, и UX поверх eventual consistency. На senior+ копают именно границы консистентности: где вы допускаете рассинхрон, как его чините (reconciliation), и почему не выбрали 2PC.

Требования#

Functional#

  • Создание заказа из корзины (N позиций, каждая со своим SKU и количеством).
  • Резервирование товара под заказ; release резерва при отмене/таймауте.
  • Координация оплаты (authorize → capture) и инициация доставки.
  • Жизненный цикл: created → pending → paid → fulfilled → shipped → delivered, плюс ветки cancelled / refunded.
  • Отмена заказа пользователем/системой с компенсациями (вернуть резерв, вернуть деньги).
  • Идемпотентное создание (двойной клик / ретрай клиента не создаёт два заказа).
  • История изменений статуса (audit log / event log), запрос статуса заказа.

Non-functional#

  • Корректность важнее доступности для денег и склада: не списать дважды, не продать то, чего нет (no oversell).
  • Доступность создания заказа: 99.95% (это путь к деньгам).
  • p99 создания заказа (синхронная часть до «принято») < 300 ms; полное прохождение саги — секунды-минуты, асинхронно.
  • Durability заказа и событий — потеря недопустима (outbox + реплицируемая БД).
  • Idempotency для всех внешних вызовов (payment, inventory).
  • Auditability: любое изменение статуса восстановимо.
  • Масштаб под пики (Black Friday) без потери данных, допустим graceful degradation read-путей.

Оценки нагрузки#

Возьмём средний крупный e-commerce:

  • Orders/day (норма): 5M заказов/день.
    • Средний QPS записи: 5_000_000 / 86_400 ≈ 58 orders/s.
    • Пик/средний для торговли обычно ×3–5 в обычный день → пик ~250–300 orders/s.
  • Black Friday: ×10–20 от обычного пика.
    • Пиковый создания заказов: ~3 000–5 000 orders/s.
    • На один заказ: 1 запись order + N резервов inventory (среднее N=3) + 1 payment authorize + события.
    • Запись в inventory на пике: 5000 × 3 ≈ 15 000 reservation ops/s, и они бьют в горячие SKU.
  • Read/Write ratio: статус заказа смотрят часто (пользователь обновляет страницу, support, аналитика). Чтения ≈ ×20–50 от записей → на пике десятки-сотни тыс. read/s. Чтения легко кэшировать и реплицировать.
  • Storage:
    • Один заказ ~2 KB (заголовок + позиции) + история статусов ~0.5 KB.
    • 5M/день × 2.5 KB ≈ 12.5 GB/день «горячих» данных, ~4.5 TB/год.
    • Outbox/event log на порядок больше по числу строк, но короткоживущий (TTL/архив в S3 + аналитика).
  • Cardinality SKU: ~10M SKU, но 80% продаж — top ~50k SKU (hot set). Именно они дают contention.

Вывод по числам: запись order — линейно шардируется; узкое место — inventory на hot SKU, не сам order service.

Архитектура#

                         ┌─────────────────────────────┐
            create order │       Order Service          │
  client ───────────────▶│  (owns Order state machine)  │
   (mobile/web)          │  + Idempotency store         │
        ▲                │  + Outbox table (same TX)    │
        │ status         └───────┬──────────────┬───────┘
        │ (eventual)             │ write order  │
        │                        ▼              ▼
        │                ┌──────────────┐  ┌──────────┐
        │                │  Orders DB   │  │  Outbox  │
        │                │ (sharded)    │  │  table   │
        │                └──────────────┘  └────┬─────┘
        │                                       │ CDC / poller
        │                                       ▼
        │                              ┌──────────────────┐
        │   read model / cache         │  Kafka / broker  │
        └──────────────────────────────┤  (order events)  │
                                        └───┬────┬─────┬───┘
                  Saga Orchestrator ◀───────┘    │     │
                  (drives the flow)              │     │
                       │ commands (idempotent)   │     │
          ┌────────────┼──────────────┬──────────┘     │
          ▼            ▼              ▼                 ▼
   ┌────────────┐ ┌──────────┐ ┌────────────┐   ┌────────────┐
   │ Inventory  │ │ Payment  │ │  Shipping  │   │  Analytics │
   │  Service   │ │ Service  │ │  Service   │   │  / DWH     │
   │ reserve-   │ │ auth/    │ │ create     │   └────────────┘
   │ with-TTL   │ │ capture  │ │ shipment   │
   └────────────┘ └──────────┘ └────────────┘

Компоненты#

  • Order Service — единственный владелец состояния заказа. Реализует state machine, хранит заказ + idempotency-ключи + outbox в одной БД/транзакции.
  • Outbox table — события пишутся в той же транзакции, что и изменение order. Отдельный поллер/CDC (Debezium) публикует их в брокер → at-least-once без потери (решает «dual write problem»).
  • Saga Orchestrator — отдельный воркер (часто внутри Order Service как state-driven процесс), читающий события и отправляющий идемпотентные команды в Inventory/Payment/Shipping, обрабатывающий ответы и компенсации.
  • Inventory Service — владеет остатками и резервами. Резерв с TTL (см. ниже). Сам решает oversell-вопрос на уровне строки SKU.
  • Payment Service — authorize (hold) при оформлении, capture после fulfilled. Поддерживает refund (компенсация).
  • Shipping Service — создание отправления, трекинг.
  • Read model / cache — денормализованный статус для клиента (Redis + read-реплики), обновляется из событий.

Ключевые решения и trade-offs#

1. Жизненный цикл заказа и State Machine#

Состояния и легальные переходы:

created ──▶ pending ──▶ paid ──▶ fulfilled ──▶ shipped ──▶ delivered
   │           │          │          │
   │           ▼          ▼          ▼
   └────────▶ cancelled  refunded   refunded (после возврата)
  • created — запись появилась, резерв ещё не подтверждён.
  • pending — резерв inventory получен, ждём оплату (payment authorized).
  • paid — оплата подтверждена (capture или authorize+later capture).
  • fulfilled — собрано на складе, резерв превратился в списание.
  • shipped / delivered — логистика.
  • cancelled — до оплаты/до отгрузки; компенсация = release резерва, void авторизации.
  • refunded — после оплаты; компенсация = возврат денег + (опц.) возврат товара на склад.

Принцип: переходы должны быть явно перечислены и валидироваться; любые «прыжки» запрещены. State machine должна быть детерминированной и идемпотентной (повторный переход в то же состояние — no-op, а не ошибка).

type State string

const (
    Created   State = "created"
    Pending   State = "pending"
    Paid      State = "paid"
    Fulfilled State = "fulfilled"
    Shipped   State = "shipped"
    Delivered State = "delivered"
    Cancelled State = "cancelled"
    Refunded  State = "refunded"
)

type Event string

const (
    EvReserved  Event = "inventory_reserved"
    EvPaid      Event = "payment_captured"
    EvFulfilled Event = "fulfilled"
    EvShipped   Event = "shipped"
    EvDelivered Event = "delivered"
    EvCancel    Event = "cancel"
    EvRefund    Event = "refund"
)

// transitions[from][event] = to
var transitions = map[State]map[Event]State{
    Created:   {EvReserved: Pending, EvCancel: Cancelled},
    Pending:   {EvPaid: Paid, EvCancel: Cancelled},
    Paid:      {EvFulfilled: Fulfilled, EvRefund: Refunded},
    Fulfilled: {EvShipped: Shipped, EvRefund: Refunded},
    Shipped:   {EvDelivered: Delivered},
    // terminal: Delivered, Cancelled, Refunded
}

var ErrIllegalTransition = errors.New("illegal state transition")

// Apply возвращает новое состояние. Идемпотентность: если событие
// уже привело нас в целевое состояние, считаем это no-op (для at-least-once).
func Apply(cur State, ev Event) (State, error) {
    next, ok := transitions[cur][ev]
    if !ok {
        return cur, fmt.Errorf("%w: %s --%s-->", ErrIllegalTransition, cur, ev)
    }
    return next, nil
}

Идемпотентность перехода на уровне БД (optimistic версия), чтобы повтор события не двигал стейт второй раз:

func (r *Repo) Transition(ctx context.Context, orderID string, ev Event, expectedVer int64) error {
    return r.tx(ctx, func(tx *sql.Tx) error {
        var cur State
        var ver int64
        err := tx.QueryRowContext(ctx,
            `SELECT state, version FROM orders WHERE id=$1 FOR UPDATE`, orderID).
            Scan(&cur, &ver)
        if err != nil {
            return err
        }
        next, err := Apply(cur, ev)
        if err != nil {
            // Если уже в целевом состоянии — это безопасный повтор события.
            if alreadyApplied(cur, ev) {
                return nil // no-op, idempotent
            }
            return err
        }
        // optimistic guard от гонок саги/ретраев
        res, err := tx.ExecContext(ctx,
            `UPDATE orders SET state=$1, version=version+1, updated_at=now()
             WHERE id=$2 AND version=$3`, next, orderID, ver)
        if err != nil {
            return err
        }
        if n, _ := res.RowsAffected(); n == 0 {
            return ErrConcurrentUpdate // ретрай саги
        }
        // событие в outbox в той же транзакции
        return writeOutbox(ctx, tx, orderID, next)
    })
}

2. Inventory reservation: три подхода#

ПодходКак работаетПлюсыМинусыКогда
Pessimistic lockSELECT ... FOR UPDATE строки SKU, уменьшаем availableПростая, строго без oversellСериализация на hot SKU → throughput падает, риск deadlockНизкий контеншн, критичный товар
Optimistic (CAS)UPDATE ... SET available=available-q WHERE sku=? AND available>=qБез блокировок, дёшево, atomicНа пике много ретраев на hot SKU (write contention)Дефолт для большинства SKU
Reserve-with-TTLРезерв = отдельная запись с истечением; стоки = available - reserved. Резерв авто-освобождается по TTLDecouples оформление и оплату; нет вечно висящих холдов; хорошо ложится на корзинуНужен reaper/sweeper; «available» = вычисляемоеДефолт для e-commerce checkout

Reserve-with-TTL — практический стандарт. Резерв создаётся на N минут (например, 15 мин под оплату). Если оплата не пришла — резерв истекает и товар возвращается в продажу.

Atomic-резерв через условный UPDATE (без блокировки на чтении), плюс запись резерва с TTL:

// Возвращает true, если резерв удался; false — недостаточно остатка (без oversell).
func (s *Inventory) Reserve(ctx context.Context, sku, orderID string, qty int, ttl time.Duration) (bool, error) {
    var ok bool
    err := s.tx(ctx, func(tx *sql.Tx) error {
        // Атомарное условное списание доступного остатка.
        res, err := tx.ExecContext(ctx, `
            UPDATE stock
               SET available = available - $1
             WHERE sku = $2 AND available >= $1`, qty, sku)
        if err != nil {
            return err
        }
        n, _ := res.RowsAffected()
        if n == 0 {
            ok = false
            return nil // не хватило — корректный исход, не ошибка
        }
        // Идемпотентность: один резерв на (order_id, sku).
        _, err = tx.ExecContext(ctx, `
            INSERT INTO reservations(order_id, sku, qty, expires_at, status)
            VALUES ($1,$2,$3, now() + $4, 'held')
            ON CONFLICT (order_id, sku) DO NOTHING`,
            orderID, sku, qty, ttl)
        if err != nil {
            return err
        }
        ok = true
        return nil
    })
    return ok, err
}

Reaper освобождает протухшие резервы (возвращает количество в available), запускается каждые ~10 с:

func (s *Inventory) ReleaseExpired(ctx context.Context, batch int) (int, error) {
    // UPDATE ... RETURNING для атомарного захвата пачки + возврат стока одним TX.
    // status: held -> released, available += qty
    return s.releaseBatch(ctx, batch)
}

3. Распределённая транзакция order+payment+inventory: Saga#

2PC отвергаем: блокирует ресурсы между сервисами, плохо масштабируется, координатор — SPOF, не работает с внешними платёжными провайдерами. Вместо этого — saga: последовательность локальных транзакций с компенсациями.

Choreography vs Orchestration:

  • Choreography — сервисы реагируют на события друг друга, нет центрального координатора. Плюс: слабая связанность. Минус: логику саги невозможно увидеть в одном месте, циклические зависимости событий, сложно отлаживать и менять. Хорошо для 2-3 шагов.
  • Orchestration — центральный оркестратор (часто Order Service) явно дирижирует шагами и компенсациями. Плюс: вся бизнес-логика в одном месте, видно состояние саги, проще timeout/retry/компенсации. Минус: оркестратор сложнее, риск «god service».

Для order+payment+inventory+shipping (4+ шага, нужны компенсации и таймауты) — orchestration. На senior-собеседовании именно так и аргументируют.

Happy path и компенсации:

Шаг               Действие                     Компенсация
1. Reserve        inventory.Reserve (TTL)      inventory.Release
2. Authorize      payment.Authorize (hold)     payment.Void
3. Capture        payment.Capture              payment.Refund
4. Fulfill        inventory.Commit (held→sold) inventory.Restock (вернуть на склад)
5. Ship           shipping.Create              shipping.Cancel

Если шаг падает — оркестратор выполняет компенсации в обратном порядке для уже выполненных шагов. Saga — это eventual consistency, а не атомарность: момент, когда деньги списаны, но shipment ещё не создан, существует — и это нормально, его надо «дотянуть» или компенсировать.

Эскиз оркестратора:

type Step struct {
    Name       string
    Do         func(ctx context.Context, s *SagaState) error
    Compensate func(ctx context.Context, s *SagaState) error
}

func (o *Orchestrator) Run(ctx context.Context, s *SagaState, steps []Step) error {
    for i, step := range steps {
        // Каждый Do идемпотентен по s.OrderID (idempotency key вниз по стеку).
        if err := step.Do(ctx, s); err != nil {
            // компенсируем уже выполненные шаги в обратном порядке
            for j := i - 1; j >= 0; j-- {
                if cerr := steps[j].Compensate(ctx, s); cerr != nil {
                    // компенсация ДОЛЖНА быть ретраебельной; логируем и
                    // оставляем сагу в состоянии "compensation_pending" для воркера.
                    o.markCompensationPending(ctx, s, j, cerr)
                }
            }
            return err
        }
        o.persistProgress(ctx, s, i) // прогресс саги durable, чтобы пережить рестарт
    }
    return nil
}

Важно: прогресс саги (на каком шаге) персистится в БД, иначе при рестарте воркера сага «забудет», что делать. Компенсации должны быть идемпотентными и всегда успешными в итоге (с ретраями), иначе деньги/сток зависнут.

4. Eventual consistency и UX#

Пользователь не должен видеть «голую» промежуточную правду саги. Практики:

  • Сразу после created/pending показываем «Заказ принят, обрабатывается» — read-your-writes через возврат состояния синхронно из самой записи (не из реплики).
  • Статусы для клиента укрупняем: внутренние 8 состояний → 3-4 пользовательских («Оформляется» / «Оплачен» / «В доставке» / «Доставлен»).
  • Если оплата провалилась после pending — показываем понятный «не удалось оплатить, резерв снят».
  • Read model обновляется из событий; для своих заказов читаем из лидера/кэша после записи, чтобы избежать «исчез заказ» на реплике.

5. Идемпотентность создания заказа#

Двойной клик, ретрай сети, at-least-once брокер — всё это даёт дубли. Решение: idempotency key от клиента (UUID на попытку оформления), уникальный индекс в БД.

func (s *OrderService) Create(ctx context.Context, req CreateReq) (*Order, error) {
    // 1. Пытаемся вставить запись idempotency. Конфликт = повтор.
    existing, err := s.repo.GetByIdemKey(ctx, req.IdempotencyKey)
    if err == nil && existing != nil {
        return existing, nil // возвращаем ТОТ ЖЕ результат, не создаём новый
    }
    order := newOrder(req) // детерминированный orderID из ключа (опц.)
    err = s.repo.tx(ctx, func(tx *sql.Tx) error {
        if _, err := tx.ExecContext(ctx,
            `INSERT INTO orders(id, idempotency_key, state, ...)
             VALUES ($1,$2,'created',...)`, order.ID, req.IdempotencyKey); err != nil {
            if isUniqueViolation(err) {
                return ErrDuplicate // другой запрос успел — вернём существующий выше
            }
            return err
        }
        return writeOutbox(ctx, tx, order.ID, Created) // запуск саги через outbox
    })
    if errors.Is(err, ErrDuplicate) {
        return s.repo.GetByIdemKey(ctx, req.IdempotencyKey)
    }
    return order, err
}

Уникальный индекс UNIQUE(idempotency_key) — последняя линия обороны от гонки двух одинаковых запросов.

6. Проблема oversell и борьба с ней#

Oversell = продали больше, чем есть. Источники: гонки на остатке, кэш остатков отстаёт, резерв не атомарен, компенсация не вернула сток.

  • Источник истины остатка — БД с атомарным условным UPDATE (available >= qty), а не «прочитал-проверил-записал».
  • Кэш остатков — только для отображения («осталось мало»), не для решения о резерве.
  • Reserve-with-TTL предотвращает «вечные холды», которые искусственно занижают доступность.
  • Для экстремально hot SKU (флэш-сейл) — отдельный путь: in-memory counter в одном шарде/партиции (Redis DECR с lua-скриптом для атомарности) как фронт перед БД, либо предварительная нарезка стока на «токены».
  • Допускаем контролируемый небольшой oversell как бизнес-решение (overbooking) с последующей компенсацией — иногда дешевле, чем терять конверсию.

7. Outbox для событий (Transactional Outbox)#

Проблема dual write: записать в БД и опубликовать в Kafka атомарно нельзя. Решение — писать событие в таблицу outbox в той же транзакции, что и изменение заказа; отдельный процесс (poller или CDC/Debezium) читает outbox и публикует в брокер, помечая отправленные.

  • Гарантия: at-least-once доставка событий, без потери при падении.
  • Потребители должны быть идемпотентны (dedup по event_id).
  • Очистка outbox по TTL/архивации; индекс по published_at IS NULL.

Масштабирование и узкие места#

  • Hot SKU / inventory contention — главный bottleneck. Все заказы на популярный товар бьют в одну строку. Митигейшн: атомарный CAS (не FOR UPDATE), Redis-фронт со счётчиком, sharding стока по «бакетам» (1000 единиц → 10 строк по 100, резерв из случайного бакета), и backpressure/очередь на флэш-сейлах.
  • Шардинг orders: by order_id vs user_id.
    • by order_id — равномерное распределение записи, но «все заказы пользователя» = scatter-gather по шардам.
    • by user_id — заказы пользователя локальны (профиль, история — дёшево), но риск hot user (маркетплейс-продавец, бот) и неравномерность.
    • Практика: шард по user_id (бизнес-запросы почти всегда «мои заказы»), а order_id делают так, чтобы из него восстанавливался шард (встроенный user-hash/префикс), чтобы lookup по order_id не требовал глобального индекса.
  • Saga complexity — рост числа шагов = экспоненциальный рост путей отказа и компенсаций. Митигейшн: orchestration с явной персистентной state machine саги, ограниченное число шагов, таймауты на каждый шаг, dead-letter для застрявших саг, реконсиляция (фоновый процесс, который сверяет order vs payment vs inventory и чинит расхождения).
  • Read-путь — статусы кэшируем (Redis) и читаем с реплик; запись order — на лидере. Read/write разделены, чтение масштабируется репликами почти бесконечно.
  • Брокер — партиционирование Kafka по order_id гарантирует порядок событий одного заказа (важно для state machine), при этом параллелизм по партициям.

Вопросы на собеседовании#

В: Почему не 2PC для order+payment+inventory? О: 2PC держит блокировки между сервисами на время всей транзакции (низкий throughput, deadlock-риск), координатор — SPOF, и он попросту не реализуем с внешним платёжным провайдером, который не участвует в вашем X/Open протоколе. Saga даёт eventual consistency с компенсациями и не блокирует ресурсы. Цена — нужно явно проектировать компенсации и временное окно несогласованности.

В: Choreography или orchestration для этой саги и почему? О: Orchestration. У нас 4+ шагов с компенсациями, таймаутами и нетривиальной логикой ветвления (отмена, refund). Оркестратор держит всю логику и состояние саги в одном месте — это проще отлаживать, менять и мониторить. Choreography на таком размере превращается в неявную паутину событий, где никто не знает полный flow.

В: Как гарантировать отсутствие oversell на hot SKU при 15k reservation/s? О: Источник истины — атомарный условный UPDATE SET available=available-q WHERE available>=q (а не read-modify-write). Кэш — только для UI. Для экстремального контеншена — Redis-счётчик с атомарным lua DECR как фронт, или нарезка стока на бакеты, чтобы распараллелить запись. Reserve-with-TTL убирает вечные холды. Контролируемый overbooking — осознанное бизнес-решение, не баг.

В: Что произойдёт, если оркестратор упадёт посередине саги? О: Прогресс саги персистится в БД после каждого шага. При рестарте воркер поднимает незавершённые саги (по статусу/таймауту), и так как все шаги идемпотентны, он либо до-выполняет, либо запускает компенсации с того места, где остановился. Поэтому критично: durable progress + идемпотентные Do и Compensate.

В: Как обеспечить идемпотентность создания заказа? О: Клиент присылает idempotency key (UUID на попытку оформления). В БД — UNIQUE(idempotency_key). При повторе возвращаем уже созданный заказ, а не создаём новый. Гонку двух одновременных одинаковых запросов ловит unique-индекс: проигравший перечитывает существующую запись. Опционально orderID детерминированно выводится из ключа.

В: Зачем outbox, если можно просто опубликовать в Kafka после коммита? О: Между коммитом БД и publish в Kafka процесс может упасть — событие потеряется (dual write problem), сага не запустится, заказ «зависнет» в created. Outbox пишет событие в той же транзакции, что и заказ; отдельный поллер/CDC публикует. Это at-least-once без потери. Цена — потребители обязаны быть идемпотентны (dedup по event_id).

В: Резерв с TTL истёк, но оплата на самом деле прошла (гонка). Что делаем? О: Это классическая гонка release vs payment. Варианты: (1) перед capture повторно проверять/продлевать резерв атомарно; если резерва нет — пытаемся зарезервировать заново, при неудаче — отменяем оплату (void/refund) и помечаем заказ failed с понятным сообщением. (2) Делать TTL заметно больше окна оплаты. (3) Reconciliation-процесс ловит «оплачено, но нет резерва» и инициирует либо доп.резерв, либо refund. Главное — деньги и сток не должны разойтись молча.

В: Как шардировать orders и как тогда искать заказ по order_id? О: Шардим по user_id, потому что 99% запросов — «заказы пользователя». Чтобы lookup по order_id не делал scatter-gather, кодируем шард внутри order_id (префикс = user-hash). Тогда из order_id сразу понятен шард. Минус user_id-шардинга — hot user (крупный продавец/бот); лечим под-шардингом таких аккаунтов.

В: Пользователь видит «заказ обрабатывается» — как не показать ему рассинхрон? О: Read-your-writes: сразу после создания возвращаем статус из лидера/самой записи, не из отстающей реплики. Внутренние 8 состояний укрупняем в 3-4 пользовательских. Промежуточные состояния саги (authorized-but-not-captured) пользователю не показываем как отдельные. На фейлах — понятный финальный статус с снятием резерва.

На что копают на senior+#

  • Где именно у вас граница консистентности и сколько длится окно несогласованности. Senior обязан назвать конкретные моменты («между capture и fulfill сток уже held, деньги уже взяты — окно X секунд») и сказать, чем чините (reconciliation).
  • Идемпотентность на всех уровнях, не только при create. Каждый шаг саги, каждая компенсация, каждый потребитель события. Любой ответ «ретрай» без «идемпотентно» — красный флаг.
  • Компенсации, которые не могут просто провалиться. Что если refund упал? Что если restock упал? Должна быть ретрай-очередь + dead-letter + алерт + ручной runbook. Бизнес-семантика компенсации (void до capture vs refund после) — обязательна.
  • Reconciliation как первоклассный компонент. Saga гарантирует «в итоге», но фоновая сверка order vs payment vs inventory ловит то, что просочилось. Отсутствие реконсиляции на проде с деньгами — серьёзный пробел.
  • Hot SKU не словами, а числами и конкретным механизмом (Redis lua DECR / нарезка стока / очередь на флэш-сейл). «Используем блокировку» на 15k/s — провал.
  • Порядок событий. Партиционирование Kafka по order_id, чтобы события одного заказа не переупорядочились и не сломали state machine. Понимание, что at-least-once + дедуп ≠ exactly-once.
  • Outbox vs CDC trade-off, что делать с ростом outbox (TTL/архив), и почему «опубликовать после коммита» — это баг, а не оптимизация.
  • Деградация на пике: что отключаем первым (рекомендации, аналитика), что не трогаем никогда (списание денег и стока). Чёткий приоритет «корректность денег/склада > доступность вторичного».