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

TL;DR#

Платёжный сервис — это система, где корректность важнее доступности, а потеря или дублирование денег недопустимы. Три кита:

  1. Идемпотентность — клиент шлёт Idempotency-Key, сервер гарантирует, что повтор запроса (ретрай, таймаут, двойной клик) не спишет деньги дважды. Реализуется через уникальный индекс в БД.
  2. Double-entry ledger — деньги не «хранятся» как баланс, а выводятся из неизменяемого (append-only) журнала проводок debit/credit, где сумма всех проводок транзакции равна нулю.
  3. Transactional outbox + CDC — события («платёж проведён») публикуются в брокер строго на основе закоммиченного состояния БД, что даёт exactly-once effect поверх at-least-once delivery брокера.

«Exactly-once деньги» — это не магия брокера, а at-least-once доставка + идемпотентные обработчики. Деньги всегда в целочисленных minor units (копейки/центы), никогда во float.

Требования#

Функциональные#

  • Приём платежа: списание с источника (карта/счёт), зачисление получателю.
  • Авторизация (hold) и последующий capture/void; частичный capture.
  • Возвраты (refund), частичные возвраты, чарджбэки.
  • Идемпотентный API создания платежа.
  • Интеграция с внешними PSP (Stripe, Adyen, локальные банки-эквайеры) через адаптеры.
  • Обработка асинхронных webhook от PSP (статус платежа меняется вне нашего запроса).
  • Reconciliation: ежедневная сверка нашего ledger с выписками провайдеров.
  • Аудит: кто, когда, что, с какого IP; неизменяемая история.

Нефункциональные (деньги — особый случай)#

  • Consistency: строгая. Баланс никогда не должен «уехать». Для одного аккаунта — линеаризуемость. Жертвуем доступностью (CP в CAP) для критичного пути списания.
  • Durability: после ответа 200 OK платёж не теряется никогда. fsync, репликация с synchronous_commit, RPO = 0 для ledger.
  • Auditability: ledger append-only, immutable. Любую запись можно проследить. Хранение 7+ лет (регуляторика).
  • Регуляторика / PCI DSS: мы не храним PAN (номер карты), CVV — никогда. Используем токенизацию на стороне PSP. Если касаемся карточных данных — это PCI DSS Level 1 со всеми вытекающими (сегментация сети, шифрование at-rest/in-transit, аудит доступа). Лучшая стратегия — минимизировать scope: пусть карту видит только PSP, мы храним только их payment_method_token.
  • Latency: p99 синхронной авторизации < 1–2 с (включая round-trip до PSP, который и есть боттлнек). Внутренняя обработка ledger — единицы мс.
  • Availability: 99.99% для приёма платежей. Но при выборе consistency vs availability на конфликте — выбираем consistency (лучше отказать в платеже, чем провести его дважды).

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

Возьмём средний платёжный шлюз:

  • Объём: 10M транзакций/день ≈ 116 TPS в среднем.
  • Пики: x10 в Black Friday / распродажи ≈ 1160 TPS. Закладываем headroom до ~3000 TPS.
  • Чтения: статусы, история — соотношение read:write ~ 10:1, значит ~1000–1200 QPS чтения в среднем.

Storage (ledger за годы)#

Каждая бизнес-транзакция = минимум 2 проводки (double-entry), часто 4 (с комиссиями). Запись проводки ~ 300 байт.

  • 10M tx/day × 4 проводки × 300 B ≈ 12 GB/день сырых проводок.
  • За год: ~4.4 TB. За 7 лет (регуляторное хранение): ~30 TB только проводки.
  • Плюс audit log, webhook payloads, idempotency-записи — ещё столько же.
  • Итого порядок: 60–80 TB на горизонте хранения. Горячие данные (последние 90 дней) — ~1 TB, держим в основной OLTP БД; холодные — архив (S3/cold storage) + OLAP для аналитики.

Бюджеты#

  • p99 внутреннего pipeline (без PSP): < 50 мс.
  • p99 с PSP: ограничен провайдером, 500–2000 мс. Поэтому capture часто делается асинхронно.

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

                         Idempotency-Key
                              │
   ┌────────┐   HTTPS   ┌─────▼──────────┐
   │ Client ├──────────►│  Payment API   │  (валидация, idempotency check)
   └────────┘           │   (stateless)  │
        ▲               └──────┬─────────┘
        │ webhook /            │ один ACID-коммит:
        │ status poll          │  ledger + outbox + idempotency
        │               ┌──────▼───────────────────────────┐
        │               │        Ledger DB (Postgres)       │
        │               │  ┌─────────┐ ┌──────┐ ┌────────┐  │
        │               │  │ ledger  │ │outbox│ │idempot.│  │
        │               │  │(append) │ │ table│ │  keys  │  │
        │               │  └─────────┘ └──┬───┘ └────────┘  │
        │               └─────────────────┼────────────────┘
        │                                 │ CDC (Debezium / logical decoding)
        │                          ┌──────▼──────┐
        │                          │ Kafka / bus │  (at-least-once)
        │                          └──────┬──────┘
        │            ┌────────────────────┼──────────────────┐
        │     ┌──────▼──────┐      ┌───────▼────────┐  ┌──────▼───────┐
        └─────┤ PSP Adapter │◄────►│  Notifier /    │  │ Reconciliation│
              │ (Stripe/...) │ PSP  │  Webhooks out  │  │   Worker      │
              └──────┬───────┘      └────────────────┘  └──────┬───────┘
                     │ HTTPS                                    │ daily
              ┌──────▼───────┐                          ┌───────▼───────┐
              │  External    │                          │ PSP settlement│
              │  PSP / Bank  │─────── settlement file ─►│   report      │
              └──────────────┘                          └───────────────┘

Компоненты#

  • Payment API — stateless, горизонтально масштабируется за LB. Проверяет idempotency-key, валидирует, открывает транзакцию.
  • Ledger DB — источник истины. Postgres (или Spanner/CockroachDB для гео-распределения). Содержит таблицы ledger_entries (immutable), accounts (баланс/проекция), outbox, idempotency_keys. Всё критичное пишется одним ACID-коммитом.
  • Outbox + CDC — события в outbox пишутся в той же транзакции, что и ledger; отдельный процессор (CDC через logical decoding / Debezium, либо poller) читает outbox и публикует в Kafka. Гарантия: событие публикуется тогда и только тогда, когда транзакция закоммитилась.
  • PSP Adapter — изолирует специфику каждого провайдера за единым интерфейсом. Отвечает за retry, circuit breaker, маппинг ошибок, идемпотентность на стороне PSP (передаём свой ключ в их API).
  • Webhook handler — принимает асинхронные уведомления от PSP (charge succeeded/failed). Идемпотентен: один и тот же webhook может прийти несколько раз.
  • Reconciliation Worker — ежедневно тянет settlement-файлы PSP и сверяет с ledger; расхождения → алерт/тикет.

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

1. Идемпотентность через Idempotency-Key + уникальный индекс#

Клиент генерирует ключ (UUID) и шлёт его в заголовке. Сервер атомарно «застолбляет» ключ. Если ключ уже есть с завершённым ответом — возвращаем сохранённый ответ, не выполняя операцию повторно.

CREATE TABLE idempotency_keys (
    key            TEXT PRIMARY KEY,
    request_hash   TEXT NOT NULL,        -- хэш тела запроса
    status         TEXT NOT NULL,        -- 'in_progress' | 'completed'
    response_code  INT,
    response_body  JSONB,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
    locked_until   TIMESTAMPTZ           -- защита от висящих in_progress
);
type IdempotencyResult struct {
    Replayed bool
    Code     int
    Body     []byte
}

// reserve пытается застолбить ключ. Если ключ уже есть — отдаёт сохранённый
// результат (replay) либо сигналит, что запрос ещё в работе (конфликт).
func (s *Store) Reserve(ctx context.Context, key, reqHash string) (*IdempotencyResult, error) {
    var status, gotHash string
    var code sql.NullInt32
    var body []byte

    err := s.db.QueryRowContext(ctx, `
        INSERT INTO idempotency_keys (key, request_hash, status)
        VALUES ($1, $2, 'in_progress')
        ON CONFLICT (key) DO UPDATE
            SET key = idempotency_keys.key   -- no-op, чтобы RETURNING сработал
        RETURNING status, request_hash, response_code, response_body
    `, key, reqHash).Scan(&status, &gotHash, &code, &body)
    if err != nil {
        return nil, err
    }

    // Тот же ключ, но другое тело запроса — клиент переиспользовал ключ. Ошибка.
    if gotHash != reqHash {
        return nil, ErrIdempotencyKeyReuse // -> 422
    }
    switch status {
    case "completed":
        return &IdempotencyResult{Replayed: true, Code: int(code.Int32), Body: body}, nil
    case "in_progress":
        // Параллельный запрос с тем же ключом ещё выполняется.
        return nil, ErrRequestInFlight // -> 409, клиент ретраит позже
    }
    return nil, nil // мы первые — выполняем операцию
}

Ключевые тонкости:

  • Проверяем request_hash: если ключ тот же, а тело отличается — это ошибка клиента (422), а не replay.
  • Состояние in_progress защищает от гонки двух параллельных ретраев. Зависший in_progress чистим по locked_until (TTL).
  • TTL ключей: 24–72 часа достаточно для ретраев; вечно хранить не нужно.

2. Double-entry ledger (immutable, append-only)#

Каждая транзакция = набор проводок, сумма которых = 0 (закон сохранения денег). Баланс счёта = SUM(entries) или материализованная проекция.

CREATE TABLE ledger_entries (
    id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    txn_id      UUID NOT NULL,            -- группирует проводки одной операции
    account_id  BIGINT NOT NULL,
    direction   SMALLINT NOT NULL,        -- +1 debit / -1 credit
    amount      BIGINT NOT NULL,          -- minor units, ВСЕГДА > 0
    currency    CHAR(3) NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
    -- НИКАКИХ UPDATE/DELETE. Только INSERT.
);
-- инвариант: для каждого txn_id  SUM(direction * amount) = 0 (на валюту)

Правила:

  • Append-only: ошибку исправляют не правкой, а компенсирующей проводкой (reversal). История неприкосновенна — это и есть аудит.
  • Никаких float: amount BIGINT в minor units, либо NUMERIC для валют с нестандартной дробностью. float64 теряет точность: 0.1 + 0.2 != 0.3.
// Деньги — отдельный тип, не примитив. Защищает от случайной арифметики.
type Money struct {
    Amount   int64  // minor units: 1050 = $10.50
    Currency string // ISO 4217
}

func (m Money) Add(o Money) (Money, error) {
    if m.Currency != o.Currency {
        return Money{}, fmt.Errorf("currency mismatch: %s vs %s", m.Currency, o.Currency)
    }
    return Money{Amount: m.Amount + o.Amount, Currency: m.Currency}, nil
}

3. Saga vs 2PC для распределённых транзакций#

Платёж затрагивает несколько систем: наш ledger + внешний PSP. 2PC (двухфазный коммит) не подходит: PSP не участвует в нашем XA-координаторе, блокировки держат ресурсы и убивают доступность, координатор — SPOF.

Используем Saga (последовательность локальных транзакций с компенсациями):

1. ledger: создать payment (status=PENDING) + hold        ─ локальный ACID
2. PSP:    authorize                                       ─ внешний вызов
3a. успех → ledger: status=AUTHORIZED                      ─ локальный ACID
3b. ошибка → ledger: status=FAILED + release hold (комп.) ─ компенсация

Saga даёт eventual consistency между нами и PSP, но строгую консистентность внутри ledger (каждый шаг — локальный ACID). Компенсации должны быть идемпотентны.

4. Transactional Outbox + CDC для exactly-once публикации#

Антипаттерн — «закоммитить в БД, потом отправить в Kafka»: между двумя действиями процесс может упасть → событие потеряно (или наоборот, отправили, но коммит откатился → фантомное событие). Это dual-write problem.

Решение: пишем событие в таблицу outbox в той же транзакции, что и ledger. Отдельный процесс публикует.

// Один ACID-коммит: ledger + idempotency + outbox.
func (s *Service) Authorize(ctx context.Context, cmd AuthorizeCmd) (*Payment, error) {
    tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
    if err != nil {
        return nil, err
    }
    defer tx.Rollback()

    p := newPayment(cmd) // status = PENDING

    if _, err = tx.ExecContext(ctx, `
        INSERT INTO ledger_entries (txn_id, account_id, direction, amount, currency)
        VALUES ($1, $2, -1, $3, $4)`, // hold = credit на счёте источника
        p.TxnID, cmd.SourceAccount, cmd.Amount.Amount, cmd.Amount.Currency); err != nil {
        return nil, err
    }

    // Событие — в outbox, в ТОЙ ЖЕ транзакции.
    event, _ := json.Marshal(PaymentPendingEvent{PaymentID: p.ID, TxnID: p.TxnID})
    if _, err = tx.ExecContext(ctx, `
        INSERT INTO outbox (id, aggregate_id, event_type, payload, created_at)
        VALUES ($1, $2, 'payment.pending', $3, now())`,
        uuid.New(), p.ID, event); err != nil {
        return nil, err
    }

    if err = tx.Commit(); err != nil {
        return nil, err
    }
    return p, nil
}

Релеер (CDC или poller) гарантирует at-least-once публикацию:

func (r *Relayer) pump(ctx context.Context) error {
    rows, err := r.db.QueryContext(ctx, `
        SELECT id, event_type, payload FROM outbox
        WHERE published_at IS NULL
        ORDER BY created_at
        LIMIT 100
        FOR UPDATE SKIP LOCKED`) // несколько релееров не конфликтуют
    if err != nil {
        return err
    }
    defer rows.Close()

    for rows.Next() {
        var id uuid.UUID
        var typ string
        var payload []byte
        _ = rows.Scan(&id, &typ, &payload)

        // Kafka key = aggregate_id → порядок по платежу. Может задублироваться
        // (упали между publish и UPDATE) — consumer должен быть идемпотентным.
        if err := r.producer.Publish(ctx, typ, id.String(), payload); err != nil {
            return err
        }
        if _, err := r.db.ExecContext(ctx,
            `UPDATE outbox SET published_at = now() WHERE id = $1`, id); err != nil {
            return err
        }
    }
    return rows.Err()
}

Предпочтительнее CDC (logical decoding/Debezium) — он читает WAL, не нагружает БД polling-ом и не пропускает события.

5. Webhook от PSP и их идемпотентная обработка#

PSP уведомляет нас асинхронно (charge succeeded). Webhook может прийти:

  • несколько раз (PSP сам делает at-least-once),
  • не по порядку (succeeded раньше, чем pending),
  • с задержкой (минуты).

Правила:

  • Проверка подписи (HMAC) — иначе кто угодно «подтвердит» платёж.
  • Идемпотентность: PSP даёт event_id; храним обработанные event_id, дубликат игнорируем.
  • State machine не допускает деградации: переход из AUTHORIZED в PENDING запрещён — поздний out-of-order webhook просто отбрасываем.
func (h *WebhookHandler) Handle(ctx context.Context, w PSPWebhook) error {
    if !h.verifySignature(w) {
        return ErrBadSignature // 401
    }
    // Идемпотентность по event_id провайдера.
    inserted, err := h.store.MarkProcessed(ctx, w.EventID)
    if err != nil {
        return err
    }
    if !inserted {
        return nil // уже обработали — 200, no-op
    }
    return h.fsm.Apply(ctx, w.PaymentID, mapStatus(w.Type)) // переход через FSM
}

6. Reconciliation с провайдером#

Даже при идеальном коде расхождения случаются (таймауты, ручные операции в PSP, чарджбэки). Reconciliation — обязательный контур, а не опция.

  • Ежедневно скачиваем settlement-файл PSP.
  • Сверяем построчно: каждая транзакция PSP ↔ запись ledger по provider_ref.
  • Категории расхождений: у нас есть, у них нет (возможно деньги не ушли), у них есть, у нас нет (потеряли webhook), разные суммы/статусы.
  • Расхождения → автоматический тикет + алерт, ручной разбор. Часть автоматизируется (доводящие проводки).

7. State machine статусов платежа#

Явная FSM с разрешёнными переходами — защита от невалидных и out-of-order изменений:

CREATED → PENDING → AUTHORIZED → CAPTURED → SETTLED
                 ↘  FAILED          │          │
                                    ↓          ↓
                                  VOIDED    REFUNDED / PARTIALLY_REFUNDED
                                                       ↓
                                                  CHARGEBACK

Любой переход — проверка allowed[from][to]. Невалидный переход → ошибка/игнор, не молчаливая порча состояния.

8. Exactly-once деньги#

«Exactly-once» в распределённых системах в общем случае недостижимо на уровне доставки. Достигается exactly-once effect = at-least-once delivery + идемпотентные обработчики + дедупликация:

  • доставка событий — at-least-once (outbox/Kafka могут дублировать);
  • каждый потребитель дедуплицирует по event_id/txn_id;
  • запись в ledger защищена уникальным индексом (UNIQUE(txn_id, account_id, direction) или dedup-таблица), поэтому повторная попытка той же проводки — no-op.

Итог: деньги движутся ровно один раз, даже если сообщение пришло пять раз.

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

  • Шардинг по account_id: ledger разрезается по аккаунту. Проводки одного аккаунта — на одном шарде → линеаризуемость баланса локальна, без распределённых блокировок. Проблема: перевод между аккаунтами на разных шардах → нужна cross-shard saga или single-writer на пару.
  • Hot accounts: маркетплейс-аккаунт продавца / платёжный шлюз партнёра принимает тысячи проводок/с → contention на одной строке баланса. Решения:
    • не апдейтить строку баланса синхронно, а считать баланс как SUM(entries) + периодические снапшоты;
    • счётчик-шардинг: баланс разбит на N суб-балансов (balance_shard 0..N-1), запись идёт в случайный, чтение суммирует. Снимает row-lock contention.
  • Consistency vs throughput: SERIALIZABLE даёт корректность, но больше откатов под нагрузкой → ретраи. Где допустимо (запись в append-only без чтения текущего баланса) — READ COMMITTED + уникальные индексы как гарантия. Критичный путь с проверкой лимита — SERIALIZABLE или явный SELECT ... FOR UPDATE.
  • Ledger как боттлнек: это единая точка записи правды. Митигация: CQRS — записи в ledger, чтения (история, статусы) — из реплик/проекций; разнести OLTP (горячее) и OLAP (аналитика, reconciliation) физически.
  • PSP — внешний боттлнек по latency: capture делаем асинхронно (через очередь), синхронно — только authorize. Circuit breaker + таймауты + per-PSP rate limit, чтобы один лежачий провайдер не выел все воркеры.

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

В: Почему нельзя хранить деньги в float64? О: Двоичная плавающая точка не представляет точно десятичные дроби: 0.1 + 0.2 == 0.30000000000000004. На миллионах операций накапливается ошибка, баланс «уезжает». Храним целые minor units (int64 центов) либо decimal/NUMERIC. Арифметика — на целых.

В: Чем outbox лучше, чем «закоммитить в БД и сразу отправить в Kafka»? О: «Коммит + send» — это dual-write: между двумя операциями процесс может упасть. Либо событие потеряно (упали после коммита, до send), либо фантомное (отправили, коммит откатился). Outbox делает запись события частью той же ACID-транзакции, а публикацию — отдельным надёжным шагом из закоммиченного состояния. Получаем at-least-once без потерь.

В: Идемпотентность — где именно нужен уникальный индекс и почему не хватит «проверить-потом-вставить»? О: SELECT + INSERT — гонка: два параллельных ретрая оба не найдут ключ и оба вставят/спишут. Нужен атомарный INSERT ... ON CONFLICT на колонке с UNIQUE-индексом: БД сериализует конфликт, второй получает отказ/replay. Уникальный индекс — единственная настоящая гарантия, остальное — гонки.

В: Что значит «exactly-once» для денег и достижимо ли оно? О: Exactly-once delivery в общем случае недостижимо. Достижимо exactly-once effect: at-least-once доставка + идемпотентные потребители + дедупликация по ключу. Деньги двигаются один раз, потому что повторная проводка с тем же txn_id отбивается уникальным индексом, даже если сообщение пришло несколько раз.

В: Почему saga, а не 2PC между ledger и PSP? О: 2PC требует, чтобы все участники поддерживали распределённый коммит (PSP не поддерживает), держит блокировки на время фазы, имеет координатор-SPOF и плохо масштабируется. Saga — цепочка локальных ACID-транзакций с компенсациями; даёт eventual consistency между системами при строгой консистентности внутри ledger. Цена: нужно проектировать компенсации и идемпотентность каждого шага.

В: Webhook от Stripe пришёл дважды и не по порядку (succeeded раньше pending). Что делаете? О: Дубликат — дедупим по event_id провайдера (таблица обработанных событий, INSERT под уникальный индекс). Out-of-order — гасим через FSM: переход AUTHORIZED → PENDING запрещён, поздний устаревший webhook просто отбрасывается. Плюс обязательная проверка HMAC-подписи до любой обработки.

В: Hot account (мерчант с тысячами проводок/с) упирается в row-lock на балансе. Как масштабируете? О: Не апдейтить строку баланса синхронно. Баланс = SUM append-only проводок + периодические снапшоты; для записи — sharded counter (N суб-балансов, пишем в случайный, читаем суммой). Это убирает contention на одной строке ценой более дорогого чтения баланса (которое кэшируем/снапшотим).

В: Зачем reconciliation, если код корректный и идемпотентный? О: Внешний мир недетерминирован: таймауты (мы не знаем, прошёл ли платёж), ручные операции в PSP, чарджбэки, потерянные webhook, баги. Reconciliation — независимая сверка с settlement-файлами провайдера, ловит расхождения, которые код в принципе не может предотвратить. Для денег это контроль, а не опция.

В: Какой уровень изоляции выберете для списания с проверкой лимита баланса? О: Проверка «хватает ли средств» + списание — это read-modify-write, уязвимый к write skew. Нужен SERIALIZABLE либо явная блокировка строки (SELECT ... FOR UPDATE). Для чистого append без чтения баланса достаточно READ COMMITTED + уникальный индекс. Под нагрузкой SERIALIZABLE даёт откаты — обрабатываем ретраями с backoff.

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

  • Семантика «потерянного ответа»: PSP вернул таймаут — деньги списаны или нет? Senior обязан спроектировать переход в UNKNOWN/PENDING + последующий статусный poll/reconciliation, а не «считать failed». Двойной charge рождается именно здесь.
  • Точная граница идемпотентности: уникальный индекс и INSERT ON CONFLICT, а не SELECT-then-INSERT; проверка request-hash; TTL и обработка зависшего in_progress; что возвращать на параллельный конфликт (409).
  • Dual-write осознан: кандидат сам называет проблему и тянет outbox/CDC, отличает CDC от polling, понимает FOR UPDATE SKIP LOCKED для нескольких релееров.
  • Деньги как тип, а не int: Money{amount, currency}, запрет смешения валют, округление по правилам валюты, никаких float — на уровне доменной модели.
  • Append-only и компенсации: исправление — reversal-проводкой, а не UPDATE/DELETE. Понимание, что immutable ledger = аудит.
  • PCI scope minimization: «мы не храним PAN/CVV, токенизация на PSP» — снижение области PCI DSS, а не «зашифруем карты у себя».
  • CAP-выбор осознан: для критичного пути — CP (лучше отказать, чем провести дважды); где можно — eventual consistency (история, нотификации).
  • Cross-shard переводы: понимает, что перевод между шардами — это распределённая транзакция/saga, и не делает вид, что шардинг бесплатен.
  • Out-of-order и дедуп на каждом стыке: webhook, Kafka-consumer, релеер — везде at-least-once, везде дедуп; FSM с явными разрешёнными переходами.
  • Numbers sense: оценивает TPS, размер ledger за годы, отделяет горячие данные от холодных, понимает, что PSP — главный источник latency и проектирует capture асинхронно.