Что такое идемпотентность и как ее правильно использовать

Идемпотентность — это тема, которая может быть как простой, так и сложной. Здесь мы начинаем с простого с тривиального варианта использования в приложении с одним состоянием. Затем мы углубимся, чтобы обсудить самый сложный вариант использования для построения высоконадежной системы с несколькими состояниями.

Что такое идемпотентность

Начнем с Википедии:

"Идемпотентность – это свойство определенных операций в математике и информатике, благодаря которому их можно применять несколько раз без изменения результата после первоначального применения".

Но что на самом деле означает выражение «не изменить результат»? Я вижу два способа, как мы это интерпретируем:

  1. Сколько бы раз я ни делал один и тот же запрос, я всегда получаю один и тот же ответ.
  2. Сколько бы раз я ни делал один и тот же запрос, я не изменю состояние системы.

Эти два утверждения звучат одинаково, и оба кажутся правильными на первый взгляд. Однако более подходящим является второе утверждение. Мы обсудим это далее в следующих разделах.

Идемпотентность в системе с одним состоянием

Давайте рассмотрим очень простую систему с одной запущенной службой поверх одной базы данных.

База данных отслеживает учетные записи пользователей. Каждая учетная запись просто имеет два свойства: UserID и Name.

И давайте предположим, что сервис предоставляет четыре основные операции CRUD (СОЗДАНИЕ, ЧТЕНИЕ, ОБНОВЛЕНИЕ и УДАЛЕНИЕ).

Эти API определяются следующим образом:

createUser(userId, name)
readUser(userId) -> User
updateUser(userId, name)
deleteUser(userId)

Возьмем в качестве примера операцию READ. Принято считать, что READ является идемпотентным.

Однако, если мы сделаем запрос READ и столкнемся с сетевой ошибкой (т. е. тайм-аутом) и повторим попытку, мы не обязательно получим тот же ответ, что и первоначальный запрос. Почему?

Потому что какой-то другой участник системы мог изменить состояние системы. Например, updateUser мог быть вызван между первым и вторым READ.

Для операции CREATE, если мы получаем сетевую ошибку во время createUser запроса, то состояние системы становится недетерминированным, как у кота Шредингера.

Если запись не была создана, повторная попытка вызовет обычную вставку в базу данных.

Если запись уже создана, тогда все становится интереснее. Что мы должны ответить клиенту? Рассмотрим следующие два варианта:

  1. Error: Your record {userId: 'yuchen123', name: 'Yuchen'} already exist
  2. Success: Your record {userId: 'yuchen123', name: 'Yuchen'} is created

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

Однако, с другой стороны, мы также можем утверждать, что другой ответ обеспечивает большую прозрачность, давая понять, что предыдущий запрос был успешным. Давайте предположим, что сейчас мы выберем вариант один.

Случай с двумя клиентами

Если у нас есть два клиента, и оба они делают один и тот же запрос на создание пользователя:

createUser(userId: 'yuchen123', name: 'Yuchen')

Предполагая, что сеть нестабильна, оба запроса от клиента A и клиента B успешно достигли сервера. Однако ни один из них не получает ответа.

Чуть позже оба клиента повторяют попытку и оба получают сообщение об ошибке:

Error: Your record {userId: 'yuchen123', name: 'Yuchen'} already exist

Интересно, что с текущей настройкой мы не можем узнать, какой из двух первоначальных запросов клиента был успешным.

Одним из распространенных подходов является введение idempotentKey. Его также иногда называют idempotentToken, requestId, nonceToken и т. д.

Теперь давайте изменим запрос на создание следующим образом:

createUser(userId, name, idempotentKey)

И запросы от Клиента А и Клиента Б теперь могут быть разными.

От клиента А:

createUser(
  userId: 'yuchen123',
  name: 'Yuchen',
  idempotentKey: 'a2906959'
)

От клиента Б:

createUser(
  userId: 'yuchen123',
  name: 'Yuchen',
  idempotentKey: 'b54ed6d9'
)

Со стороны сервера при вставке этой записи в базу данных мы также включаем новый столбец idempotentKey.

Когда клиент повторяет попытку, он будет использовать тот же idempotentKey из своего предыдущего запроса. Благодаря этому мы можем определить, какой клиентский запрос был успешным раньше.

Например, если мы видим в базе данных запись с idempotentKey равным a2906959, мы можем вернуть двум клиентам следующее.

Ответ клиенту А: Success: Your record {userId: 'yuchen123', name: 'Yuchen'} is created.

Ответ клиенту Б: Error: Your record {userId: 'yuchen123', name: 'Yuchen'} already exist.

Обратите внимание, что мы используем короткие строки в качестве idempotentKey. На практике они могут быть любыми, если они уникальны.

Дилемма между «не более одного раза» и «не более одного раза»

Крупномасштабная распределенная система ненадежна и непредсказуема по своей природе. Сервер может рухнуть в любой момент времени.

Некоторое время назад я описал систему заказа кофе в блоге Дизайн системы с точки зрения непрофессионала. Там же я написал:

… кассиры записывают транзакции в бухгалтерскую книгу одновременно с тем, как прикрепляют стикеры к доске объявлений.

Но я не объяснил, как они могут делать это одновременно. Что произойдет, если кассир запишет заказ на стикере для бариста, отвлечется на телефонный звонок, а потом забудет записать транзакцию в бухгалтерскую книгу?

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

  1. сначала подтвердите, что мы получили сообщение, а затем обработайте его
  2. сначала обработайте сообщение и подтвердите сообщение только после завершения обработки

Выбор между (1) и (2) приведет к доставке не более одного раза и не более одного раза соответственно.

Одним из решений является протокол атомарной фиксации, который подробно обсуждается в книге Мартина Клеппманна «Проектирование приложений с интенсивным использованием данных: большие идеи, лежащие в основе надежных, масштабируемых и обслуживаемых систем»:

«Если происходит сбой доставки сообщения или транзакции базы данных, обе операции прерываются, поэтому брокер сообщений может безопасно повторно доставить сообщение позже. Таким образом, атомарно фиксируя сообщение и побочные эффекты его обработки, мы можем гарантировать, что сообщение будет эффективно обработано ровно один раз, даже если для его успешной обработки потребовалось несколько повторных попыток. Прерывание отменяет любые побочные эффекты частично завершенной транзакции».

Хотя на бумаге это звучит элегантно, на практике добиться этого чрезвычайно сложно. В следующем разделе мы рассмотрим другой способ решения этой проблемы — с помощью идемпотентности.

Идемпотентность в системе с несколькими состояниями

Сложная система может состоять из многих компонентов. Но концептуально их можно разделить на две категории:

  • состояния
  • Вычисления

Вычисление — это то, что изменит состояние объекта. Это небольшая единица исполняемой функции, которую также часто называют шагом или задачей.

Если ориентироваться на государства, то такую ​​сложную систему иногда называют государственной машиной. Если мы сосредоточимся на вычислениях, это иногда называют рабочим процессом. Академически это также известно как шаблон проектирования саги.

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

Способ включения идемпотента ничем не отличается от того, что мы обсуждали выше в системе с одним состоянием — путем добавления idempotentKey к запросу. На стороне сервера мы помещаем запрос в базу данных и немедленно отвечаем клиенту подтверждением получения запроса. Любые последующие повторные запросы будут игнорироваться, поскольку запрос с этим idempotentKey уже присутствует в нашей системе.

Важно отметить, что эта система является асинхронной. У нас есть отдельный воркер, который будет выполнять запрос.

Такая система становится очень надежной. Рабочий может вылететь в любой момент, и это было бы нормально.

Например, в приложении для электронной коммерции происходит сбой рабочего процесса при обработке кредитной карты пользователя. Мы должны изменить состояние транзакции с «Ожидание списания» на «Списано». Поскольку работник умирает, мы не знаем, успешно ли мы изменили кредитную карту пользователя. Что мы делаем? Мы просто пытаемся снова снять деньги с кредитной карты пользователя после перезапуска воркера. Мы уверены, что не будем вычитать сумму дважды, пока запрос идемпотентный.

С точки зрения клиента, он может опросить результаты. В качестве альтернативы он также может полагаться на некоторые отдельные системы для получения уведомлений после выполнения запроса (например, электронная почта, push-уведомления, распределенные очереди сообщений и т. д.).

Короче говоря, в сложных системах с несколькими состояниями и вычислениями трюк, чтобы сделать их очень надежными, состоит в том, чтобы сделать запросы идемпотентными.

Где важна идемпотентность

С таким огромным преимуществом в надежности, почему бы нам не сделать все запросы идемпотентными?

Некоторые запросы, очевидно, могут выиграть от идемпотентности. Например, операции с кредитными картами, как мы упоминали выше. На самом деле, большинство (если не все) платежных инфраструктур поддерживают идемпотентность, например платежные API от Stripe или Square.

Но идемпотентность тоже дорогая. Его сложнее реализовать, и он также имеет дополнительные последствия задержки. Следовательно, некоторые службы могут не иметь смысла быть идемпотентными. Например,

  • Нам не нужна идемпотентность в системе логирования. Если система перезапускается, вполне допустимо дублирование сообщений журнала.
  • Нам не нужна идемпотентность в потоковых приложениях, таких как вызовы VoIP, или средствах совместного редактирования (таких как GDoc). В этих системах мы отдаем предпочтение доступности, а не согласованности.

Как однажды сказал Томас Соуэлл:

«Решений нет. Есть только компромиссы. И вы пытаетесь получить лучший компромисс, который вы можете получить. Это все, на что вы можете надеяться».

Он говорил в контексте экономики. Но то же самое может относиться и к нам, инженерам. Нам нужно решить, какие операции являются критическими, и нам нужно сделать идемпотентными те из них, которые менее критичны и достаточно хороши как минимум один раз, так и максимум один раз.

Конец

Это все на сегодня. Это была непростая тема для написания. Если вы зашли так далеко, надеюсь, что вы тоже наслаждаетесь красотой идемпотентности. Как всегда, большое спасибо за чтение. Вам приходится использовать идемпотентность в своей работе? Что это вы строите? Что вы считаете самым сложным при реализации этого? Я тоже хотел бы услышать от вас!