Владимир Синявский@vsinyavsky
AI-native Software Engineer
Информация
- В рейтинге
- 227-й
- Откуда
- Москва, Москва и Московская обл., Россия
- Дата рождения
- Зарегистрирован
- Активность
Специализация
Фулстек разработчик
Ведущий
От 5 500 $
Проектирование архитектуры приложений
Управление требованиями к ПО
Высоконагруженные системы
Управление разработкой
Системная интеграция
Управление людьми
Бизнес аналитика
.NET
Angular
Построение команды
Привет! Вопрос в точку, и мыслите вы правильно: ключ из бизнес-полей
bookingId + сумма + валюта- это то, к чему статья в итоге и приходит. Разложу породбнее.Тут дело не в уникальности BookingId. Проблема возникает когда один и тот же запрос на оплату приходит на сервер несколько раз: сеть отвалилась и клиент повторил, пользователь дважды нажал на зависшую кнопку, service worker дослал из очереди. BookingId у всех этих запросов одинаковый - и как раз поэтому по нему можно дедуплицировать, тут вы правы. Загвоздка в том, что наивные реализации часто кладут в ключ НЕ bookingId, а UUID на каждый http-запрос или хеш с timestamp внутри (грабли 1 и 3) - тогда у повторов ключи разные и дедуп ломается. Половина статьи - это путь от таких ключей к стабильному бизнес-ключу, к которому вы интуитивно и пришли.
Такой ключ слишком стабильный - он один и тот же навсегда, и поэтому не различает два разных случая:
повтор прямо сейчас (сеть, двойной клик) - надо вернуть тот же результат, не списывать второй раз
честная повторная оплата позже - первый платёж отклонил банк (перегружен, временный fraud-флаг и т.д.), через час пользователь пробует снова - та же бронь, та же сумма... и тот же ключ... но это уже новая легитимная операция, а не повтор старой
Если кэшировать результат с таким стабильным ключом, вторая оплата упрётся в закэшированный отказ первой - заплатить будет нельзя, пока ключ не истечёт. Поэтому в ключ добавляется временное окно: в пределах 5 минут это повтор (дедуплицируем), через час это новое намерение (обрабатываем). Окно и есть та деталь, которой не хватает в "просто bookingId + сумма + валюта".
Ответил? )
Если взять только 1 узкую задачу "не пустить дубль записи", тогда да, вы правы. При атомарной записи второй запрос с устаревшим хешем отклонится. Тут спора нет.
Но идемпотентность - это не только "не записать дубль", это ещё и "повтор возвращает тот же ответ, что был в первый раз". А по вашей схеме на повтор бэк возвращает не ответ, а конфликт "хеш устарел". И клиент не может понять, что это значит:
либо мой платёж прошёл, состояние сдвинулось, поэтому устарел (типа я заплатил)
либо состояние сдвинул кто-то другой, а мой платёж вообще не прошёл (типа не заплатил)
Получаем что ответ один и тот же - "устарел". Чтобы различить, клиент должен найти в состоянии именно свою операцию, а для этого нужен её идентификатор. Тот самый ключ, который вы убираете.
Плюс вопрос что считать "состоянием" для хеша:
весь аккаунт → любой соседний платёж меняет хеш и отклоняет вашу честную новую операцию (в активном аккаунте можно вообще не сойтись)
только бронь → хеш не несёт больше информации, чем bookingId, и мы возвращаемся к варианту "ключ = bookingId", который выше уже не закрыл повторный платёж после decline
Для простых случаев схема рабочая, не спорю. Но "полностью покрывает" она только пока цель простая - "не сделать дубль". Как только клиенту нужно узнать результат своей операции на повторе - без идентификатора операции это не получится.
В целом, идея хорошая. Только это чуть другой механизм - optimistic concurrency (aka ETag / версия). Тут всё зависит от того, как использовать хеш:
если хеш это ключ для поиска дублей (сервер проверяет: "такой хеш уже был?") - не сработает: планшет был офлайн и не знал про оплату с другого устройства, поэтому его хеш посчитан по старому состоянию, а для сервера это новый хеш → запрос проходит → дубль
если хеш это ожидаемое состояние (клиент говорит "рассчитываю, что состояние = X", сервер сверяет с тем, что у него сейчас) - вот так работает: состояние не совпало → сервер отвечает 412 Precondition Failed → клиент перечитывает и показывает актуальную картину
Разница в том, что сравнивать должен сервер с тем состоянием, которое у него прямо сейчас. Сам по себе хеш от клиента ничего не гарантирует: он настолько же устаревший, насколько устарел сам клиент.
И ещё: "конфликтов не будет" - не совсем так. Конфликт всё равно случится, просто теперь сервер его поймает и отклонит, а не пропустит тихий дубль. В этом и смысл.
Получается 2 механизма не спорят, а дополняют друг друга: optimistic concurrency ловит запись по устаревшим данным, а idempotency key отсекает повтор той же операции.
Так тоже можно, но это не упрощение, просто сложность переезжает в другие места:
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", это та же сложность, разнесённая по другим местам.
По Redis согласен - в грабле 2 как раз про это:
В финале мы так и сделали именно из-за асинхронной репликации - ack может пропасть на сбое мастера, и idempotency-store этого не прощает.
По bookingId - интуиция верная, и он действительно часть ключа в моём решении. Но только лишь bookingId не закрывает такие кейсы:
Soft decline + честный повтор через час. Первый платёж провалился (банк перегружен, fraud-флаг), пользователь пробует снова. Тот же bookingId → закэшированный fail → легитимная оплата заблокирована. Это уже не идемпотентность, а простой по чужой ошибке.
Изменение содержания операции. Бронь на 50к не прошла, пользователь добавил трансфер, стало 65к, bookingId тот же. По вашей схеме интенты слиплись - вернётся ответ от первого платежа. По бизнесу это разные операции с разной суммой.
Поэтому ключ собирается из
userId + bookingId + amount + currency + окно. Amount+currency ловят изменение содержания, окно отделяет "повтор сейчас" от "новая попытка через час".Про "если провайдер не наркоман" - это и есть грабля 4 - не все провайдеры поддерживают свой
Idempotency-Key, особенно региональные банковские шлюзы и старые интеграции с эквайерами. Когда у провайдера ничего нет - идемпотентность держится на своей стороне, а это уже не про "ретраить где угодно и сколько угодно".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 я привёл только серверный слой, чтобы не мешать два механизма в одну портянку. А в проде они работают вместе.
Спасибо, это вопрос, который я ждал! Намеренно не стал включать это в статью, чтобы излишне не раздуть её, и хотел вынести это в обсуждение. Window5Min это самая простая реализация, а вообще есть несколько разных подходов как зафиксировать intent в промежуток времени:
Window5Min() - округление вниз до начала 5-минутного интервала:
Пример:
Если запрос пришёл в 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-е считаем ключ и для текущей корзины, и для предыдущей - если попадание хоть в одной, это повтор. Слегка дороже по запросам, но граничный случай закрыт.
Вариант 2: sliding window. Не корзина, а timestamp последней попытки по
userId+bookingId+amount. Если новая попытка пришла в пределах 5 мин от сохранённого - это тот же intent. Точнее, но требует чтения "последней попытки" перед каждым запросом, и что важно, сама эта запись должна обновляться атомарно.Вариант 3 (и главный): клиентский intent token как основная линия. UUID привязан к клику, а не к настенным часам - у него нет понятия "граница корзины", он сам по себе уникален. Window5Min остаётся подстраховкой для случаев, когда токена нет.
В статье код упрощён до одного слоя fixed-bucket - чисто для иллюстрации идеи "ключ из бизнес-семантики + временное окно". В реале обычно используем 1 из расширенных вариантов.
Справедливое замечание, формулировка была плохая - paxos через партицию такого не пропустит. Я имел в виду немного другое - на этом легко споткнуться при переезде с реляционок:
цена: lwt-запись примерно в 4 раза медленнее обычной, тут 4 раунда обмена по сети вместо 1 - на популярных ключах клиенты начинают мешать друг другу в paxos
таймаут: если ответ не пришёл - операция могла пройти, могла не пройти, и клиент это не отличает - т.е. чтобы безопасно повторить, надо отдельно перечитать ключ с уровнем serial и посмотреть, что там по факту
и чтение сразу после записи: если читать обычным кворумом сразу после lwt-записи, можно ещё не увидеть только что записанное - paxos закоммитил, но до всех реплик не доехало, поэтому lookup в idempotency-store читается тоже с serial
LWT - инструмент рабочий, не "дно", просто переезд intent-key из postgres в cassandra не бесплатный
Пока я добрался ответить, тут в ветке половину ответа уже собрали - саги, компенсации и универсальность refund - всё в тему. Добавлю.
Refund действительно почти всегда есть, и как safety net работает, но...
Строить идемпотентность только на refund - дорогой компромис: клиент видит в выписке два списания и возврат, в поддержку летит "почему у меня два списания?", даже если по деньгам чётко сошлось. Поэтому в моей картине refund - не первичный механизм дедупа, а фоллбэк как компенсация после ежедневной сверки с провайдером (reconciliation). Основная задача - не допустить второго списания вообще.
Что тут помогает:
Single-flight на нашей стороне - между intent-key и провайдером всегда один in-flight вызов, параллельные ретраи блокируются.
На таймауте не повторяем вслепую - сначала пытаемся выяснить, прошла ли первая попытка: ищем по сумме+карте+окну, по нашему reference (если есть хоть какая-то возможность поискать). Только если убедились, что не прошла, тогда ретраим.
Между уходом запроса и доказанным результатом операция в статусе "в процессе подтверждения", не "оплачено". Ретраит фоновый job, не клиент.
Reconciliation в фоне - сверяем с выгрузкой провайдера - на расхождения auto-refund. Это уровень, где refund и срабатывает.
Чем толще верх (single-flight + lookup), тем реже срабатывает низ (auto-refund), тем чище и очевиднее для юзера.
Замечание про то, что статья этот пласт явно не раскрывает - справедливое. Слой компенсаций тянет на отдельный пост.
это работает пока повторы идут только от нажатий, а в статье речь также про повторы от service worker, rxjs retry и фоновых job на стороне сервера - атомарная транзакция в БД их не различает, нужен ключ выше неё - это и есть intent-key
Согласен, при росте начинается следующий круг - eventual consistency, cap, межрегиональные задержки. В статье намеренно ограничился одним кругом, чтобы не размазывать тему, но направление мне нравится 👌
К слову, подход с intent key неплохо ложится на eventually-consistent store именно потому, что строится на бизнес-семантике, а не на транзакционной блокировке. В Cassandra и ScyllaDB то же поведение делается через LWT (
IF NOT EXISTS) по intentKey и с теми же тремя статусами Reserved/InProgress/Completed. Цена этого в том что LWT медленнее обычного write на порядок, и при сетевой партиции возможен false-negative по уже зарезервированному ключу. То есть этот приём можно переиспользовать, но появляются свои новые грабли - вы правы, как минимум одна точно.По сетевым проблемам на объемах stripe - там retry-стратегия живёт уже отдельной жизнью с jittered backoff, hedged requests, региональным failover. Это тянет на полноценную объемную статью.
Спасибо, обе мысли по делу. Разверну подробнее...
Да, в моей формулировке идемпотентность держится на нашей стороне: провайдер гарантирует идемпотентность своих собственных методов, т.е.
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.Что было бы багом - ключ, который НЕ эволюционирует вместе с бизнесом. Тогда дедуп начинает срабатывать на семантически разные операции, и грабля прилетит уже с другой стороны.