open atlas
↑ К треку
Разборы System Design SDC · 05 · 03

Спроектируй платёжную систему

Платёжная система: ключи идемпотентности против двойного списания, двойная запись (double-entry) как источник истины, сверка с PSP, оркестрация saga для распределённой транзакции и неизменяемый аудит-трейл.

SDC Senior ◷ 38 min
Уровень
ОсновыJuniorMiddleSenior

Клиент нажал «Оплатить» один раз. С его карты списали дважды. Вот последовательность: приложение отправило запрос на списание, платёжный провайдер успешно его обработал — а потом ответ отвалился по таймауту на обратном пути. Приложение, не видя ответа, сделало «очевидное» и повторило запрос. Провайдер, не имея способа узнать, что это тот же намеренный платёж, обработал второе списание. Клиент увидел две строки, открыл chargeback, и компания съела комиссию спора поверх возврата. Баг был не крэшем и не гонкой в глубине БД; это самая обыкновенная вещь в интернете — повторённый запрос по ненадёжной сети — встретившая систему, у которой не было способа распознать «я уже видел ровно этот платёж». Всё в проектировании платёжных систем вращается вокруг этого вопроса.

Требования

Платёжная система двигает деньги от плательщика к получателю через внешних процессоров и обязана делать это корректно в жесточайших условиях: ненадёжные сети, частичные отказы и злоумышленники.

Функциональные. Принять запрос платежа (сумма, валюта, плательщик, получатель, метод). Списать с плательщика через Payment Service Provider (PSP — Stripe, Adyen, карточная сеть). Записать результат. Поддержать возвраты и реверсы. Выдать выписки и аудит-трейл. Сверять собственные записи системы с отчётами PSP о расчётах ежедневно.

Нефункциональные. Корректность важнее доступности — при сомнении платёжная система останавливается, а не рискует списать дважды или потерять деньги; пропущенный платёж восстановим, двойное списание — это спор и потерянный клиент. Каждая смена состояния должна быть долговечной и проверяемой (регуляторы и споры требуют полной истории). Операции должны быть идемпотентными, потому что ретраи гарантированы, не гипотетичны. И книги всегда должны сходиться — сумма дебетов равна сумме кредитов, без исключений.

Определяющее свойство: деньги — это сохраняющаяся величина. Нельзя потерять запись так, как можно потерять «лайк». Поэтому весь дизайн о том, чтобы сделать операции безопасными к повтору и сделать каждое движение сбалансированным, постоянным фактом.

Оценка

Платежи — не задача высокого QPS; это задача высокой корректности, высокой долговечности.

объём             = 10 000 000 платежей/день
средний QPS       = 10^7 / 86 400         ≈ 116 платежей/с  (скромно!)
пик QPS           = ~3–10× средн.         ≈ 1 000 платежей/с (Чёрная пятница)
записей в ledger  = 2+ на платёж (double-entry) → ~2×10^7 неизменяемых строк/день
хранение          = годы (регуляторно) — append-only, никогда не удаляется
задержка PSP      = ~300 мс–2 с на списание (внешнее, медленная часть)

Числа говорят важное: ~100–1 000 платежей/с — тривиальная пропускная способность для любой современной БД — не нужен экзотический шардинг ради QPS. Что нужно — это долговечность, точность и проверяемость на этом скромном темпе. Доминирующая цена — не масштаб; это инженерия, чтобы никогда не списать дважды, не потерять запись и всегда быть способным доказать, что произошло. Самый медленный компонент — внешний вызов PSP (сотни мс до секунд), поэтому поток асинхронный и управляется конечным автоматом, а не одним блокирующим запросом.

Высокоуровневый дизайн

Ядро — конечный автомат на платёж (создан → pending → успех/отказ → возвращён), управляемый асинхронно, потому что вызов PSP медленный и может отказать двусмысленно. Тяжёлую работу делают три части: хранилище идемпотентности, превращающее «я уже это делал?» в быстрый lookup, ledger двойной записи, источник истины системы о деньгах, и задача сверки, ловящая любой дрейф между тем, что система думает, и тем, что PSP реально рассчитал.

Глубокое погружение

Ключи идемпотентности и проблема двойного списания

У бага из вступления есть точная починка. Клиент генерирует уникальный ключ идемпотентности на каждый намеренный платёж (не на каждый HTTP-запрос — тот же логический платёж сохраняет ключ через ретраи) и шлёт его со списанием. Сервер, прежде чем что-либо делать, проверяет хранилище идемпотентности:

on charge(idempotency_key, amount, ...):
  existing = store.get(idempotency_key)
  if existing:
      return existing.result        # ретрай → вернуть ТОТ ЖЕ исход, нового списания нет
  result = process_payment(...)      # первый раз → реально списать
  store.put(idempotency_key, result) # атомарно, вместе со списанием
  return result

Теперь ретрай из вступления находит ключ уже на месте и возвращает исходный результат вместо повторного списания. Две тонкости делают это реальным, а не размытым. Первое: check-and-store должен быть атомарным со списанием — уникальное ограничение на ключ плюс транзакция, чтобы два конкурентных ретрая не прошли оба проверку «не виден» (проигравший упирается в ограничение и читает результат победителя). Второе: ключ должен быть привязан к параметрам запроса: переиспользование ключа с другой суммой — баг клиента, который сервер должен отклонить, а не молча трактовать как тот же платёж. API Stripe работает ровно так — передаёшь заголовок Idempotency-Key, и ретрай возвращает сохранённый ответ.

Почему это работает

Почему ключ идемпотентности должен генерировать клиент, до отправки запроса, а не сервер? Потому что вся проблема — двусмысленный таймаут: клиент отправил списание и не получил ответа. Он не может сказать, никогда ли сервер его получил, получил и упал до списания, списал, но ответ потерялся, или всё ещё работает. В каждом из этих случаев безопасное действие — повторить с тем же ключом — и только ключ, отчеканенный клиентом до первой попытки, стабилен через все из них. Серверный ключ здесь бесполезен: если ответ потерялся, клиент так и не узнал ключ, так что его ретрай нёс бы новый ключ и выглядел бы как свежий платёж. То, что клиент владеет ключом, и делает «повторить безопасно» возможным, поэтому идемпотентные API кладут ключ в руки клиента.

Ledger двойной записи: деньги как сохраняющиеся факты

Наивный дизайн хранит баланс как изменяемое число и обновляет его (balance = balance - 100). Так деньги теряются: крэш посреди обновления, гонка, баг — и число просто неверно, без способа узнать, каким оно должно быть. Платёжные системы вместо этого используют ledger двойной записи, 500-летнюю бухгалтерскую модель: каждое движение денег записывается как две записи, которые обязаны давать в сумме ноль — дебет одного счёта и равный кредит другого.

Платёж $100 от клиента продавцу (минус $3 комиссии):

  счёт                 дебет    кредит
  ----------------------------------------
  customer_funds                  100.00     ← деньги уходят от клиента
  merchant_payable      97.00                ← продавцу должны
  platform_fee           3.00                ← платформа берёт комиссию
  ----------------------------------------
  итоги                100.00    100.00      ✅ сбалансировано

Два свойства делают это источником истины. Записи неизменяемы и append-only — строку никогда не правят; коррекция — это новая компенсирующая запись, так что полная история сохранена (аудит-трейл бесплатно). Каждая транзакция сбалансирована — дебеты равны кредитам, так что деньги сохраняются по построению; баланс выводится суммированием записей, а не хранится как изменяемое поле, а значит, всегда может быть пересчитан и проверен. Если что-то не так, дисбаланс обнаружим. Поэтому «какой баланс?» становится запросом по неизменяемому логу, а не полем, на правильность которого молишься.

Частая ошибка

Классическая ошибка — хранить балансы как изменяемые колонки и трактовать успешный ответ PSP как конец истории. Следуют два провала. Первое: у изменяемого баланса нет аудит-трейла и способа обнаружить порчу — когда число неверно, нельзя реконструировать, каким оно должно быть, ведь ты перезаписал историю. Второе: трактовка «PSP вернул успех» как истины игнорирует, что ответ может потеряться: списание удалось, но ты ничего не записал, или ты записал отказ для реально прошедшего списания. Ledger плюс сверка чинят оба: каждое движение — неизменяемая сбалансированная запись (порча обнаружима, история цела), а дневная сверка с отчётом PSP о расчётах ловит случаи, где твоя запись и реальность PSP разошлись. Никогда не доверяй одному ответу как последнему слову о деньгах; доверяй сверенному ledger.

Saga и сверка: проблема распределённой транзакции

Платёж охватывает системы, которые нельзя обернуть в одну ACID-транзакцию — твой ledger, внешний PSP, может быть кошелёк или сервис инвентаря. Нет глобального коммита через HTTP-границу к третьей стороне. Поэтому используешь saga: моделируешь платёж как последовательность локальных шагов, каждый с компенсирующим действием, которое его откатывает. Зарезервировать средства → вызвать PSP → при успехе закоммитить записи ledger; при отказе запустить компенсацию (снять резервирование, аннулировать pending-запись). Если исход шага двусмыслен (вызов PSP отвалился по таймауту), saga не угадывает — она запрашивает у PSP реальный статус платежа (используя ключ идемпотентности) и лишь затем продвигается или компенсирует.

Финальная страховочная сеть — сверка. Раз в день PSP шлёт отчёт о расчётах: каждая транзакция, которую он реально обработал, и деньги, которые он двинул. Пакетная задача сравнивает его построчно с ledger. Совпадения подтверждают корректность; расхождения (списание, что есть у PSP, но нет в ledger, или наоборот) помечаются на разбор и компенсирующую запись. Это слой, ловящий остаток — потерянные ответы, двусмысленные таймауты, которые saga разрешила неверно, корректировки на стороне PSP. Это то, почему платёжная система может быть и высокодоступной, и доказуемо корректной: быстрый путь может изредка быть неуверен, потому что медленная, исчерпывающая сверка — финальный арбитр истины.

Расставь шаги по порядку

Расставьте шаги saga платежа в правильном порядке, чтобы при падении на любом этапе система оставалась в восстановимом состоянии:

  1. 1 Сгенерировать и сохранить ключ идемпотентности со статусом pending — до того как двинутся деньги, чтобы падение здесь было безопасной no-op, а повтор нашёл ключ и пропустил повторное выполнение.
  2. 2 Записать отложенную дебетовую запись в двойной ledger — зарезервировать средства, чтобы баланс клиента отражал платёж в процессе и второй конкурентный запрос не мог списать дважды.
  3. 3 Вызвать PSP с ключом идемпотентности — внешнее списание; это медленный, ненадёжный шаг, ради выживания при котором и построена вся saga. Передавать тот же ключ, чтобы PSP дедуплицировал повторы.
  4. 4 При двусмысленном таймауте запросить PSP об истинном исходе через ключ идемпотентности — никогда не угадывать; коммитить только когда известно, прошло ли списание или нет.
  5. 5 Закоммитить сбалансированный результат в двойной записи (дебет клиента, кредит мерчанта/комиссии) или выполнить компенсирующее действие (отменить отложенную запись, снять резервирование) — сделать ledger финальным источником истины.

Узкие места и компромиссы

Определяющая позиция — корректность важнее доступности: когда исход операции двусмыслен, система держит (помечает pending, запрашивает PSP, ждёт сверки), а не рискует двойным списанием или потерянным платежом. Конкуренция за хранилище идемпотентности может стать узким местом горячего пути — lookup дедупа и атомарная запись на каждом платеже — поэтому оно должно быть быстрым и консистентным (строго консистентное хранилище, ключи с TTL после расчёта). Пропускная способность записи ledger редко предел на платёжном QPS, но дисциплина append-only, неизменяемости меняет рост хранения и стоимость запросов (балансы суммируются, часто с периодическими снимками/материализованными балансами, чтобы не пересуммировать всю историю) на проверяемость — компромисс, который платежи всегда делают. Связанность с PSP — реальный риск: внешний процессор медленный, может отказать, может лежать — поэтому интеграция асинхронна, ретраится и в идеале спрятана за интерфейсом, чтобы можно было маршрутизировать на запасной PSP. И PCI/комплаенс толкают никогда не хранить сырые номера карт — токенизируешь через PSP, держа чувствительные данные вне своих систем целиком.

Викторина

Клиент шлёт списание, PSP успешен, но ответ теряется; клиент повторяет. Какой дизайн реально предотвращает двойное списание, и почему ключ должен прийти от клиента?

Викторина

Почему платёжные системы используют append-only ledger двойной записи, а не хранят баланс каждого счёта как изменяемое число, обновляемое на каждом платеже?

Закончи аналогию

В ledger двойной записи каждый платёж записывается как две записи — дебет и равный кредит — что обязаны давать в сумме ноль, так что деньги _______ по построению: они могут двигаться между счетами, но не могут быть созданы или уничтожены, а любой дисбаланс немедленно обнаружим.

Вспомните перед уходом
  1. 01
    Как ключ идемпотентности решает проблему двойного списания, и почему его должен генерировать клиент?
  2. 02
    Что такое ledger двойной записи и какие два свойства делают его источником истины о деньгах?
  3. 03
    Почему saga и сверка, а не одна транзакция, и какова общая позиция?
Итог

Платёжная система вращается вокруг одного вопроса — видел ли я ровно этот платёж раньше? — потому что деньги это сохраняющаяся величина, а самая обыкновенная вещь в интернете, повторённый запрос после двусмысленного таймаута, — это двойное списание из вступления. Починка — клиентом отчеканенный ключ идемпотентности с атомарным check-and-store: ретрай находит ключ и возвращает исходный результат вместо повторного списания, и клиент обязан владеть ключом, потому что лишь ключ, созданный до первой попытки, переживает потерянный ответ. Сами деньги живут в append-only ledger двойной записи, где каждое движение — две сбалансированные записи (дебет = кредит): неизменяемые, чтобы аудит-трейл был цел и порча обнаружима, сбалансированные, чтобы деньги сохранялись по построению, а баланс был проверяемой суммой, никогда изменяемым полем, что молча портится. Поскольку платёж охватывает твой ledger и внешний PSP, которые не разделят одну ACID-транзакцию, ты оркеструешь его как saga с компенсирующими действиями и разрешаешь двусмысленные таймауты запросом к PSP — а дневная сверка с отчётом PSP о расчётах — финальный арбитр, ловящий потерянные ответы и дрейф. Всюду QPS скромен, но планка жестока: система выбирает корректность важнее доступности, токенизирует карточные данные ради PCI и считает сверенный ledger — а не какой-либо единственный ответ — истиной о деньгах. Теперь, когда встретишь таймаут на платёжном вызове, не угадывай и не считай его провалом — запроси реальный статус у PSP, и пусть сверенный ledger скажет последнее слово.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.