Спроектируй платёжную систему
Платёжная система: ключи идемпотентности против двойного списания, двойная запись (double-entry) как источник истины, сверка с PSP, оркестрация saga для распределённой транзакции и неизменяемый аудит-трейл.
Клиент нажал «Оплатить» один раз. С его карты списали дважды. Вот последовательность: приложение отправило запрос на списание, платёжный провайдер успешно его обработал — а потом ответ отвалился по таймауту на обратном пути. Приложение, не видя ответа, сделало «очевидное» и повторило запрос. Провайдер, не имея способа узнать, что это тот же намеренный платёж, обработал второе списание. Клиент увидел две строки, открыл 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 Сгенерировать и сохранить ключ идемпотентности со статусом pending — до того как двинутся деньги, чтобы падение здесь было безопасной no-op, а повтор нашёл ключ и пропустил повторное выполнение.
- 2 Записать отложенную дебетовую запись в двойной ledger — зарезервировать средства, чтобы баланс клиента отражал платёж в процессе и второй конкурентный запрос не мог списать дважды.
- 3 Вызвать PSP с ключом идемпотентности — внешнее списание; это медленный, ненадёжный шаг, ради выживания при котором и построена вся saga. Передавать тот же ключ, чтобы PSP дедуплицировал повторы.
- 4 При двусмысленном таймауте запросить PSP об истинном исходе через ключ идемпотентности — никогда не угадывать; коммитить только когда известно, прошло ли списание или нет.
- 5 Закоммитить сбалансированный результат в двойной записи (дебет клиента, кредит мерчанта/комиссии) или выполнить компенсирующее действие (отменить отложенную запись, снять резервирование) — сделать ledger финальным источником истины.
Узкие места и компромиссы
Определяющая позиция — корректность важнее доступности: когда исход операции двусмыслен, система держит (помечает pending, запрашивает PSP, ждёт сверки), а не рискует двойным списанием или потерянным платежом. Конкуренция за хранилище идемпотентности может стать узким местом горячего пути — lookup дедупа и атомарная запись на каждом платеже — поэтому оно должно быть быстрым и консистентным (строго консистентное хранилище, ключи с TTL после расчёта). Пропускная способность записи ledger редко предел на платёжном QPS, но дисциплина append-only, неизменяемости меняет рост хранения и стоимость запросов (балансы суммируются, часто с периодическими снимками/материализованными балансами, чтобы не пересуммировать всю историю) на проверяемость — компромисс, который платежи всегда делают. Связанность с PSP — реальный риск: внешний процессор медленный, может отказать, может лежать — поэтому интеграция асинхронна, ретраится и в идеале спрятана за интерфейсом, чтобы можно было маршрутизировать на запасной PSP. И PCI/комплаенс толкают никогда не хранить сырые номера карт — токенизируешь через PSP, держа чувствительные данные вне своих систем целиком.
Клиент шлёт списание, PSP успешен, но ответ теряется; клиент повторяет. Какой дизайн реально предотвращает двойное списание, и почему ключ должен прийти от клиента?
Почему платёжные системы используют append-only ledger двойной записи, а не хранят баланс каждого счёта как изменяемое число, обновляемое на каждом платеже?
В ledger двойной записи каждый платёж записывается как две записи — дебет и равный кредит — что обязаны давать в сумме ноль, так что деньги _______ по построению: они могут двигаться между счетами, но не могут быть созданы или уничтожены, а любой дисбаланс немедленно обнаружим.
- 01Как ключ идемпотентности решает проблему двойного списания, и почему его должен генерировать клиент?
- 02Что такое ledger двойной записи и какие два свойства делают его источником истины о деньгах?
- 03Почему saga и сверка, а не одна транзакция, и какова общая позиция?
Платёжная система вращается вокруг одного вопроса — видел ли я ровно этот платёж раньше? — потому что деньги это сохраняющаяся величина, а самая обыкновенная вещь в интернете, повторённый запрос после двусмысленного таймаута, — это двойное списание из вступления. Починка — клиентом отчеканенный ключ идемпотентности с атомарным check-and-store: ретрай находит ключ и возвращает исходный результат вместо повторного списания, и клиент обязан владеть ключом, потому что лишь ключ, созданный до первой попытки, переживает потерянный ответ. Сами деньги живут в append-only ledger двойной записи, где каждое движение — две сбалансированные записи (дебет = кредит): неизменяемые, чтобы аудит-трейл был цел и порча обнаружима, сбалансированные, чтобы деньги сохранялись по построению, а баланс был проверяемой суммой, никогда изменяемым полем, что молча портится. Поскольку платёж охватывает твой ledger и внешний PSP, которые не разделят одну ACID-транзакцию, ты оркеструешь его как saga с компенсирующими действиями и разрешаешь двусмысленные таймауты запросом к PSP — а дневная сверка с отчётом PSP о расчётах — финальный арбитр, ловящий потерянные ответы и дрейф. Всюду QPS скромен, но планка жестока: система выбирает корректность важнее доступности, токенизирует карточные данные ради PCI и считает сверенный ledger — а не какой-либо единственный ответ — истиной о деньгах. Теперь, когда встретишь таймаут на платёжном вызове, не угадывай и не считай его провалом — запроси реальный статус у PSP, и пусть сверенный ledger скажет последнее слово.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.