Всем привет! Меня зовут Наташа, и я системный аналитик. Сейчас я в поиске работы, сходила на пару собеседований, и хочу описать ответы на некоторые вопросы, которые там встречались — некая рефлексия для меня, и надеюсь, эти короткие статьи будут полезны и еще кому‑то.
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

Шаги диаграммы: 

  1. Пользователь нажал Перевести

  2. Web-интерфейс генерит уникальный ключ, и передает его в параметрах запроса перевода денег

  3. Запрос поступает в балансировщик - и тот передает его на выполнение на Ноду1.

  4. Выполняется проверка в кэше, новый ли это запрос - в этом случае в кэше не будет информации о запросе с таким ключом идемпотентности

  5. Если запрос новый - сохраняем в кэше информацию о нем, с состоянием "в обработке"

  6. Подтверждается необходимость выполнения запроса и...

  7. Нода1 начинает обрабатывать запрос

  8. Предположим, что в какой-то момент, позже шага 2, пользователь нетерпеливо нажал кнопку Перевести еще раз.

  9. Так как параметры запроса не изменились, в параметрах запроса передается тот же самый уникальный ключ

  10. Новый запрос поступает в балансировщик - и тот передает его на выполнение на Ноду2, которая "не знает", что этот же запрос уже выполняется на Ноде1.

  11. Нода2 по ключу ищет в кэше, есть ли там информация о запросе с таким ключом, и находит запись о том, что такой запрос в обработке

  12. Redis передает ответ на Ноду2 и

  13. Нода2 передает на web-интерфейс ответ, что запрос в работе.

  14. Нода2 ждет

  15. В это время, Нода1 закончила обработку запроса, и записала в кэш результат запроса

  16. а также передала ответ на интерфейс

  17. Просыпается Нода2 - идет в кэш, чтобы проверить, появился ли ответ

  18. Ответ появился, и Нода передает тот же самый ответ на интерфейс

И таким образом, в течение жизни ключа на Redis, запрос не будет выполняться повторно.
Всем успехов! :-)