Обновить

Eventual Consistency: как мы починили тормоза апрува и сломали бюджет

Уровень сложностиСложный
Время на прочтение14 мин
Охват и читатели6.1K
Всего голосов 2: ↑2 и ↓0+3
Комментарии8

Комментарии 8

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

Так вот как раз reserve/confirm в посте и реализован: TryReserve - бронь, проверка на необратимом шаге, конфликт только на одной строке бюджета, без глобальной очереди. С этим спора нет. И да, через сервисы это ровно сага, как вы и описываете.

А вся соль в том, где 2 фазы разъезжаются:

  1. Одобрение - мягкий guess, а жёсткая проверка лимита позже, в момент брони. Внутри одной БД это одно согласованное состояние. Здесь между "одобрил" и "забронировал" - лаг, и фаза 2 законно отклоняет то, что фаза 1 одобрила. Откатом это не закрыть - нужен спроектированный apology, т.к. человек уже считает поездку согласованной.

  2. Само подтверждение/откат уходит за границу сервиса - charge в платежном шлюзе, билет у поставщика. Там каждый компенсирующий шаг должен быть идемпотентным, иначе на ретрае получишь двойной возврат. Вот этот проход "через сервисы" - и есть тема, ради которой пост.

Внутри одной БД здравого смысла хватает, тут вы правы. Пост про то что начинается, когда фаза 2 находится в чужой системе.

Так а какая разница? Человек создал заявку в системе -> минуснуло глобальный бюджет сразу. Все остальные видят уменьшенный бюджет. Внешняя система отказала в бронировании -> делаем роллбек и возвращаем бюджет на место

Вот теперь по делу. В happy path разницы нет - вы описали сагу, и в посте она же. А вот вся соль в строчке "делаем роллбек".

Этот "роллбек" - не локальный откат транзакции, а отдельное действие через границу сервиса. И оно само может выполниться дважды если: сеть оборвалась, воркер перезапустился, таймаут сработал не вовремя и т.п.. Возврат средств, отмена брони, откат резерва - каждый шаг может повториться, и если он не идемпотентен, вы получаете двойной возврат вместо одного. Это ровно вопрос 3 в посте: без идемпотентности по намерению "вернём бюджет на место" возвращает его дважды.

Есть ещё 1 слой глубже, который я в посте сознательно оставил за скобками: что делать, когда внешняя система не "отказала", а молчит (таймаут, сеть отпала). В этот момент вы не знаете, прошло бронирование или нет, и сам таймаут этого не скажет. Тогда откатывать вслепую нельзя - нужно сверяться с провайдером по вашему же ключу запроса и до результата сверки резерв откатывать нельзя. Это уже не "отказал → откат", а отдельный асинхронный процесс, и компенсация должна быть идемпотентной по тому же ключу.

Шаблон вы назвали верный. Дьявол - в деталях: "а что если откат выполнился дважды?", "а что если ответа вообще нет?". Ровно ради этого пост.

Не знаю, какие-то выдуманные проблемы, приправленные терминологией.

Это ровно вопрос 3 в посте: без идемпотентности по намерению "вернём бюджет на место"

Если в основной БД нет объекта "блокировка ресурса" (не важно, как строка, лог, поле, или ещё что-то), который можно удалить ровно один раз, получив идемпотентность, то это проблемы проэктировки доменной модели. Наворачивать поверх какие-то гессы и аполоджайсы - масштабирование корпоративного буллшита.

"а что если откат выполнился дважды?", "а что если ответа вообще нет?".

То, как ваш продакт овнер скажет, так и будет система себя вести в плане статусов/нотификаций/ретраев. Но сути это не меняет. Есть заявка на ресурс - ресурс блокируется. Пока заявка не пришла к терминальному состоянию, ресурс не могут блокировать другие.

Вы сами в прошлом комменте провели границу, о которой я и говорю. "Минуснуло бюджет" и "внешняя система отказала в бронировании" - это два разных мира, и с первым у нас полное согласие. Бюджет лежит в вашей БД, его блокировку правда можно снять ровно один раз, и идемпотентность достаётся даром: DELETE по своей природе идемпотентен. В посте это обычная локальная транзакция, тут спорить не о чем.

Весь вопрос во втором - во взаимодействии со внешней системой. Вы за "удалить ровно один раз", и я ровно за то же. Только "ровно один раз" в своей БД и "ровно один раз" в чужом сервисе - это очень разные цены. Внутри - даром. А с бронью у поставщика так не выйдет: вы отправили запрос, а он мог дойти, мог потеряться, и ответ может не прийти вовсе. Шлёте повтор на всякий случай - и рискуете забронировать дважды. Чтобы этого не произошло, нужен ключ, по которому поставщик узнает тот же запрос, и аккуратная обработка случая "а прошло ли оно вообще". Вот это в посте и названо гессом и аполоджайсом - то, что вы называете "корпоративным буллшитом". Без идемпотентности через границу системы "ровно один раз" не получить. Выходит забавно - вы его хотите, а механизм, который его там и даёт, записали в буллшит.

Термины из статьи раздражают, давайте заменим: "гесс" - это статус PENDING, "аполоджайс" - компенсация, ок? Когда ресурс находится в чужой системе, ваша же "блокировка ресурса" ровно в это и превращается.

И поэтому "ретраи - как скажет PO" не складывается. Политику PO действительно задаёт: сколько раз повторить, что показать пользователю. Но сделать сам повтор безопасным - это тот же ключ и та же обработка неизвестного ответа, то есть инженерия, а не продукт. Сеть-то не перестанет рваться от того, что так решил PO 🤷‍♂️

Выглядит слишком сложно для сотни пользователей, saga какая-то появилась. Но это не отменяет того, что все такие операции (связанные с распределением физических благ - деньги и товар) всегда в итоге идут в один поток с блокировками.

Да, можно параллельно отработать со snapshota-ми, но потом они все равно все сойдутся в одной точке, с нормальной полноценной транзакционной блокировкой. В 99% случаев все будет хорошо, но вот на краю в 1% кому-то не достанется.

Так как во время обработки возможны проблемы с сетью, с подключением к БД, деадлоками - нужна retry policy. А это означает - очередь, с асинхронной обработкой, и три статуса для пользователя на UI - In processing | Approved | Failed (после допустим 5 попыток).

Тут мы в основном согласны, а местами сильнее, чем может показаться.

Выглядит слишком сложно для сотни пользователей, saga какая-то появилась.

Cага в посте не из-за числа пользователей. Внутренняя часть - резерв бюджета - это одна локальная транзакция, без саги: TryReserve плюс один SaveChanges выполняются атомарно. Сага начинается там, где операция выходит за вашу БД - charge в платёжном шлюзе, билет у поставщика. Это сложность от распределённости, не от нагрузки: и на сотне юзеров, и на одном бронировании через внешнего провайдера, который отвалился на полпути, нужна компенсация 💁‍♂️

все сойдутся в одной точке, с нормальной полноценной транзакционной блокировкой.

Точка синхронизации одна, но это одна строка бюджета, а не один поток. В посте делаем оптимистично по row_version: апрувы разных отделов не ждут друг друга, а конфликт на одной строке в одну секунду редок и добивается повтором, не глобальной очередью. Высокая конкуренция на один бюджет - это уже горячая точка домена, лечится не способом блокировки.

А retry policy → очередь → async → InProcessing | Approved | Failed - это не альтернатива посту, это дословно вопрос 3 в посте: "обновляется..." на UI, ограниченный повтор шиной команд, тикет оператору после N попыток. И "на краю 1% кому-то не достанется" - оттуда же: guess иногда проигрывает, потому apology и проектируется заранее.

Вывод у вас верный. Только это не упрощение саги, а ровно те детали реализации, которые сага и описывает.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации