Идемпотентность и retry в платёжном ядре: строим операции, которые нельзя задвоить и нельзя забыть
1. Введение: почему «повторить запрос» — не то же самое, что «повторить операцию»
Каждому, кто работал с платежами, знаком этот сценарий. Пользователь платит, браузер крутит спиннер, сервер молчит дольше обычного. Пользователь жмёт кнопку ещё раз — и через минуту обнаруживает два списания вместо одного. Или ни одного: платёж «ушёл», но статус не вернулся, и что с ним — неизвестно ни пользователю, ни системе.
Внешне это выглядит как сбой. На самом деле это нормальная работа распределённой системы: сеть не гарантирует доставку сообщений — пакеты теряются, соединения рвутся, таймауты случаются. Запрос может дойти до сервера и обработаться, но ответ потеряться в пути. Клиент не может отличить «не дошло» от «обработано, но ответ потерян» — и ретраит. Сервер получает повторный запрос и не знает, обрабатывать ли его заново. Это классическая проблема двух генералов: при ненадёжном канале связи стороны не могут гарантированно согласовать, выполнена ли операция. Единственный практический способ прийти к согласию — идемпотентная обработка повторов, о которой и пойдёт речь.
Разница между «повторить запрос» и «повторить операцию» — это разница между двойным списанием и корректным повторным уведомлением. Платёжное ядро должно проектироваться так, чтобы повторный запрос не создавал новую операцию, а возвращал результат уже существующей.
Но задвоение — только половина проблемы. Вторая половина — операции, которые теряются. Шлюз оказался недоступен, повторные попытки результата не дали, платёж так и остался необработанным — и о нём забыли. Или его провели с большой задержкой, когда льготный период оплаты штрафа (скидка 50% в первые 20 дней) уже истёк. Для компании это прямые убытки: штраф, который можно было оплатить со скидкой, уходит по полной стоимости. Поэтому контракт платёжного ядра формулируется жёстко: каждая оплата должна быть проведена ровно один раз — не 0 и не 2 раза.
Цена ошибки здесь выше, чем в большинстве других систем, — и она трёхслойная.
Финансовая. Двойное списание, или оплата 100% штрафа вместо 50% — ведь сервис гарантирует оплату, — это деньги, которые нужно возвращать, споры, chargeback'и, потерянные проценты. Каждое задвоение стоит реальных денег, и чем выше оборот, тем дороже.
Репутационная. Пользователь, у которого списали дважды, не разбирается в архитектурных причинах: он просто запоминает сервис как «ненадёжный». В B2B-платежах это теряемые контракты, в B2C — уходящие пользователи.
Регуляторная. Платёжные системы обязаны обеспечивать корректность операций: ошибки в проведении платежей — это претензии, проверки, штрафы. Регулятор не интересуется, была ли у вас «проблема с сетью».
На платёжном ядре, которое я развивал для «РОСШТРАФЫ», обработка шла на объёме более 20 000 успешных транзакций в сутки при uptime 99.99%. На таком масштабе процент ошибок перестаёт быть абстракцией: каждый процент ошибочных операций — это сотни инцидентов в день. Поэтому задача ставилась не «исправить баг», а спроектировать операции так, чтобы сбои сети и повторные попытки были безопасны по построению.
Эта статья — о связке из двух механизмов, идемпотентности и retry, и о том, что они дали на реальном ядре: снижение уровня ошибочных и мошеннических операций на 25% и рост конверсии успешных платежей примерно на 10%.
2. Подход: платёжное ядро как конечный автомат
Любая платёжная операция — это не «запрос», который выполнился или нет. Это путь с состоянием: операция создаётся, проходит этапы, меняет статусы и в любой момент может быть прервана — сетью, таймаутом, падением сервиса, повторной доставкой запроса.
Если мысль «операция = запрос» приводит к хаосу при любом сбое, то мысль «операция = конечный автомат» превращает сбои в управляемые переходы.
Контракт простой и жёсткий: операция должна быть проведена ровно один раз (exactly-once). Ноль раз — это потерянный платёж и упущенный льготный период; два раза — двойное списание, возвраты и споры. Конечный автомат со статусами решает обе проблемы: состояние не даёт потерять операцию, идемпотентность — не даёт провести её дважды.
Операция как набор состояний и переходов
У платежа есть предсказуемые состояния: создана, обрабатывается, проведена, отклонена, в сверке, требует ручного разбора. Каждый переход между состояниями — это отдельное действие, и главное требование к нему одно: переход должен быть идемпотентным. Повторное применение того же перехода не должно менять результат.
Это значит: если операция уже переведена в статус «проведена», повторная попытка перевести её туда же не должна создавать второе списание. Она должна обнаружить, что состояние уже достигнуто, и вернуть его — как факт, а не как новое действие.
Три уровня, на которых живёт идемпотентность
Идемпотентность — не свойство одного метода, а свойство всей цепочки. Она должна быть обеспечена на трёх уровнях, и если пропустить хотя бы один — дубли пролезут через другие.
Уровень API. Повторный запрос с тем же идентификатором операции возвращает тот же результат, что и первый. Клиент может безопасно ретраить, не думая о последствиях.
Уровень обработчика. Логика проведения платежа перед каждым действием проверяет: а не выполнено ли оно уже? Не «создать новое списание», а «найти операцию с таким ключом и вернуть её состояние».
Уровень хранилища. Уникальные ограничения (unique constraints) на ключи операций — последний рубеж, который не даёт базе данных создать дубль, даже если приложение ошиблось. Хранилище не прощает, и это хорошо.
Один бизнес-смысл — один идентификатор
Ключевой принцип, вокруг которого строится вся конструкция: один бизнес-смысл операции = один идентификатор (idempotency key), который переживает все ретраи. Клиент генерирует ключ один раз на всю операцию — не на каждый запрос, а на весь её жизненный цикл. Ретраи шлют тот же ключ. Сервер по ключу находит уже существующую операцию и возвращает её результат.
Это работает как в реальном мире. Банковский перевод — это не «сообщение», которое можно отправить дважды: это поручение с номером, и банк по нему видит: операция уже проведена, вот её статус. Точно так же повторная регистрация гостя в отеле по одному паспорту не создаёт второй номер — система находит уже существующую запись и возвращает её.
Платёжное ядро, в котором «операция = конечный автомат с идемпотентными переходами и единым ключом», может пережить любые повторные попытки — сети, клиентов, шлюзов. Дальше разберём два механизма по отдельности.
3. Механизм 1: идемпотентность
Идемпотентность — это свойство операции: сколько раз ни выполняй — результат тот же, что при первом выполнении. В платежах это свойство не роскошь, а необходимость, потому что повторная доставка запросов — не авария, а норма. Очереди доставляют сообщения как минимум один раз (at-least-once), шлюзы ретраят, пользователи пережимают кнопки.
На ядре «РОСШТРАФЫ» механизмы идемпотентности внедрялись вместе с интеграциями с ГИС ГМП и эквайринговыми провайдерами: каждый внешний вызов, каждая операция проведения — с ключом, который позволяет и нам понять: «это уже было».
Как проверить, что операция уже проведена
Перед тем как выполнить действие, обработчик ищет операцию по ключу:
- ключ найден, операция в терминальном статусе — возвращаем её результат как ответ на текущий запрос. Не проводим повторно, не создаём дубль;
- ключ найден, операция в промежуточном статусе — возвращаем «операция в обработке»; клиент может продолжать ждать и ретраить;
- ключ не найден — создаём новую операцию и выполняем действие.
Простая логика, но именно она превращает «повторный запрос» из источника двойных списаний в безопасное повторное уведомление.
Проектирование ответа
Важный нюанс: повторный запрос должен возвращать исходный результат, а не «новый». Это значит — тот же статус, тот же идентификатор, те же реквизиты. Если первый запрос завершился ошибкой, повторный должен вернуть ту же ошибку с тем же идентификатором, а не «успех» — иначе клиент не сможет отличить реальный отказ от сбоя сети.
Аналогия «кнопка лифта»
Идемпотентность — это кнопка лифта. Сколько раз ни нажимай — лифт приедет один раз. Кнопка запоминает, что вызов уже принят, и повторные нажатия не создают новых вызовов. Именно так должен вести себя платёжный сервис на повторные запросы: признать, что операция уже принята, и вернуть её результат.
4. Механизм 2: retry и очереди
Идемпотентность отвечает на вопрос «что будет, если запрос повторится?». Retry отвечает на вопрос «а надо ли вообще повторять — и как?». Сами по себе эти механизмы не работают: retry без идемпотентности — это фабрика двойных списаний, идемпотентность без retry — это потерянные операции. Клиент получил таймаут и ушёл, операция повисла в неопределённости. Работают они только в паре.
Платёж — это две операции, а не одна
Пользовательская часть платежа — это чаще всего две операции, а не одна. Сначала система блокирует средства на карте — авторизация (hold): сумма замораживается, но ещё не списана. Затем платёж уходит на проведение в ГИС ГМП, и только после подтверждения внешней системы авторизация превращается в списание (capture) — деньги реально снимаются с карты.
Для retry это принципиально: между блокировкой и списанием есть окно, в котором повторная попытка опасна вдвойне. Повторная блокировка без снятия предыдущей — это замороженные дважды средства. Повторное списание без проверки статуса — двойное снятие. Поэтому каждая из двух операций получает собственный idempotency key и собственные правила повторов: блокировка идемпотентна сама по себе, а списание происходит ровно один раз и только после того, как внешняя система подтвердила проведение.
Retry на синхронном участке
Синхронный участок — это клиентская часть платежа: инициализация, блокировка средств, лёгкие проверки. Здесь вызовы ретраятся по правилам, а не «как получится»:
- ограниченное число попыток — не бесконечный цикл, а фиксированный лимит, после которого операция уходит в очередь недоставленных (dead-letter queue) для ручного разбора;
- экспоненциальная пауза — интервал между попытками растёт, чтобы не долбить внешнюю систему в момент, когда ей плохо;
- таймауты на каждую попытку — попытка ограничена по времени, иначе retry сам становится источником зависаний;
- лёгкий разброс (jitter) — чтобы одновременные ретраи клиентов не приходились в одну точку.
У синхронного участка есть ещё одно важное свойство: здесь проходят проверки безопасности. Операция ещё не провела деньги — средства только заблокированы, списание произойдёт после подтверждения внешней системы. Поэтому на этом этапе платёж можно безопасно отменить без финансовых потерь: если транзакция кажется подозрительной или поведение пользователя аномальное — блокировка снимается, деньги не списаны, пользователь ничего не потерял. Чем дальше операция продвинулась по конвейеру, тем дороже отмена: после списания это уже возврат со спорами и chargeback'ами. Отсюда правило: проверки безопасности — на входе, где отказ ничего не стоит.
Правило, которое превращает retry из опасности в механизм: повторяем только идемпотентные операции. Повторный вызов безопасен ровно настолько, насколько безопасно повторить его действие. Поэтому сначала — ключи и проверки, потом — ретраи.
Асинхронный участок: ГИС ГМП
Вторая часть контура — работа со шлюзами ГИС ГМП — асинхронная по построению. Шлюзы не справляются с синхронным потоком запросов, поэтому запросы на оплату штрафов и проводку денежных средств уходят только в очередь и обрабатываются воркерами: запрос принят → поставлен в очередь → выполнен → статус зафиксирован → пользователь уведомлён.
Здесь работает полный набор механик: балансировка нагрузки между шлюзами, перенос нагрузки с одного шлюза на другой при деградации, повторы по правилам, контроль за исполнением каждой операции и статус-модель проводок оплаты штрафа — операция всегда знает, на каком этапе она находится. Зависшие и проблемные операции не ждут, пока на них наткнутся: инспектор платежей периодически прогоняет все операции за период, находит проблемные и отдаёт список на ручной разбор. Отчёты по проводкам дают видимость на уровне всего потока.
Сбой в этом контуре не должен оборачиваться финансовыми потерями. Если упало уведомление — пользователь не узнал о статусе вовремя, но деньги не пострадали, и очередь доставит сообщение повторно. Сверки, которые гоняются по расписанию, тоже переживают сбои: пропущенный цикл догоняется следующим.
Асинхронность снимает с критичного пути всё, что может подождать: проводки, уведомления, сверки. Синхронным остаётся то, что определяет результат на стороне пользователя: инициализация и блокировка средств.
Когда retry не спасает: балансировка между шлюзами
Один эквайринг — это единая точка отказа. Если шлюз недоступен, никакие ретраи не помогут: операция будет повторяться впустую, копить fail и задерживать оплату — ровно тот сценарий «оплата не прошла вовремя». Поэтому в ядре работало несколько провайдеров по паттерну «Стратегия» — поддержка 5+ платёжных провайдеров с ротацией: если один шлюз деградирует или отдаёт нестабильный ответ, трафик уходит на другой. Это подняло надёжность приёма платежей до 99.8% и убрало зависимость от единого поставщика. Приоритетный список провайдеров снизил комиссионные издержки: трафик отдаётся живому провайдеру с самой низкой комиссией. Суммарный эффект — рост конверсии приёма платежей.
Балансировка между шлюзами — это retry на уровне архитектуры: повторяем не запрос к недоступному шлюзу, а маршрутизацию к доступному. Учитываются и лаги сторонних сервисов: решение, куда отправить платёж, принимается не по «любимому» провайдеру, а по текущему состоянию каждого — таймауты, доля ошибок, скорость ответа.
Рекарринг: регулярные платежи как отдельные операции
Рекарринг (recurring payments) — это регулярные платежи, которые система проводит по расписанию без участия пользователя. Типичные примеры: подписки на сервисы, автоплатежи — ЖКХ, штрафы, кредиты, рассрочки — и регулярные взносы. Пользователь соглашается на списания один раз, а дальше платёжное ядро само проводит платёж за каждый период: день, месяц, квартал.
Главная ценность рекарринга — конверсия. Пользователь один раз вводит платёжные реквизиты и подтверждает их (3DS), а дальше повторные оплаты проходят «автоматом»: реквизиты вводить заново не нужно, система сама проводит списание. Каждый период, в котором пользователю пришлось бы снова доставать карту и проходить подтверждение, — это шанс потерять платёж. Рекарринг этот шанс убирает: первый платёж окупает себя на всей серии последующих.
Отсюда важное правило маршрутизации: если по пользователю уже есть рекарринговый платёж через конкретный шлюз — повторные оплаты нужно до последнего пытаться провести именно через этот шлюз. Там уже есть подтверждённый токен и отработанная связка; уход на другой шлюз разрывает рекарринговую связь, и пользователю, скорее всего, придётся снова вводить данные и проходить подтверждение. Переключение между шлюзами — для новых платежей и экстренных случаев, а не для рекарринга.
Для ядра рекарринг при этом остаётся серией обычных идемпотентных операций: каждый платёж периода — самостоятельная операция со своим idempotency key, повторная доставка не создаст второй платёж за период, а пропущенный период не потеряется — retry по расписанию доведёт его до конца. Отдельный слой — управление подпиской: пауза, отмена, смена платёжного средства, уведомления об истечении срока действия карты. Эти события тоже должны быть идемпотентными: повторное уведомление или повторный запрос на отмену не должны менять состояние подписки дважды.
Аналогия «курьер»
Retry — это курьер, который при неудачной доставке пробует снова по расписанию: подождал, вернулся, попробовал ещё раз, с увеличивающимся интервалом. Но он никогда не приносит две одинаковые посылки — потому что посылка идемпотентна: у неё один номер, и по номеру видно, доставлена она или нет. Retry отвечает за «попробовать ещё раз», идемпотентность — за «не сделать дважды».
5. Наблюдаемость и контроль
Механизмы есть — но как понять, что они работают, а не маскируют проблему? Retry без наблюдения — это чёрный ящик: операции повторяются, никто не знает почему, и инциденты всплывают в жалобах пользователей.
Мониторинг и алертинг на ключевых этапах
Платёжный цикл контролируется на ключевых этапах — инициализация, блокировка средств, проведение, подтверждение списания, проверка статуса. На каждом этапе собираются счётчики, и они живут в Grafana: сколько операций начато, проведено, отклонено, зависло, потребовало повторных попыток. В терминах SRE это SLI — индикаторы уровня сервиса, и для каждого задан целевой уровень (SLO): доля успешных операций, время ответа шлюза, доля операций, дошедших до конца без ручного вмешательства.
Но счётчики показывают только количество — второе измерение — время: сколько занимает переход от одного шага к другому. Инициализация → блокировка средств в шлюзе → проведение в ГИС ГМП → подтверждение списания: для каждого перехода есть среднее время, и оно живёт рядом со счётчиками. Временные метрики первыми замечают деградацию: если среднее время блокировки средств в платёжном шлюзе резко пошло вверх — это не статистический шум, а сигнал о проблемах со шлюзом и повод переключить трафик на другой.
Но метрика без алерта — это архив. Алерты настроены на нетипичные значения: резкий рост ретраев, падение доли успешных операций, всплеск статусов «требует разбора». Цель — поймать проблему на этапе зарождения, пока она не стала инцидентом: когда отклонение ещё видно в счётчике, а не в жалобах пользователей.
Метрики повторных попыток — отдельная история. Рост числа ретраев — это сигнал, а не шум: значит, внешняя система деградирует, или таймауты настроены неверно, или что-то в нашем контуре начало тормозить. Если ретраев стало больше, а операций проведено меньше — retry не работает, а маскирует проблему.
Распределение статусов как диагностика
Самый быстрый способ понять здоровье ядра — посмотреть распределение операций по статусам. Нормальная картина: подавляющее большинство — успешно проведённые, небольшая доля — отклонённые по бизнес-правилам, и маленький хвост — «требует разбора». Если хвост растёт — это повод для разбирательства, а не для надежды «само рассосётся».
Надёжность — это контролируемая обработка сбоев
Uptime 99.99% на ядре с 20 000+ транзакций в сутки — это не «у нас ничего не падает». Это «у нас сбои случаются и обрабатываются так, что пользователь их не замечает». Сеть будет терять пакеты, внешние системы — задерживать ответы, сервисы — перезапускаться. Вопрос не в том, как их избежать, а в том, что система делает, когда это происходит: идемпотентность не даёт задвоить, retry — потерять, наблюдаемость — не заметить.
6. Грабли и подводные камни
Механизмы простые, но на практике всё решают детали. Вот грабли, на которые наступают чаще всего.
Ложная идемпотентность
Ключ, построенный из данных, которые меняются между попытками, — это не идемпотентность. Если ключ включает пересчитываемую сумму или время, ушедшее вперёд, — повторный запрос создаст новую операцию. Ключ должен строиться из стабильных данных бизнес-смысла, а не из технических атрибутов запроса. И генерировать его должен клиент — один раз на операцию, а не на попытку.
Retry без идемпотентности — задвоение; идемпотентность без retry — потери
Это две стороны одной монеты. Ретраим без ключей — получаем дубли. Не ретраим вообще — операции теряются: пользователь получил таймаут, ушёл, а платёж так и не прошёл. Внедрять механизмы нужно в связке и именно в таком порядке: сначала идемпотентность, потом retry на её основе.
Сверки как страховочная сетка
Сколько ни проектируй, часть расхождений не поймают ни ретраи, ни идемпотентность: внешняя система провела операцию, а мы не узнали. Страховочная сетка — периодические сверки с внешними системами: сверяем наши записи с их записями и находим расхождения, которые механизмы пропустили. Сверка — это не замена идемпотентности, а второй контур защиты, который работает по расписанию.
На ядре «РОСШТРАФЫ» аналитика и сверки опираются на ClickHouse: ежедневные финансовые отчёты, которые раньше считались несколько часов, теперь строятся за 3–5 минут. Быстрая аналитика — это не только удобство: чем быстрее сверка, тем раньше находится расхождение и тем дешевле его исправить.
Мошеннические паттерны под видом технических сбоев
Часть «странных повторов» — не сеть, а целенаправленные действия: фрод-паттерны. Повторные попытки могут быть попыткой провести операцию повторно или прощупать границы системы. Важно отличать технические ретраи от аномальных последовательностей — и здесь решает фрод-мониторинг: сотрудничество инженеров и службы безопасности. Внутренние правила выявления мошенничества не раскрываю — но архитектурный вывод простой: логика повторов должна быть видимой и измеряемой, иначе аномалии тонут в шуме.
Для этого собираются статистические данные об аномальном поведении пользователя при оплате: паттерны, которые не похожи на технические ретраи, — аномальная частота операций, нехарактерные последовательности, подозрительные выборки. Статистика собирается на лету и позволяет принимать решения в моменте, а не по итогам разбора инцидента. Архитектурно это означает одно: данные для анализа должны собираться на каждом этапе платежа, а не только в момент, когда подозрение уже возникло.
Чего не делать
Не хранить чувствительные данные в логах и не логировать полные платёжные данные (PAN, CVV) — это прямые требования PCI DSS. Платёжный контур — это зона, где логирование «на всякий случай» превращается в компрометацию данных. Логировать нужно идентификаторы и статусы, а не содержимое.
7. Метрики результата
−25% ошибочных и мошеннических операций. Интеграции с ГИС ГМП и эквайринговыми провайдерами с механизмами идемпотентности и retry снизили уровень ошибочных и мошеннических операций на 25% — по данным службы безопасности. Это эффект связки «механизмы → меньше ошибок»: дубли перестали создаваться, потерянные операции перестали теряться, аномалии стали видны.
+~10% к конверсии успешных платежей и −15% к среднему времени обработки. Оптимизация ключевых процессов — инициализация и проведение платежа — подняла конверсию успешных платежей примерно на 10% и сократила среднее время обработки транзакции на 15%.
20 000+ успешных транзакций в сутки при uptime 99.99%. Масштаб, на котором всё это работает.
Как читать связку. Меньше ошибочных операций — меньше брошенных попыток и повторных кликов — выше доля доведённых до конца платежей. Прямую причинно-следственную зависимость между каждым механизмом и каждой цифрой я не утверждаю: у цифр несколько источников, и часть эффекта — от сопутствующей оптимизации процессов. Но направление однозначное: надёжность ядра напрямую влияет на бизнес-метрики, а не только на технические. Одна цифра = один факт из резюме, без домысливания промежуточных зависимостей.
И про способ, которым гипотезы проверялись: постоянное A/B-тестирование через feature flags. Изменение — порядок шагов оплаты, таймауты, поведение при сбое, маршрутизация по шлюзам — выкатывалось за флагом и включалось на реальных клиентах: выборка определялась на живом трафике, контрольная и экспериментальная группы работали параллельно, и в прод уходила только гипотеза, подтверждённая данными. Синтетические тесты здесь не работали: эффекты измерялись долями процента конверсии, и увидеть их могла только статистика по реальным платежам, а не лабораторный прогон. Это дисциплина, а не процесс: конверсия росла не «от хорошей идеи», а от решений, проверенных на живых выборках.
8. Выводы и чек-лист
Идемпотентность и retry — не «nice to have» и не «когда-нибудь потом». Это базовая надёжность платёжного ядра: без них любая сеть, любой таймаут и любой повторный клик — это потенциальный финансовый инцидент.
Порядок внедрения неслучаен: сначала идемпотентные операции, потом retry на их основе, затем наблюдаемость и в конце — сверки. Каждый шаг опирается на предыдущий: ретраить можно только то, что безопасно повторить; наблюдать — только то, что измеряемо; сверять — только то, что записано.
Чек-лист для ревью платёжного контура
- У каждой операции есть единый idempotency key, который генерируется один раз и переживает все ретраи?
- Повторный запрос с тем же ключом возвращает исходный результат — тот же статус, тот же идентификатор?
- Уникальные ограничения на ключи стоят на уровне хранилища — как последний рубеж?
- Retry ограничен по числу попыток, с экспоненциальной паузой, таймаутами и jitter?
- Повторяются только идемпотентные операции — ретрай не может создать дубль?
- Метрики повторных попыток и распределение статусов видны — рост ретраев невозможно пропустить?
- Сверки с внешними системами работают по расписанию — как второй контур защиты?
- Чувствительные данные не попадают в логи?
Если на любой пункт ответ «нет» — это кандидат в ближайший спринт. Не потому что «надо сделать красиво», а потому что цена ошибки в платежах — деньги.