Огромная часть дизайна бессерверного API — это обработка повторных попыток или случайных повторных отправок. Без этого целостность данных теряется.

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

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

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

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

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

На прошлой неделе я провел опросы в LinkedIn и Twitter с каверзным вопросом на идемпотентность, чтобы узнать, что думает сообщество. Вопрос, который я задал, был:

Что бы вы сделали для идемпотентной конечной точки, когда приходит дублирующий запрос, в то время как исходный все еще обрабатывается?

Это само по себе очень целенаправленный вопрос. Я не спрашивал о том, что такое идемпотентность или какие-либо ее основные аспекты. Но у меня есть мнение обо всем. Я чувствовал, что поставил ловушку для кроликов, но поймал медведей, оленей, кроликов, енотов и стервятников.

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

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

Принципы идемпотентности

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

Влияние на систему

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

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

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

Однако это только царапает поверхность DELETE. Если вы учитываете события, которые запускаются при удалении объекта данных, или записи журналов аудита, действительно ли они имеют тот же эффект, что и при первом вызове? Может быть, на объекте данных, но не на системе в целом.

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

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

Самый распространенный способ добиться идемпотентности с точки зрения кодирования — принять заголовок idempotency-key в ваших запросах. Если ваши операции асинхронны и не имеют заголовков, вы можете разместить свойство idempotency-key в полезной нагрузке или использовать что-то вроде идентификатора запроса (при условии, что он не меняется при повторных попытках).

Используйте idempotency-key в качестве блокировки и в качестве ключа поиска для сохранения ответа и возврата результата при последующих вызовах.

Ответ звонящему

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

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

Хорошим примером этого являются дебаты DELETE. При удалении ресурса обычно возвращается код состояния 204 No Content при успешном выполнении удаления. Но что делать, если вы снова попытаетесь удалить ресурс, случайно или намеренно? Вы по-прежнему возвращаете 204, чтобы предоставить идемпотентный ответ вызывающей стороне? Или вы возвращаете код состояния 404 Not Found или 410 Gone, потому что его не существует?

Возврат кода состояния 404 или 410 приводит к тому же эффекту в системе (за исключением последующих событий), поэтому некоторые до сих пор считают его идемпотентным.

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

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

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

Прочитав множество комментариев к опросам и пообщавшись с Андресом Морено, Кевином Свибером и Мэттью Бонигом, я пришел к выводу немедленно вернуть успех.

Когда я изначально разместил опрос, это звучало как самый далекий от правильного вариант. Этого варианта даже в опросе не было! Но это имеет смысл. Если вы возвращаете код состояния 202 Accepted, это указывает вызывающей стороне, что процесс выполняется на сервере. При желании вы можете вернуть URL-адрес конечной точки «получить статус» в ответе, чтобы вызывающая сторона могла самостоятельно проверить статус.

Ожидание ответа — пустая трата ресурсов. Вы напрасно говорите своему приложению ждать ответа только для того, чтобы вызов ощущался одинаково для вызывающего абонента. С бессерверной системой вы просто выбрасываете деньги, заставляя лямбда-функцию оставаться в живых в ожидании. Теперь, когда устойчивость является основой концепции Well-Architected Framework, принуждение к ожиданию противоречит лучшим практикам AWS.

Ошибка 4XX указывает на то, что вызывающий абонент сделал что-то не так. В этом случае они не дождались завершения обработки достаточно долго, что не является ошибкой вызывающего абонента. Это также не ошибка на стороне сервера (код состояния 5XX). Это означает, что выдача ошибки на самом деле не применяется. Последнее, что вам нужно, это чтобы вызывающая сторона предприняла корректирующие действия, изменив запрос или отправив запрос еще раз, потому что он получил ошибку.

Ответы на идемпотентные операции различаются в зависимости от состояния исходного запроса:

  • Завершено успешно – исходный код состояния и текст ответа извлекаются из кэша, например Momento, и возвращаются вызывающему абоненту.
  • Завершено с ошибкой — повторная попытка операции, как если бы она была исходной.
  • Выполняется — возвратить успешное выполнение и не выполнять никаких операций.

Время жить

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

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

Всегда следует проверять полезную нагрузку запроса на соответствие ключу идемпотентности.

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

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

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

В эталонном проекте мы берем хэш тела запроса и сохраняем его вместе с ключом. Если поступает другой запрос с тем же ключом, но с другим хэшем, возвращается сообщение 400 Bad Request, указывающее, что полезная нагрузка не соответствует полезной нагрузке исходного запроса.

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

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

Помните, что ваша цель — предотвратить повторные входы в систему, поэтому установите время жизни немного больше, чем максимальное время повторной попытки. Если у вас есть стратегия отсрочки и повторных попыток, которая автоматически повторяет неудачный асинхронный процесс 50 раз в течение 24 часов, установите время жизни на 25 часов.

Для синхронных вызовов или вызовов типа запрос/ответ время жизни может быть значительно короче. Вероятным вариантом использования конечной точки API, отправляющей дубликаты, будет случайный двойной щелчок по кнопке отправки. В этом случае дубликаты появятся сразу. Ради потомства мы можем установить время жизни этих вызовов примерно на час, чтобы перехватывать любые поступающие мошеннические запросы.

Хранение записей

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

Похоже, это отличный пример использования кэширования.

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

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

Вместо этого я выбрал Momento, полностью бессерверное решение для кэширования. Он работает по той же модели плата за то, что вы используете, что и бессерверные сервисы AWS, включая уровень бесплатного пользования 50 ГБ в месяц. Поскольку он бессерверный, он автоматически масштабируется в соответствии с объемом трафика и увеличивает размер кеша, не беспокоясь о узлах данных.

Сохранение записей в кэш вместо базы данных помогает следовать принципу наименьших привилегий. Поскольку мы не устанавливаем соединение с DynamoDB, мы можем опустить разрешения GetItem, PutItem и DeleteItem для любых функций Lambda, которые должны быть идемпотентным, потому что мы не управляем записями идемпотентности. Это блокирует наши функции только разрешениями, необходимыми для операции.

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

Краткое содержание

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

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

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

  • Идемпотентность не гарантирует идентичный ответ вызывающей стороне, но это приятный бонус.
  • Параллельные вызовы с одним и тем же ключом идемпотентности должны приводить к успешному ответу и отказу от операции.
  • Время жизни на ключах идемпотентности должно быть немного больше, чем общее время вашего механизма отсрочки/повторения.
  • Операции, которые мы считаем естественно идемпотентными, такие как DELETE и PUT, на самом деле могут ими не быть. Это зависит от последующих действий, которые происходят в результате изменения.
  • Если возможно, используйте механизм кэширования вместо базы данных для записей идемпотентности для более быстрых ответов и своевременного истечения срока действия записи.

Конечно, мы должны помнить, что это программное обеспечение, и как решить большинство программных проблем? Это зависит от обстоятельств. Примите во внимание варианты использования, прежде чем внедрять решение. У вас даже могут быть разные стратегии, основанные на разных API!

Существуют пакеты, которые исключительно хорошо справляются с идемпотентностью. Разработчикам Serverless Python следует настоятельно рассмотреть возможность использования библиотеки AWS Lambda Powertools. Он обрабатывает все, что мы рассмотрели сегодня, а также некоторые расширенные варианты использования, например, когда ваша функция Lambda истекает. Обратите внимание, что на момент написания этой статьи он доступен только в Python. TypeScript Lambda Powertools его не поддерживает.

Этот пост ни в коем случае не является исчерпывающим, и я уверен, что некоторые перья взъерошены. Согласны ли вы с моим мнением или нет, хорошо знать, во что верят другие.

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

Удачного кодирования!