Игнорирование идемпотентности в распределённых системах приводит к серьёзным проблемам. Рассмотрим вымышленный пример из практики разработки приложения для заказа такси.
Разработка приложения и первые проблемы
Разработан API с двумя конечными точками: POST /orders для создания заказа и GET /orders для получения списка активных заказов. Мобильное приложение функционировало следующим образом:
- При запуске вызывалось GET /orders для отображения активных заказов.
- При нажатии кнопки «Заказать такси» вызывалось POST /orders с данными пользователя.
- При ошибках отображалось сообщение об ошибке.
После запуска приложения поступили жалобы на двойной заказ такси и списание денег за две машины.
Проявление проблемы и первое решение
Выяснилось, что пользователи отправляли два одинаковых запроса с небольшой временной разницей. Замедление работы базы данных (запросы выполнялись за секунды вместо миллисекунд) указывало на проблему. Первым решением стала блокировка кнопки «Заказать такси» после отправки запроса. Однако, проблема полностью не исчезла.
Недостаточность блокировки кнопки
Жалобы продолжались. Оказалось, что блокировки кнопки недостаточно в условиях ненадежного соединения. Запрос, отправленный при плохом интернет-соединении, мог не получить ответа, приложение показывало ошибку и разблокировало кнопку, а запрос на сервере тем временем выполнялся успешно.
Решение на стороне сервера и проблема гонок
Была добавлена проверка на стороне сервера: перед созданием заказа выполняется выборка из базы данных заказов пользователя с теми же параметрами за последние пять минут. Если такой заказ найден, сервер возвращает ошибку 500. Однако, при параллельном запуске тестов возникла проблема гонок между операциями SELECT и INSERT.
Идемпотентность и решение проблемы дублей
Понимание концепции идемпотентности стало ключевым. Идемпотентный метод API — метод, повторный вызов которого не меняет состояние системы. Результаты могут различаться (например, код ответа 200 или 400), но состояние системы останется неизменным. Методы GET, PUT и DELETE обычно считаются идемпотентными, а POST и PATCH — нет.
Решение заключалось в использовании idempotency-key — уникального ключа, генерируемого на стороне клиента и передаваемого в запросе. Сервер проверял уникальность ключа. Если заказ с таким ключом уже существовал, сервер возвращал ошибку 409 (позже код ошибки был изменен на 200 для упрощения обработки на стороне клиента).
Дополнительные проблемы и их решения
Даже с реализованной идемпотентностью возникли новые проблемы:
- Изменение параметров заказа: заказ создавался со старыми параметрами, если пользователь менял точку назначения между повторными запросами. Решение: запретить изменение параметров после отправки первого запроса.
- Длительное создание заказа: длинная операция создания заказа приводила к дубликатам из-за повторных запросов пользователя.
- Отмена заказа: проблема возникла из-за простого удаления записи из базы данных при отмене заказа. Решение: переход к «мягкому» удалению (софт-делету), устанавливая флаг deleted.
Версионирование списка заказов
Для решения проблем, связанных с длительным созданием и отменой заказа, был введён механизм версионирования списка заказов. Приложение передаёт серверу известную ему версию списка заказов. Сервер сравнивает её с текущей версией и возвращает ошибку, если версии не совпадают.
Идемпотентность и другие операции
Идемпотентность необходима не только для создания, но и для удаления и изменения ресурсов. Для удаления заказов реализован софт-делет, код ответа изменён на 410 вместо 404. Для изменения точки назначения (PATCH /order) проблема гонок требовала отдельного подхода, не решаемого только идемпотентностью.
Идемпотентность и счётчик завершённых заказов
Проблема возникла и со счётчиком завершённых заказов пользователя. Повторные вызовы API могли увеличивать счётчик более чем на 1. Решение: перенос подсчёта счётчика в фоновую задачу.
Проблема дублей SMS
Проблема с дублированием SMS была решена изменением логики пометки задачи как выполненной.
Этот пример показывает важность идемпотентности API, особенно в больших системах. Необходимо учитывать множество граничных случаев и тестировать систему на устойчивость к сетевым ошибкам и отказам оборудования. Хорошо спроектированная система должна корректно обрабатывать такие ошибки.