Всем привет! Меня зовут Наташа, и я системный аналитик. Сейчас я в поиске работы, сходила на пару собеседований, и хочу описать ответы на некоторые вопросы, которые там встречались — некая рефлексия для меня, и надеюсь, эти короткие статьи будут полезны и еще кому‑то.
Upd Добавила код для диаграмм для PlantUML
Итак, кейс:
Пользователь банка хотел перевести 100р другу, а потом нажал на кнопку «Перевести» нечаянно пять раз — как предотвратить отпра вку 5 раз по 100р?
На собеседовании я почему-то не дала очевидный ответ с синхронным взаимодействием и блокировкой кнопки, сразу пошла рассуждать об асинхронной версии.
Здесь опишу оба варианта, буду использовать для этого диаграммы последовательности - Sequence diagram.
Синхронный вариант
При этом варианте мы в момент отправки запроса блокируем кнопку на интерфейсе и ждем ответ.

Код диаграммы для PlantUML
@startuml
!theme mars
actor User
participant "Web UI" as WebUI
participant "Банковская система" as BankSys
User -> WebUI++: 1. Нажал кнопку Перевести
WebUI -> BankSys++: 2. Web-интерфейс передает запрос в систему, \nкнопка Перевести блокируется
alt успешно
BankSys --> WebUI: 3. Результат - запрос выполнен успешно. \nКнопка Перевести разблокирована.
else любая ошибка
BankSys --> WebUI--: 3а. Результат - ошибка. \nКнопка Перевести разблокирована
end
WebUI--
@enduml
Шаги диаграммы:
1. Пользователь нажимает кнопку Перевести
2. Кнопка становится неактивной
Запрос уходит на сервер, интерфейс ждёт ответ
3. Сервер ответил, запрос успешно выполнен, - кнопка снова активна
3а. Если произошла ошибка при выполнении запроса, либо запрос не был выполнен - кнопка снова активна, можно повторить попытку перевода
Таким образом, у пользователя нет возможности ошибочно нажать кнопку дважды.
Разумеется, если он откроет еще одну такую же вкладку в браузере, кнопка будет там активна, и он сможет выполнить перевод еще раз, но как по мне, вряд ли это можно считать случайной ошибкой.
Асинхронный вариант с использованием ключа идемпотентности - стандарт для применения
Наша система, которую пользователь использует для перевода средств, сложная и высоконагруженная, и запросы пользователей в ней обрабатывают несколько экземпляров микросервиса (ноды). Конкретный экземпляр, куда будет передан запрос, выбирает Балансировщик. Также в системе должен быть Redis для сохранения статусов запросов и кэширования данных.
В тот момент, когда пользователь нажимает кнопку Перевести, Web-интерфейс генерит уникальный идентификатор запроса - идемпотентный ключ, и добавляет его в заголовок HTTP-запроса. Время жизни ключа (TTL) обычно составляет один час.
Если пользователь нажимает кнопку повторно, и запрос с таким ключом уже в процессе обработки - система будет ждать завершения первого запроса, и, когда дождется, вернет тот же результат, что и для первого запроса.
Подробно шаги процесса отражены на диаграмме. Синим цветом отмечены шаги, которые выполняются при повторном нажатии кнопки Перевести.

Код диаграммы для PlantUML
@startuml
!theme mars
actor User
participant "Web UI" as WebUI
participant Балансировщик
participant Нода1
participant Нода2
participant Redis
User -> WebUI++: 1. Нажал кнопку Перевести
WebUI -> Балансировщик++: 2. Web-интерфейс генерит уникальный ключ, \nи передает его вместе с запросом Перевести деньги
Балансировщик -> Нода1--++: 3. Запрос на перевод денег
Нода1 -> Redis++: 4. Проверяем по уникальному ключу, \nвыполнялся ли этот запрос
Redis -> Redis: 5. Устанавливаем значение \n"в обработке" \nдля данного ключа
Redis --> Нода1: 6. Запрос взят в работу
Нода1 -> Нода1: 7. Выполняется запрос
User [#blue]-> WebUI: 8. Нажал кнопку Перевести еще раз
WebUI [#blue]-> Балансировщик++: 9. Web-интерфейс передает тот же самый уникальный ключ \nвместе с запросом Перевести деньги
Балансировщик [#blue]-> Нода2--++: 10. Запрос на перевод денег
Нода2 [#blue]-> Redis: 11. Проверяем по уникальному ключу, \nвыполнялся ли этот запрос
Redis [#blue]--> Нода2: 12. Нашли статус "в обработке" для данного ключа. \nЗапрос не выполняем, ждем
Нода2 [#blue]--> WebUI: 13. Ответ "202 запрос в работе"
Нода2 [#blue]-> Нода2: 14. Таймаут
Нода1 -> Redis: 15. Запрос выполнен, записываем в кэш результат выполнения
Нода1 --> WebUI--: 16. Результат запроса
Нода2 [#blue]-> Redis: 17. Проверяем по уникальному ключу, есть ли \nрезультат запроса
Redis [#blue]-> Нода2--: 18. Результат есть - возвращаем его
Нода2 [#blue]--> WebUI--: 19. Ответ на второе нажатие - тот же самый
WebUI--
@enduml
Шаги диаграммы:
Пользователь нажал Перевести
Web-интерфейс генерит уникальный ключ, и передает его в параметрах запроса перевода денег
Запрос поступает в балансировщик - и тот передает его на выполнение на Ноду1.
Выполняется проверка в кэше, новый ли это запрос - в этом случае в кэше не будет информации о запросе с таким ключом идемпотентности
Если запрос новый - сохраняем в кэше информацию о нем, с состоянием "в обработке"
Подтверждается необходимость выполнения запроса и...
Нода1 начинает обрабатывать запрос
Предположим, что в какой-то момент, позже шага 2, пользователь нетерпеливо нажал кнопку Перевести еще раз.
Так как параметры запроса не изменились, в параметрах запроса передается тот же самый уникальный ключ
Новый запрос поступает в балансировщик - и тот передает его на выполнение на Ноду2, которая "не знает", что этот же запрос уже выполняется на Ноде1.
Нода2 по ключу ищет в кэше, есть ли там информация о запросе с таким ключом, и находит запись о том, что такой запрос в обработке
Redis передает ответ на Ноду2 и
Нода2 передает на web-интерфейс ответ, что запрос в работе.
Нода2 ждет
В это время, Нода1 закончила обработку запроса, и записала в кэш результат запроса
а также передала ответ на интерфейс
Просыпается Нода2 - идет в кэш, чтобы проверить, появился ли ответ
Ответ появился, и Нода передает тот же самый ответ на интерфейс
И таким образом, в течение жизни ключа на Redis, запрос не будет выполняться повторно.
Всем успехов! :-)