Идемпотентность в System Design: полный пример
Идемпотентность часто упоминается при проектировании систем (system design). Ниже будет простыми словами объяснено, что это такое, далее мы разберём основные детали идемпотентности, часто понимаемые неверно и, наконец, проиллюстрируем её на полном примере.
Что такое идемпотентность?
Операция является идемпотентной, если при однократном или многократном выполнении она всякий раз даёт один и тот же результат.
Иными словами, если вы выполняете идемпотентную операцию один раз, то можете быть уверены, что и на 2-й, и на 3-й, и на 10-й раз она завершится точно так же, как и на 1-й. Рассмотрим стандартный пример: у нас есть кнопки «вкл». и «выкл». Нажимая её, мы выполняем идемпотентную операцию. Нажмёте её один раз — машина включится. Если после этого нажимать её снова и снова, то ничего не изменится. Машина останется во включённом состоянии. То же самое касается кнопки «выкл».
Рассмотрим пример из программирования:
def hide_my_button(self): self.show_my_button = False
Определённо, это идемпотентная операция.
def toggle_my_button_visibility(self): self.show_my_button = not self.show_my_button
А эта операция, конечно же, не идемпотентная.
Дело здесь совсем не в возвращаемом значении!
Как раз этого зачастую не понимают. Приведённую выше функцию скрытия можно реализовать и следующим образом:
def hide_my_button(self): has_something_changed = self.show_my_button self.show_my_button = False return has_something_changed
Таким образом, функция возвращается независимо от того, изменилось что-нибудь или нет. Если вызывать эту функцию несколько раз, то возвращаемое ею значение может меняться! Но она всё равно является идемпотентной, так как идемпотентность характеризует воздействие на «состояние» или «эффект» и напрямую не связана с тем кодом состояния, который получит клиент.
Идемпотентность vs Чистота
Хотя, чистота функции не является темой этой статьи, я всё равно хочу коротко её затронуть, поскольку в этом заключается ещё одна распространённая путаница.
Функция или операция является чистой, если, получая одинаковый ввод, она всегда результирует одинаковым выводом, и при этом не возникает никаких побочных эффектов.
def square(my_number): return my_number ** 2
Это чистая функция. square(3) всегда будет одним и тем же числом.
def square_with_randomness(my_number): return (my_number ** 2) * random.uniform(0, 1)
Это не чистая функция. Функцияsquare_with_randomness(3)почти всегда будет давать разные результаты, поскольку мы умножаем её на случайное число в диапазоне от 0 до 1. Аналогично, если мы будем умножать её на какую-то глобальную переменную или переменную класса, она больше не будет чистой. Глобальная переменная может измениться, в таком случае изменится и результат.
Идемпотентная функция не обязана быть чистой. Рассмотрим следующий простой пример.
def save_name(name): my_database.save(name) # побочный эффект return name
Идемпотентность в проектировании систем
Почему же эта концепция так важна при проектировании систем? На это есть много причин, и ниже мы обсудим основные из них.
Обработка сообщений
Допустим, мы проектируем нашу систему как событийно-ориентированную. В состав нашей системы входят очередь сообщений и сервис, потребляющий сообщения из этой очереди.

Проблема заключается в следующем. Когда Service B потребляет сообщение, скажем, содержащее Event 3, он его обрабатывает, а затем записывает результат обработки в нашу базу данных. Оставим пример максимально простым и предположим, что Service B вычисляет для каждого события некую сложную формулу, и именно результат этого вычисления попадает в базу данных. Теперь важно обеспечить, чтобы ничего из этого ни в коем случае не потерялось. Мы работаем с очень важными данными!
Но, если в процессе вычислений Service B откажет, либо если обнаружится фрагментация сети между Service B и базой данных, или если произойдёт что-то ещё, то и сообщение, и событие будут утрачены навсегда. Ужасно.
Проблема решается просто: мы не будем сразу же удалять сообщение из очереди, а дождёмся, пока Service B закончит работу, то есть, запишет результат в базу данных. Только после этого мы удалим сообщение.
Но здесь возникает и другая проблема. Может случиться, что одно и то же сообщение будет прочитано дважды. Например, Service B выполнил вычисление и записал результат в базу данных, а затем что-то произошло. Из-за этого сервис аварийно завершил работу. Отказ сервиса произошёл в момент, когда сообщение ещё оставалось в очереди. Что произойдёт дальше? Сервис перезапустится и, возобновив работу, он продолжит потреблять сообщения. Причём, возобновит работу он именно с того сообщения, на котором остановился. Таким образом, это сообщение окажется обработано дважды!
Как решается эта проблема? Напрямую — никак. Как известно, проектирование систем — это поле для компромиссов. Можно пойти либо на потерю сообщений, либо на двукратную обработку одних и тех же сообщений.
Но эта проблема не так серьёзна. Если мы спроектируем работу Service B как идемпотентную операцию, то ничего не случится. Сервис повторно потребит сообщение, но это не важно, поскольку результат будет точно таким, как и раньше.
Единственный недостаток заключается в некоторой дополнительной сложности (нужно найти способ, как сделать эту операцию идемпотентной) и в дополнительном расходе вычислительных ресурсов (потенциально нам потребуется делать что-то по несколько раз, а эти операции избыточны). Но, как правило, а в нашем случае — точно — это лучше, чем потерять сообщения!
Минимум однократная доставка + идемпотентность = строго однократная доставка
Что здесь наиболее важно: в таких очередях сообщений как AWS SQS предлагается семантика минимум однократной доставки. Это означает, что ваше сообщение может прибыть один раз, два раза или более. Но, если одновременно сделать такую операцию идемпотентной, то, фактически, получится семантика строго однократной доставки. Может быть, сообщение будет обработано многократно. Но, поскольку операция является идемпотентной, с точки зрения бизнес-логики результат получится ровно таким же, как и после строго однократной обработки. Это практичное и элегантное решение сложной проблемы, связанной с распределёнными системами.
Подводные камни
Здесь есть несколько аспектов, с которыми нужно проявлять осторожность. Во-первых, может возникнуть бесконечный цикл (катастрофическое восстановление после отказов). Если у нас есть “плохое” сообщение, например, записанное в нечитаемом формате, и именно из-за него отказывает сервис, потребляющий сообщения (Service B), то оно так и останется в очереди. Таким образом, Service B будет перезапускаться лишь для того, чтобы потребить то же самое плохое сообщение — и снова отказывать. И так снова и снова. Даже если в вашей системе пока нет таких проблем, рано или поздно они возникнут. Для устранения подобной проблемы используется так называемая «очередь недоставленных сообщений» (dead-letter queue), также называемая «госпиталь сообщений».
Как ещё можно использовать идемпотентность
Мы обсудили доставку сообщений, но идемпотентность встречается в проектировании систем повсюду. Давайте рассмотрим другие распространённые варианты её применения.
API
Если вы пишете REST API, то уже имеете дело с идемпотентностью, даже если не осознаёте этого. В протоколе HTTP прямо определено, какие методы должны быть идемпотентными.
Запросы GET ничего не меняют на сервере, поэтому, естественно, они идемпотентны. Их можно вызывать хоть 100 раз — результат всегда будет одинаков. Можно сколько угодно обновлять веб-страницу, не беспокоясь о том, что она может измениться.
Запросы PUT должны полностью заменять один ресурс на другой. Но, если PUT одни и те же данные дважды, то результат будет одинаков. Представьте, что вы перезаписываете файл — если эту операцию повторить, то ничего не изменится.
Запросы DELETE предназначены для удаления ресурса. А если удалить что-то, что уже удалено? Его просто больше нет. Никаких проблем.
Запросы POST обычно по определению не идемпотентны. При каждом запросе POST, как правило, что-то создаётся. Но их можно сделать идемпотентными при помощи специальных ключей. Вот как это работает: вместе с запросом вы посылаете уникальный ID (часто он записывается в заголовке), и сервер вспоминает: «этот ID я уже обрабатывал, так что просто верну уже имеющийся результат, а не буду выполнять работу повторно».
def create_user(request): idempotency_key = request.headers.get('Idempotency-Key') # Обрабатывали ли мы уже ранее именно этот запрос? if idempotency_key and already_processed(idempotency_key): return get_cached_response(idempotency_key) # Нет, создаём пользователя user = User.create(request.data) # Кэшируем отклик на будущее if idempotency_key: cache_response(idempotency_key, user) return user
Базы данных
Операции над базами данных также зачастую бывают идемпотентными. Вот наиболее распространённые паттерны:
Операции UPSERT (INSERT или UPDATE при наличии) естественно идемпотентны. Можно 10 раз выполнить upsert над одними и теми же данными – и всякий раз результат будет одинаков. Запись в базе данных либо создаётся один раз, либо сколько угодно раз обновляется одними и теми же значениями.
Распределённые системы
В распределённых системах что-то постоянно отказывает. Сети фрагментируются, сервисы аварийно завершаются, жёсткие диски умирают и, да, кот может прогуляться по клавиатуре — бывает и так. Поэтому многие операции то и дело выполняются с повторной попытки. Но повторные попытки безопасны лишь при условии, что сами операции идемпотентны.
Полный пример: система обработки заказов
Хорошо, теперь давайте соберём всё разобранное здесь в конкретный пример, который соответствует системе, которая здесь схематически изображена. Имеется простой конвейер обработки заказов: заказы поступают из веб-приложения, проверяются сервисом заказа, поступают в очередь, а затем обслуживаются сервисом обработки заказов, который записывает информацию в базу данных.
Что мы создаём
Система устроена просто:
-
Веб-приложение отправляет запросы HTTP POST, в которых содержатся данные о заказе
-
Шлюз API обрабатывает маршрутизацпю, аутентификацию и ограничивает частоту передачи данных
-
Сервис заказа проверяет заказы и публикует их в очереди
-
Сообщения о заказах содержатся в Amazon SQS
-
Сервис обработки заказов потребляет сообщения и записывает информацию в базу данных
-
В базе данных с заказами содержится вся информация о наших заказах
-
В очередь недоставленных сообщений отправляются все те сообщения, которые можно считать ядовитыми
-
Сервис уведомлений отправляет нашим клиентам подтверждения
Ключевой момент здесь заключается в том, что сервис обработки заказов должен быть идемпотентным.
Как сделать сервис обработки заказов идемпотентным
Сервис обработки заказов потребляет сообщения, поступающие от SQS, и выполняет конкретную бизнес-логику.
При обработке сообщений, то есть, когда происходит заказ, нам требуется:
-
Проверить, был ли уже обработан этот заказ
-
Если нет — вставить его в нашу OrdersDB
-
Если нет – приказать NotificationService, чтобы он отправил уведомление
Эта операция идемпотентна, поскольку мы проверяем, был ли уже обработан заказ. Для этого можно, например, выполнить запрос SELECT в OrderDB и вставлять данные лишь в случае, если их там пока нет. Схожую операцию можно выполнять для NotificationService или внутри NotificationService, работая с его собственной базой данных.
Правда, здесь будут возникать проблемы, связанные с конкурентностью, и с ними придётся справляться. Что, если у нас есть два разных экземпляра OrderProcessService, обрабатывающих одно и то же сообщение? И они оба одновременно выполняют SELECT. Тогда мы обработали бы сообщение дважды, а это нехорошо. Поэтому стоит обернуть данную логику в транзакцию.
В итоге у нас должно получиться нечто подобное:

Ещё одно замечание: следует попытаться обеспечить в системе полноценную отказоустойчивость, и для этого мы также поставим очередь между OrderProcessService и NotificationService, а затем сделаем так же, как и выше.
У идемпотентности есть область действия
Этот момент также часто остаётся недопонятым: иногда идемпотентность понимают так, будто при многократном выполнении одной и той же идемпотентной операции совсем ничего не меняется. То есть, что последующие вызовы никак не подействуют на систему.
Это не совсем верно. Идемпотентность касается только операций бизнес-логики, а не состояния всей системы в целом.
Допустим, у нас имеется идемпотентная операция обработки платежей. Если вызвать её трижды с одними и теми же реквизитами платежа, то в базе данных должна быть создана только одна транзакция. Это операция бизнес-логики, и она идемпотентна. Но это не означает, что при втором и третьем вызове ничего не происходит. Происходит следующее:
-
Мы продолжаем логировать информацию о вызовах в журнале операций
-
Мы как и раньше записываем время отклика и собираем мониторинговые метрики
-
Мы как и раньше валидируем запрос
-
Мы проверяем, обрабатывали ли мы этот заказ ранее
Всё это может (и должно) происходить. Ключевой момент здесь таков: идемпотентной является базовая бизнес-операция, а не всё состояние системы. Абсолютно необходимо логировать тот факт, что вызов был получен. Необходимо отслеживать связанные с ним метрики. Это побочные эффекты, которые не противоречат идемпотентности.
ссылка на оригинал статьи https://habr.com/ru/articles/1034544/