Модуль: System Design · Уровень: Senior
TL;DR#
Платёжный сервис — это система, где корректность важнее доступности, а потеря или дублирование денег недопустимы. Три кита:
- Идемпотентность — клиент шлёт
Idempotency-Key, сервер гарантирует, что повтор запроса (ретрай, таймаут, двойной клик) не спишет деньги дважды. Реализуется через уникальный индекс в БД. - Double-entry ledger — деньги не «хранятся» как баланс, а выводятся из неизменяемого (append-only) журнала проводок debit/credit, где сумма всех проводок транзакции равна нулю.
- 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 асинхронно.