Модуль: 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 lock | SELECT ... 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. Резерв авто-освобождается по TTL | Decouples оформление и оплату; нет вечно висящих холдов; хорошо ложится на корзину | Нужен 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/архив), и почему «опубликовать после коммита» — это баг, а не оптимизация.
- Деградация на пике: что отключаем первым (рекомендации, аналитика), что не трогаем никогда (списание денег и стока). Чёткий приоритет «корректность денег/склада > доступность вторичного».