Комментарии 37
Браво. Но это вы еще не добрались до eventual consistency и region availability. Традиционные базы не масштабруются - если бизнес растет быстро, все решения должны быть масштабируемые, включая базу и Postgres - это вариант только до первого серьезного пика. Для "настоящих сварщиков" есть DDB или ее opensource вариант Cassandra - вот они масштабируются. но они eventually consistent и их использовать надо очень аккуратно. Я не про вашу компетенцию - вы точно сможете. Я лишь про то, что грабли там тоже припасены и не одна.
А если вы доберетесь до объемов Stripe, вам еше и сеть начнет сильно мешать - с ее задержками, потерями пакетов и доступностью. И чем больше вы будете распределять нагрузки, тем сильнее сеть будет мешать.
Согласен, при росте начинается следующий круг - eventual consistency, cap, межрегиональные задержки. В статье намеренно ограничился одним кругом, чтобы не размазывать тему, но направление мне нравится 👌
К слову, подход с intent key неплохо ложится на eventually-consistent store именно потому, что строится на бизнес-семантике, а не на транзакционной блокировке. В Cassandra и ScyllaDB то же поведение делается через LWT (IF NOT EXISTS) по intentKey и с теми же тремя статусами Reserved/InProgress/Completed. Цена этого в том что LWT медленнее обычного write на порядок, и при сетевой партиции возможен false-negative по уже зарезервированному ключу. То есть этот приём можно переиспользовать, но появляются свои новые грабли - вы правы, как минимум одна точно.
доберетесь до объемов Stripe, вам еше и сеть начнет сильно мешать
По сетевым проблемам на объемах stripe - там retry-стратегия живёт уже отдельной жизнью с jittered backoff, hedged requests, региональным failover. Это тянет на полноценную объемную статью.
Про Cassandra звучит как неправда. Это с чего это вдруг алгоритм консенсуса делает какой то битый стейт? Зачем тогда нужно lwt если оно такое дно?
Справедливое замечание, формулировка была плохая - paxos через партицию такого не пропустит. Я имел в виду немного другое - на этом легко споткнуться при переезде с реляционок:
цена: lwt-запись примерно в 4 раза медленнее обычной, тут 4 раунда обмена по сети вместо 1 - на популярных ключах клиенты начинают мешать друг другу в paxos
таймаут: если ответ не пришёл - операция могла пройти, могла не пройти, и клиент это не отличает - т.е. чтобы безопасно повторить, надо отдельно перечитать ключ с уровнем serial и посмотреть, что там по факту
и чтение сразу после записи: если читать обычным кворумом сразу после lwt-записи, можно ещё не увидеть только что записанное - paxos закоммитил, но до всех реплик не доехало, поэтому lookup в idempotency-store читается тоже с serial
LWT - инструмент рабочий, не "дно", просто переезд intent-key из postgres в cassandra не бесплатный
И там уже есть 2 новые сущности (новые, если у вас их еще нет) -- обработка в центрах поближе к клиенту и консолидация. Вторая - это просто зеркало всё тех же проблем. Первая - практически не реализуемая без облаков (реализуемая и без, конечно, но дорого). Консолидация, может не правильный термин, наверное правильнее - миграция контекста. Это когда обработка ближе клиенту, но клиент переехал и хочет видеть всё тот же контекст, который теперь для него в старом центре. Это норм, если клиент не напряжный, а если он очень активный и таких много, это уже проблема.
Настоящие сварщики просто молча делают авто-рефанд по ночам через кронджоб)) А строить мультирегиональный кластер ради продажи билетов это уже архитектурная астронавтика
Корзина - это же не Counter-Strike. Если в ней товар, то он зарезервирован на сервере и любое количество нажатий приводит или к началу атомарной транзакции, или к очистке корзины. А в статусе покупки или оплачено или отмена.
это работает пока повторы идут только от нажатий, а в статье речь также про повторы от service worker, rxjs retry и фоновых job на стороне сервера - атомарная транзакция в БД их не различает, нужен ключ выше неё - это и есть intent-key
Транзакция в базе не спасет, если запрос отвалился по таймауту на балансировщике. Фронт кинет ретрай, а база создаст новую корзину и спишет деньги второй раз
provider.AcquireReference
идемпотентность силами клиента, интересно...
Еще такой вопрос:
составной ключ идемпотентности несет в себе отпечаток архитектуры всей системы. Получается, что при развитии архитектуры и состав ключа будет "уползать"?
Спасибо, обе мысли по делу. Разверну подробнее...
идемпотентность силами клиента
Да, в моей формулировке идемпотентность держится на нашей стороне: провайдер гарантирует идемпотентность своих собственных методов, т.е. AcquireReference идемпотентен у него, Commit идемпотентен по референсу у него, но связку "наш intentKey ↔ их providerRef" знаем только мы. Альтернатива такая: класть наш payload в их собственный Idempotency-Key (например, Stripe это позволяет), тогда ответственность уезжает уже на провайдера. Выбор делается по тому, насколько провайдер доверенный и стабильный - когда у тебя 4 разных шлюза, проще держать единый язык на своей стороне и не зависеть от мелких различий между ними, имхо.
при развитии архитектуры и состав ключа будет "уползать"?
Да, и это правильно. Состав ключа это отражение наших бизнес-инвариантов операции на текущий момент. Появится мульти-валютный split - попадёт в ключ. Появится tenant-id, partial payments - попадут в ключ.
По сути это та же история, что миграции схем в БД: меняем их не молча, а с версионированием. Как вариант, добавляем префикс версии: v1:pay:... сейчас, а при изменении состава переключаемся на v2:pay:..., старые v1-ключи доживают свой TTL в хранилище и обслуживают повторы клиентов на старой схеме. Через окно retry клиента (у нас это 30 дней) v1 можно выкидывать. Главное, не делать молчаливых изменений: добавление поля в ключ - такой же пункт в релизе, как и ALTER TABLE.
Что было бы багом - ключ, который НЕ эволюционирует вместе с бизнесом. Тогда дедуп начинает срабатывать на семантически разные операции, и грабля прилетит уже с другой стороны.
Состав ключа меняется только при изменении самой бизнес-логики платежа. Добавление новой фичи типа сплит-оплаты естественно потребует новой версии ключа
Спасибо, хорошая статья.
Но можно ли подробней осветить этот момент?
>Если провайдер не поддерживает ничего из этого – оборачиваем его в свой адаптер с собственным reference-store и реализуем двухфазную модель на свой страх и риск. Это работает, но добавляет новые точки отказа.
Если провайдер не поддерживает "ничего", то какими приседаниями вы от него добиваетесь идемпотентности?
Наверное, явной отправкой cancel после авторизации или вызовом refund после capture, если авторизация или capture явно не завершились успехом.
Cancel бесплатный, refund может стоить денег, но это дешевле, чем разбираться потом.
Ну, это вариант компенсации саги, для этого провайдер должен поддерживать методы отмены. В статье этот момент явно не оговаривается.
Пока я добрался ответить, тут в ветке половину ответа уже собрали - саги, компенсации и универсальность refund - всё в тему. Добавлю.
Refund действительно почти всегда есть, и как safety net работает, но...
Строить идемпотентность только на refund - дорогой компромис: клиент видит в выписке два списания и возврат, в поддержку летит "почему у меня два списания?", даже если по деньгам чётко сошлось. Поэтому в моей картине refund - не первичный механизм дедупа, а фоллбэк как компенсация после ежедневной сверки с провайдером (reconciliation). Основная задача - не допустить второго списания вообще.
Что тут помогает:
Single-flight на нашей стороне - между intent-key и провайдером всегда один in-flight вызов, параллельные ретраи блокируются.
На таймауте не повторяем вслепую - сначала пытаемся выяснить, прошла ли первая попытка: ищем по сумме+карте+окну, по нашему reference (если есть хоть какая-то возможность поискать). Только если убедились, что не прошла, тогда ретраим.
Между уходом запроса и доказанным результатом операция в статусе "в процессе подтверждения", не "оплачено". Ретраит фоновый job, не клиент.
Reconciliation в фоне - сверяем с выгрузкой провайдера - на расхождения auto-refund. Это уровень, где refund и срабатывает.
Чем толще верх (single-flight + lookup), тем реже срабатывает низ (auto-refund), тем чище и очевиднее для юзера.
Замечание про то, что статья этот пласт явно не раскрывает - справедливое. Слой компенсаций тянет на отдельный пост.
Редкий случай, когда постмортем написан по делу и без корпоративной воды.
Идемпотентность по бизнес-логике, а не по http-запросу это буквально база, которую многие осознают только после потери реальных денег
Вообще звучит как-то сложно эти все пункты. Redis кстати не имеет синк репликации так что при сбое мастера будет потеря данных и потеря идемпотентности.
Выглядит так что ключом идемпотентности должен быть "id бронирования", видимо это bookingId. Он летит с фронтенда, он же летит в платежного провайдера, всё, задача решена. Можно ретраить где угодно и сколько угодно (если провайдер не наркоман и у него идемпотентность работает).
По Redis согласен - в грабле 2 как раз про это:
переноси в надёжное хранилище – Postgres вместо Redis
В финале мы так и сделали именно из-за асинхронной репликации - ack может пропасть на сбое мастера, и idempotency-store этого не прощает.
По bookingId - интуиция верная, и он действительно часть ключа в моём решении. Но только лишь bookingId не закрывает такие кейсы:
Soft decline + честный повтор через час. Первый платёж провалился (банк перегружен, fraud-флаг), пользователь пробует снова. Тот же bookingId → закэшированный fail → легитимная оплата заблокирована. Это уже не идемпотентность, а простой по чужой ошибке.
Изменение содержания операции. Бронь на 50к не прошла, пользователь добавил трансфер, стало 65к, bookingId тот же. По вашей схеме интенты слиплись - вернётся ответ от первого платежа. По бизнесу это разные операции с разной суммой.
Поэтому ключ собирается из userId + bookingId + amount + currency + окно. Amount+currency ловят изменение содержания, окно отделяет "повтор сейчас" от "новая попытка через час".
Про "если провайдер не наркоман" - это и есть грабля 4 - не все провайдеры поддерживают свой Idempotency-Key, особенно региональные банковские шлюзы и старые интеграции с эквайерами. Когда у провайдера ничего нет - идемпотентность держится на своей стороне, а это уже не про "ретраить где угодно и сколько угодно".
Изменение содержания операции должно привести к отмене старой и генерации нового bookingId например.
Если платёж провалился из-за банка то этот bookingId уже нельзя использовать, нужен новый по идее.
Так тоже можно, но это не упрощение, просто сложность переезжает в другие места:
Offline-сценарии. Юзер был без сети 4 дня, service worker дослал запрос спустя эти 4 дня - у него старый bookingId, на сервере уже новый - либо ошибка "пересинхронизируйся" (offline ломается), либо плодим дубль.
Семантика. Если bookingId меняется при каждом decline - это получается уже не "бронь", а "попытка оплаты". Сама бронь всё равно нужна со стабильным ID для саппорта и отчётов - получаем 2 ID там где мог бы быть 1.
Multi-payment. Депозит + остаток, сплит между двумя картами, корп.карта + личная - это всё одна бронь, несколько частичных платежей. С "bookingId = попытка оплаты" не работает.
Front-back координация. Регенерация bookingId на decline - это синхронный round-trip перед каждой попыткой. А текущая модель от клиента ничего не требует.
Для простых доменов без split и offline - вполне жизнеспособно, только это не "задача решена одним bookingId", это та же сложность, разнесённая по другим местам.
Спасибо, отличная статья. Подскажите как реализована функция Window5Min. А так же не совсем понял как решена проблема с тем, что service worker с фронта дошлет запрос после истечения этого 5ти минутного окна если пропала связь. Тогда ведь ключ будет другой и операция повторится?
как реализована функция Window5Min
Спасибо, это вопрос, который я ждал! Намеренно не стал включать это в статью, чтобы излишне не раздуть её, и хотел вынести это в обсуждение. Window5Min это самая простая реализация, а вообще есть несколько разных подходов как зафиксировать intent в промежуток времени:
Window5Min() - округление вниз до начала 5-минутного интервала:
static DateTime Window5Min(DateTime t)
{
var bucket = TimeSpan.FromMinutes(5).Ticks;
return new DateTime((t.Ticks / bucket) * bucket, DateTimeKind.Utc);
}Пример:
Если запрос пришёл в 12:03:45 – функция вернёт 12:00:00 (начало корзины 12:00-12:05)
Если запрос пришёл в 12:07:32 – функция вернёт 12:05:00 (начало следующей корзины 12:05-12:10)
Да, в этом подходе очевидно есть проблема:
Если пользователь кликнул в 12:04:58 и 12:05:02 (4 секунды между кликами), они попадают в разные корзины → разные ключи → дедуп не сработает.
Как с этим живут в проде:
Вариант 1: проверять две корзины сразу. При lookup-е считаем ключ и для текущей корзины, и для предыдущей - если попадание хоть в одной, это повтор. Слегка дороже по запросам, но граничный случай закрыт.
var keyNow = BuildIntentKey(u, b, m, Window5Min(DateTime.UtcNow));
var keyPrev = BuildIntentKey(u, b, m, Window5Min(DateTime.UtcNow.AddMinutes(-5)));
if (await store.AcquireOrGet(keyNow) is Completed c) return c;
if (await store.AcquireOrGet(keyPrev) is Completed cPrev) return cPrev;
Вариант 2: sliding window. Не корзина, а timestamp последней попытки по userId+bookingId+amount. Если новая попытка пришла в пределах 5 мин от сохранённого - это тот же intent. Точнее, но требует чтения "последней попытки" перед каждым запросом, и что важно, сама эта запись должна обновляться атомарно.
Вариант 3 (и главный): клиентский intent token как основная линия. UUID привязан к клику, а не к настенным часам - у него нет понятия "граница корзины", он сам по себе уникален. Window5Min остаётся подстраховкой для случаев, когда токена нет.
В статье код упрощён до одного слоя fixed-bucket - чисто для иллюстрации идеи "ключ из бизнес-семантики + временное окно". В реале обычно используем 1 из расширенных вариантов.
...service worker с фронта дошлет запрос после истечения этого 5ти минутного окна если пропала связь.Тогда ведь ключ будет другой и операция повторится?
5-минутное окно ловит серию быстрых нажатий в одной сессии, для повторов через дни нужен другой слой:
Клиентский intent token - UUID генерится при клике, кладётся в localStorage или IndexedDB, оттуда же берёт service worker при background sync. Этот UUID едет в
Idempotency-Keyи хранится на сервере 30 дней. Закрывает повторы через дни.Серверный fingerprint с 5-min окном - подстраховка на случай, когда клиентского токена нет (типа прокси режет заголовок, новая сессия). Закрывает быстрые повторы внутри сессии.
Когда service worker дослал через 4 дня: запрос приходит с UUID из первого клика, сервер находит его в store и отдаёт кэш (или статус-ссылку из грабли 5). Window5Min тут уже не сработает, но клиентский токен закрывает вопрос.
В коде грабли 3 я привёл только серверный слой, чтобы не мешать два механизма в одну портянку. А в проде они работают вместе.
А что если, клиент бы отправлял какой-то ключ, который был хешом из его состояния оплат?
Оплаты изменились, например на другом устройстве сделал оплату, пока планшет отключен. Включил планшет - вторая бронь пришла.
А если сделать хеш из состояния, то конфликтов не будет
В целом, идея хорошая. Только это чуть другой механизм - optimistic concurrency (aka ETag / версия). Тут всё зависит от того, как использовать хеш:
если хеш это ключ для поиска дублей (сервер проверяет: "такой хеш уже был?") - не сработает: планшет был офлайн и не знал про оплату с другого устройства, поэтому его хеш посчитан по старому состоянию, а для сервера это новый хеш → запрос проходит → дубль
если хеш это ожидаемое состояние (клиент говорит "рассчитываю, что состояние = X", сервер сверяет с тем, что у него сейчас) - вот так работает: состояние не совпало → сервер отвечает 412 Precondition Failed → клиент перечитывает и показывает актуальную картину
Разница в том, что сравнивать должен сервер с тем состоянием, которое у него прямо сейчас. Сам по себе хеш от клиента ничего не гарантирует: он настолько же устаревший, насколько устарел сам клиент.
И ещё: "конфликтов не будет" - не совсем так. Конфликт всё равно случится, просто теперь сервер его поймает и отклонит, а не пропустит тихий дубль. В этом и смысл.
Получается 2 механизма не спорят, а дополняют друг друга: optimistic concurrency ловит запись по устаревшим данным, а idempotency key отсекает повтор той же операции.
Сервер проверяет хэш:
Совпал - значит запись новая
Не совпал - значит клиент не знает актуального состояния.
Если сервер атомарно пишет в базу, то получается что и дублей не будет. Потому что при дубле сервер сверит хэш, и увидит что хеш устарел.
Так что, мой предложенный вариант полностью покрывает идемпотентное общение между клиентом и сервером.
Если взять только 1 узкую задачу "не пустить дубль записи", тогда да, вы правы. При атомарной записи второй запрос с устаревшим хешем отклонится. Тут спора нет.
Но идемпотентность - это не только "не записать дубль", это ещё и "повтор возвращает тот же ответ, что был в первый раз". А по вашей схеме на повтор бэк возвращает не ответ, а конфликт "хеш устарел". И клиент не может понять, что это значит:
либо мой платёж прошёл, состояние сдвинулось, поэтому устарел (типа я заплатил)
либо состояние сдвинул кто-то другой, а мой платёж вообще не прошёл (типа не заплатил)
Получаем что ответ один и тот же - "устарел". Чтобы различить, клиент должен найти в состоянии именно свою операцию, а для этого нужен её идентификатор. Тот самый ключ, который вы убираете.
Плюс вопрос что считать "состоянием" для хеша:
весь аккаунт → любой соседний платёж меняет хеш и отклоняет вашу честную новую операцию (в активном аккаунте можно вообще не сойтись)
только бронь → хеш не несёт больше информации, чем bookingId, и мы возвращаемся к варианту "ключ = bookingId", который выше уже не закрыл повторный платёж после decline
Для простых случаев схема рабочая, не спорю. Но "полностью покрывает" она только пока цель простая - "не сделать дубль". Как только клиенту нужно узнать результат своей операции на повторе - без идентификатора операции это не получится.
Привет, спасибо за статью!
Остался вопрос, который не понимаю.
BookingId - это уникальный идентификатор корзины заказа, верно?
Если верно почему вообще возникает проблема идемпотентности? Почему ключа, состоящего из идентификатора корзины, суммы и валюты не достаточно?
Привет! Вопрос в точку, и мыслите вы правильно: ключ из бизнес-полей bookingId + сумма + валюта - это то, к чему статья в итоге и приходит. Разложу породбнее.
BookingId - это уникальный идентификатор корзины заказа... почему вообще возникает проблема идемпотентности?
Тут дело не в уникальности BookingId. Проблема возникает когда один и тот же запрос на оплату приходит на сервер несколько раз: сеть отвалилась и клиент повторил, пользователь дважды нажал на зависшую кнопку, service worker дослал из очереди. BookingId у всех этих запросов одинаковый - и как раз поэтому по нему можно дедуплицировать, тут вы правы. Загвоздка в том, что наивные реализации часто кладут в ключ НЕ bookingId, а UUID на каждый http-запрос или хеш с timestamp внутри (грабли 1 и 3) - тогда у повторов ключи разные и дедуп ломается. Половина статьи - это путь от таких ключей к стабильному бизнес-ключу, к которому вы интуитивно и пришли.
Почему ключа, состоящего из идентификатора корзины, суммы и валюты не достаточно?
Такой ключ слишком стабильный - он один и тот же навсегда, и поэтому не различает два разных случая:
повтор прямо сейчас (сеть, двойной клик) - надо вернуть тот же результат, не списывать второй раз
честная повторная оплата позже - первый платёж отклонил банк (перегружен, временный fraud-флаг и т.д.), через час пользователь пробует снова - та же бронь, та же сумма... и тот же ключ... но это уже новая легитимная операция, а не повтор старой
Если кэшировать результат с таким стабильным ключом, вторая оплата упрётся в закэшированный отказ первой - заплатить будет нельзя, пока ключ не истечёт. Поэтому в ключ добавляется временное окно: в пределах 5 минут это повтор (дедуплицируем), через час это новое намерение (обрабатываем). Окно и есть та деталь, которой не хватает в "просто bookingId + сумма + валюта".
Ответил? )

Idempotency keys: 5 граблей, которые мы поймали на проде