Все углублённые блоки
Углублённые блоки
Блок 15 / Углублённый разбор

Бронирование и журнал операций: сохранить смысл при повторах

Свяжите ограниченный ресурс, неизменяемые проводки и внешний результат; разберите сбой между системами.

Клиент оплатил бронь, но приложение не получило ответ. Он повторяет действие. Первая попытка могла завершиться, поэтому повтор без идентификатора бизнес-операции способен создать второе списание. А если приложение успешно создало платёж, но потеряло бронь, пользователю недостаточно увидеть «ошибка, попробуйте снова».

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

Сначала обозначаем объекты и правила

Бронь связывает пользователя и ограниченный ресурс. Hold временно резервирует место до deadline. Заказ фиксирует намерение приобрести его. Платёжная попытка связывает заказ с запросом внешнему провайдеру. Журнал хранит принятые изменения учёта; он не заменяет все эти объекты одной таблицей status.

В учебной модели деньги представлены целым числом минимальных единиц и кодом валюты. Это исключает двоичную погрешность 0.1 + 0.2, но не задаёт автоматически правила округления, конвертации и диапазон. Разные валюты нельзя складывать в один баланс. Во внешнем договоре размер минимальной единицы определяется конкретной валютой и API.

У каждой операции есть уникальный ключ в области tenant и типа действия. Повтор с теми же параметрами возвращает ранее сохранённый результат. Повтор ключа с другой суммой, валютой или получателем отвергается. Правила срока хранения и повторного использования ключа фиксируются явно. Документ Stripe показывает договор одного провайдера; его нельзя автоматически переносить на любой сервис.

Проводки позволяют проверить сохранение суммы

Перенос 700 учебных единиц с A на B создаёт одну операцию и две проводки: A — −700, B — +700. Сумма проводок операции в одной валюте равна нулю. Начальное пополнение моделируем переносом из явно выбранного системного счёта; его правила отличаются от пользовательского счёта. Не создаём «деньги из воздуха» обычным UPDATE баланса.

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

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

Отдельно определяем договор отказа. В лаборатории недостаток средств фиксируется в таблице requests: повтор того же ключа возвращает тот же отказ, даже если позже появились средства. Для нового намерения нужен новый ключ. Изменённые параметры прежнего ключа дают конфликт. Невалидный вход и переполнение диапазона отклоняются до принятого запроса. В другой системе возможен иной договор, но он должен быть указан и проверен.

Модель ограничивает суммы и балансы значением 2^63−1. До записи проверяется сумма текущего баланса получателя и перевода. В SQLite арифметическое переполнение может дать REAL вместо ожидаемого целого значения; ограничение типа и явная проверка защищают инвариант модели. Целочисленный тип без проверки диапазона ещё не доказывает корректность учёта.

Проследим две попытки

Начало: A=1000, B=0. Запрос X переносит 700. Параллельный Y переносит 600. Если обе попытки отдельно прочитали 1000, они могут обе признать сумму допустимой.

В выбранном сериализованном протоколе X фиксирует A=300, B=700. Затем Y проверяет актуальные 300 и получает отказ. Журнал содержит одну принятую операцию и две проводки. Если первым фиксируется Y, допустим другой результат: A=400, B=600, а X отвергается. Обещаем инвариант и согласованный исход, а не победу определённого запроса без отдельного правила очередности.

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

Внешний эффект не входит в нашу транзакцию

Можно атомарно записать заказ и outbox в своей базе. Доставщик вызывает провайдера с устойчивым ключом платёжной операции. Полученный ответ или webhook обновляет локальную модель с дедупликацией. Но успех локального commit не доказывает успех внешнего списания.

Полезные состояния попытки: created, pending, succeeded, declined и unknown. Unknown означает необходимость узнать результат, а не возможность начать новое независимое списание. Подпись webhook проверяем, event_id дедуплицируем, связь события с нужным заказом валидируем. Порядок прихода webhook и ответа HTTP может отличаться. Переходы должны выдержать позднее сообщение и повтор.

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

Deadline брони — событие, а не магическое свойство часов

Hold истёк в 12:00:00. Callback об успешной оплате приходит в 12:00:01. Нужно заранее выбрать правило: сохранить место до согласованного завершения платежа, отвергнуть поздний результат с процедурой возврата либо принять только после повторной проверки доступности.

Нельзя одновременно обещать немедленно отдавать место после deadline и гарантированно завершать любую позднюю оплату без дополнительного ресурса. Переходы hold и booking должны иметь единую защиту вместимости. При смене лидера старый обработчик deadline не получает право отменить новую подтверждённую бронь; используем версию состояния или fencing на стороне ресурса.

Нагрузка и разделение данных

Пусть 1000 переводов/с создают по две проводки. Записывается 2000 строк проводок/с, дополнительно заголовки и индексы. За 30 суток это 5,184 миллиарда проводок. При учебном среднем 100 байт полезных полей — около 518,4 GB без индексов, реплик, WAL и служебных расходов. Это нижняя оценка, которую проверяют измерением физического объёма и распределения записей.

Шардирование по счёту усложняет перевод между шардами. Появляется выбор: ограничить размещение связанных счетов, применить согласованный распределённый протокол или использовать явные промежуточные состояния. «Добавим Kafka» не объясняет, кто подтвердил дебет и кредит. Горячий общий счёт тоже не распределится простым увеличением количества шардов.

Лаборатория

В архиве лабораторий запускайте python3 -m unittest -v test_cases. Модель на SQLite сериализует записи через транзакцию и предназначена для проверки инвариантов, а не для моделирования банковской инфраструктуры. Тесты проверяют конкурентные X/Y, повтор ключа, конфликт параметров и отказ до фиксации. В этой модели начальные балансы заданы отдельно: для каждого счёта текущий баланс должен равняться начальному плюс сумма его проводок. Общая сумма балансов сохраняется, а сумма проводок каждого переноса равна нулю.

Самостоятельно добавьте частичный возврат: исходный перевод 700, затем попытки вернуть 500 и 400. Сформулируйте, какие две строки нужно согласованно защитить, и добейтесь суммарного возврата не больше 700. Не меняйте общую проверку только для того, чтобы тест стал зелёным.

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

Проверьте себя

Перевод X зафиксировал обе проводки и баланс, но клиент потерял ответ. Приходит тот же X с прежними параметрами. Как сохранить один эффект?

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