- PVSM.RU - https://www.pvsm.ru -
Идемпотентность — звучит сложно, говорят о ней редко, но это касается всех приложений, использующих API в своей работе.
Меня зовут Денис Исаев, и я руковожу одной из бэкенд групп в Яндекс.Такси. Сегодня я поделюсь с читателями Хабра описанием проблем, которые могут возникнуть, если не учитывать идемпотентность распределенных систем в своем проекте. Для этого я выбрал формат вымышленных историй о стажёре Васе, который только-только учится работать с API. Так будет нагляднее и полезнее. Поехали.
Вася разрабатывал приложение для заказа такси с нуля и получил задачу сделать API для заказа машины. Он сидел днями и ночами и реализовал API вида POST /v1/orders
:
{
"from": "Москва, ул. Садовническая набережная 82с2",
"to": "Аэропорт Внуково"
}
Когда надо было сделать API для отдачи активных заказов, Вася задумался: а может ли понадобиться заказывать одновременно несколько машин такси? Менеджеры ответили, что нет, такая возможность не нужна. Тем не менее он сделал API для отдачи списка активных заказов в общем виде GET /v1/orders
:
{
"orders": [
{
"id": 1,
"from": "Москва, ул. Садовническая набережная 82с2",
"to": "Аэропорт Внуково"
}
]
}
В мобильном приложении программист Федя поддержал серверное API следующим образом:
GET /v1/orders
, если получили активный заказ, то рисуем в UI его состояние;POST /v1/orders
с введенными пользовательскими данными;Как и положено, на серверный код и код приложения написали автотесты, а перед релизом мобильного приложения его вручную тестировали 2 дня. Тестирование нашло ряд багов, их быстро исправили. Приложение успешно зарелизили на пользователей и дали рекламную компанию. Пользователи оставили несколько положительных отзывов, благодарили разработчиков, просили новых фич. Команда разработки и менеджеров отметили пончиками успешный запуск и разошлись по домам.
В 8 утра Васю разбудил звонок от саппорта: двое пользователей пожаловались на то, что к ним приехало две машины вместо одной, и деньги списали за обе машины. Быстро делая кофе, Вася сел за ноутбук, подключился по VPN и начал копать логи, графики и код. По логам Вася обнаружил, что у этих пользователей было по два одинаковых запроса с разницей в несколько секунд. По графикам он увидел: в 7 утра база данных начала тормозить и запросы записи в базу стали работать секундами вместо миллисекунд. К этому моменту причина медленных запросов уже была найдена и устранена, но нет гарантий, что подобное не повторится когда-нибудь. И тут он понял: приложение не блокирует кнопку «заказать такси» после отправки запроса, и, когда, запросы начали тормозить, пользователи стали жать на кнопку еще раз, думая, что первый раз она не нажалась.
Приложение стало блокировать кнопку: этот фикс зарелизился через несколько дней. Но команде пришлось еще несколько недель получать подобные жалобы и просить пользователей обновить приложение.
Пришла очередная подобная жалоба, а саппорт по инерции ответил «обновите приложение». Но тут пользователь сообщил, что у него уже самая новая версия приложения. Васю и Федю вырвали из их текущих фич и попросили разобраться, как же так, ведь этот баг уже был пофиксили.
Потратив два дня на раскопки этого единичного случая, они выяснили, в чем было дело. Оказалось, что блокировать кнопку недостаточно: один из пользователей пытался заказать такси, находясь в подземном переходе. Мобильный интернет у него работал еле-еле: при нажатии на кнопку заказа запрос ушел на сервер, но ответ не был получен. Приложение показало сообщение «произошла ошибка» и разблокировало кнопку заказа. Кто бы мог подумать, что такой запрос мог быть успешно выполнен на сервере, а таксист уже быть в пути?
Выбрали вариант править на сервере, так как это можно сделать в тот же день, не дожидаясь долгой раскатки приложения. Из нескольких вариантов исправления Вася выбрал такой: перед созданием заказа в базе он селектит из базы заказы пользователя с такими же параметрами from и to за последние 5 минут. Если такой заказ найден, то сервер отдает ошибку 500. Вася написал автотесты, и, случайно, запустил их параллельно: один из тестов упал. Вася понял, что есть гонка между селектом и инсертом в базу при параллельных запросах от одного пользователя. По результатам случившихся багов Вася понял, что и сеть может «моргать», и база данных может тормозить, увеличивая окно гонки, поэтому случай вполне реальный. Как это чинить правильно, было непонятно.
По совету более опытного программиста Вася посмотрел на проблему с другой стороны и обошел гонку, используя такой алгоритм:
UPDATE active_orders SET n=1 WHERE user_id={user_id} AND n=0;
update
изменил 0 записей, то отдать HTTP код 409;Приложение при получении 409 кода ответа перезапрашивало список активных заказов. Фикс на сервере зарелизили в тот же день, дубли миновали, а после выкатки приложения пользователи перестали видеть ошибки. Вася с Федей вернулись к своим фичам.
Прошел месяц, и к Васе пришел новенький менеджер: за сколько дней можно сделать фичу «мультизаказ»: чтобы пользователь мог заказать две машины такси? Вася удивлен: как же так, я же спрашивал, и вы говорили мне, что это не понадобится?! Вася сказал, что это не быстро. Менеджер удивился: разве это не просто поднять лимит с 1 до 2? Но мультизаказ полностью ломал Васину схему защиты от дублей. Вася даже не представлял, как вообще можно решить эту задачу, не вводя дублей.
Вася решил изучить, кто как борется с такими проблемами наткнулся на понятие идемпотентности. Идемпотентным называют такой метод API, повторный вызов которого не меняет состояние. Здесь есть тонкий момент: результат идемпотентного вызова может меняться. Например, при повторном вызове идемпотентного API создания заказа — заказ не будет создаваться еще раз, но API может ответить как 200, так и 400. При обоих кодах ответа API будет идемпотентно с точки зрения состояния сервера (заказ один, с ним ничего не происходит), а с точки зрения клиента поведение существенно разное.
Также Вася узнал, что HTTP методы GET, PUT, DELETE формально считаются идемпотентными, тогда как POST и PATCH нет. Это не означает, что вы не можете сделать GET неидемпотентным, а POST идемпотентным. Но это то, на что полагается множество программ, например, прокси-серверы могут не повторять POST и PATCH запросы при ошибках, тогда как GET и PUT могут повторить.
Вася решил посмотреть примеры и наткнулся на понятие idempotency key в некоторых публичных API.
Яндекс.Касса позволяет клиентам слать вместе с формально неидемпотентными (POST) запросами заголовок Idempotency-Key [1] с уникальным ключом, сгенерированном на клиенте API. Рекомендуется использовать UUID V4. Stripe аналогично позволяет клиентам слать вместе с формально неидемпотентными (POST) запросами заголовок Idempotency-Key [2] с уникальным ключом, сгенерированном на клиенте API. Ключи хранятся в течение 24ч. Среди неплатежных систем Вася нашел client tokens [3] у AWS.
Вася добавил в запрос POST /v1/orders новое обязательное поле idempotency_key, и запрос стал таким:
{
"from": "Москва, ул. Садовническая набережная 82с2",
"to": "Аэропорт Внуково",
"idempotency_key": "786706b8-ed80-443a-80f6-ea1fa8cc1b51"
}
Клиент стал генерировать ключ идемпотентности как UUID v4 и слать его на сервер. При повторных попытках создания заказ клиент шлет тот же ключ идемпотентности. На сервере ключ идемпотентности инсертится в базу в поле, на котором есть ограничение базы данных по уникальности. Если это ограничение не дало сделать инсерт, то код обнаруживал это и отдавал ошибку 409. По совету Феди этот момент был переделан в сторону упрощения клиента: отдавать стали не 409, а 200, будто бы заказ успешно создан, тогда на клиентах не надо учиться обрабатывать код 409.
После этого лимит просто подняли с 1 до 2 и поддержали изменение в приложении. При тестировании приложения нашли следующий баг:
Сначала Вася предложил Феде генерировать новый ключ идемпотентности в таком случае. Но Федя объяснил, что тогда может быть дубль: при сетевой ошибке запроса создания заказа клиент не может знать, был ли действительно заказ создан.
Федя заметил, что хоть это и не решение, но для раннего обнаружения таких багов на сервере следовало проверять, что параметры входящего запроса совпадают с параметрами существующего заказа с таким же ключом идемпотентности. Например, AWS отдает ошибку IdempotentParameterMismatch [3] в таком случае.
В итоге вдвоем они придумали следующее решение: приложение не дает изменить параметры заказа и бесконечно пытается создать заказ, пока получает коды ответа 5xx или же сетевые ошибки. Вася добавил серверную валидацию, предложенную Федей.
На код-ревью реализованного решения нашли два проблемных сценария.
Вася с Федей рассматривали простые варианты, как поправить обе проблемы:
Но старшие товарищи предложили более общее решение: версионировать состояние списка заказов. API GET /v1/orders
отдавало бы версию списка заказов. Это версия всего списка заказов пользователя, а не конкретного заказа. При создании заказа клиент передает в отдельном поле или заголовке If-Match версию, о которой он знает. Сервер атомарно с изменением увеличивает версию при любых изменениях заказов (создание, отмена, изменение). То есть клиент в запросе к серверу говорит ему, какое состояние заказов он знает. И если это состояние заказов (версия) расходится с тем, что хранится на сервере, то сервер отдает ошибку «заказы были изменены параллельно, перезагрузите информацию о заказах». Версионирование решает обе найденные проблемы, и именно его Вася с Федей и поддержали.
По итогам всех переделок Вася поразмышлял и понял, что любой API создания ресурсов обязательно должен быть идемпотентным. Кроме того важно синхронизировать знание о списке ресурсов на клиенте и сервере через версионирование этого списка.
В один день Васе в телеграм приходит нотификация о том, что в API был код ответа 404. По логам Вася нашел, что это случилось в API отмены заказа.
Отмена заказа делалась через запрос DELETE /v1/orders/:id. Внутри строка с заказом просто удалялась. В soft delete (выставление deleted_at=now()) необходимости не было.
В данной ситуации приложение послало первый запрос на отмену, но он стаймаутил. Приложение, не уведомляя пользователя, сразу сделало перезапрос и получило 404: первый запрос уже выполнился и удалил заказ. Пользователь же увидел сообщение «неизвестная ошибка сервера».
Оказывается, идемпотентным должны быть не только API создания, но и удаления ресурсов, — подумал Вася.
Вася рассматривал вариант отдавать 200 всегда, даже если DELETE запрос в базе не удалил ничего. Но это создавало риск скрыть и пропустить возможные проблемы. Поэтому он решил сделать soft delete и переделать API отмены:
Больше подобных проблем с отменой заказа не всплывало.
Вася решил проверить по коду, есть ли идемпотентность у API изменения поездки: он уже осознал, что идемпотентным должно быть абсолютно любое API.
В приложении пассажир может изменить точку B. При этом посылается запрос PATCH /v1/orders/:id
:
{
"to": "новая точка назначения"
}
Сервер же внутри просто выполняет update
в базу:
UPDATE orders SET to={to} WHERE id={id}
Тут все идемпотентнее некуда — подумал Вася и был прав. Только не учел он того, что при параллельном изменении и чтении/изменении тут могут быть гонки, но это уже совсем другая история.
Также Вася проверил API завершения поездки: оно вызывается водительским приложением, когда водитель выполнил заказ. На сервере API помечает заказ выполненным и делает ряд действий, в том числе подсчет статистики. Среди считаемой статистики взгляд Васи упал на метрику кол-ва завершенных заказов у пользователя. При вызове API счетчик завершенных заказов инкрементился запросом вида
UPDATE user_counters SET orders_finished = {orders_finished+1} WHERE user_id={user_id}
Стало понятно, что при повторных вызовах API счетчик может увеличиться больше, чем на 1.
Вася задумался: зачем вообще нужен счетчик, если можно каждый раз по базе считать общее число таких заказов? Коллега подсказал ему, что во-первых, старые заказы уезжают в отдельные хранилища, а во-вторых, счетчик используется в нагруженных API, где важно не делать лишние запросы в базу.
Вася создал задачу в таск-трекере на переделку расчета счетчика по следующему алгоритму:
Через полчаса Васю спросил его руководитель: зачем это делать? После небольшого обсуждения у них появилось взаимное понимание, что редкое расхождение счетчиков приемлемо. И переделывать схему для точного подсчета метрики нецелесообразно для бизнеса на данном этапе.
Как ответственный стажер-разработчик, Вася проверил все места, где API может быть неидемпотентно. Но точно ли он проверил все, что нужно?
В середине рабочего дня к столу Васи прибегает обеспокоенный менеджер: в фейсбуке медийная личность написала гневный пост о том, что наше такси-приложение завалило его десятком одинаковых SMS. Реагировать нужно немедленно, пост уже собрал сотни лайков.
Вася внимательно просмотрел код отправки SMS: сначала в очередь клалась задача, затем при исполнении задачи делался запрос в SMS шлюз. Ни там, ни там не было перезапросов в случае ошибок. Откуда же могли взяться дубли, может быть проблема у шлюза или оператора? Затем Вася обнаружил, что во время дублей consumer очереди многократно крэшился. Его осенило: задача берется из очереди, выполняется, и помечается помеченной только в конце исполнения.
На исправление понадобилось два дня: для задач, отправляющих SMS, email и пуши, изменилась логика пометки задачи выполненной: пометка стала делаться в самом начале выполнения. В терминах распределенных систем, Вася перешел от "at least once delivery" к "at most once delivery". Были настроены мониторинги, продуктово было согласовано, что недоставка нотификаций лучше, чем их дублирование.
С помощью выдуманных историй я попытался объяснить, почему так важно, чтобы API были идемпотентными. Показал, какие есть нюансы на практике.
В Яндекс.Такси мы всегда думаем об идемпотентностью наших API. В небольшом проекте допустимо было бы не тратить время на проработку редких случаев. Но Яндекс.Такси это десятки миллионов поездок ежемесячно. Поэтому у нас есть процедура дизайн-ревью архитектуры и API. Если что-то неидемпотентно, есть гонки, либо логические проблемы, то API не пройдет ревью. Для разработчиков это означает, что приходится внимательно относиться к деталям и продумывать множество граничных случаев. Это нетривиальная задача, и особенно сложно покрывать такие граничные случаи автотестами.
Случаются ли таймауты, перезапросы, дубли, когда у приложения нет миллионов пользователей? К сожалению, да. Описанные ситуация типичны для распределенных систем: сетевые ошибки происходят регулярно, железо регулярно выходит из строя и т.д. Хорошо спроектированная система считает подобные ошибки нормальным поведением и умеет их компенсировать.
Автор: Денис Исаев
Источник [4]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/interfejsy/311289
Ссылки в тексте:
[1] заголовок Idempotency-Key: https://kassa.yandex.ru/docs/checkout-api/#idempotentnost
[2] заголовок Idempotency-Key: https://stripe.com/docs/api/idempotent_requests
[3] client tokens: https://docs.aws.amazon.com/AWSEC2/latest/APIReference/Run_Instance_Idempotency.html#client-tokens
[4] Источник: https://habr.com/ru/post/442762/?utm_source=habrahabr&utm_medium=rss&utm_campaign=442762
Нажмите здесь для печати.