Хабрахабр

Стажёр Вася и его истории об идемпотентности API

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

Сегодня я поделюсь с читателями Хабра описанием проблем, которые могут возникнуть, если не учитывать идемпотентность распределенных систем в своем проекте. Меня зовут Денис Исаев, и я руковожу одной из бэкенд групп в Яндекс.Такси. Так будет нагляднее и полезнее. Для этого я выбрал формат вымышленных историй о стажёре Васе, который только-только учится работать с API. Поехали.

image

Про API

Он сидел днями и ночами и реализовал API вида POST /v1/orders: Вася разрабатывал приложение для заказа такси с нуля и получил задачу сделать API для заказа машины.

{ "from": "Москва, ул. Садовническая набережная 82с2", "to": "Аэропорт Внуково"
}

Менеджеры ответили, что нет, такая возможность не нужна. Когда надо было сделать API для отдачи активных заказов, Вася задумался: а может ли понадобиться заказывать одновременно несколько машин такси? Тем не менее он сделал API для отдачи списка активных заказов в общем виде GET /v1/orders:

]
}

В мобильном приложении программист Федя поддержал серверное API следующим образом:

  1. при старте приложения вызываем GET /v1/orders, если получили активный заказ, то рисуем в UI его состояние;
  2. при нажатии на кнопку «заказать такси» вызываем POST /v1/orders с введенными пользовательскими данными;
  3. при возникновении любой ошибки сервера или сетевой ошибки рисуем сообщение об ошибке и больше ничего не делаем.

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

Блокирование кнопки

image

Быстро делая кофе, Вася сел за ноутбук, подключился по VPN и начал копать логи, графики и код. В 8 утра Васю разбудил звонок от саппорта: двое пользователей пожаловались на то, что к ним приехало две машины вместо одной, и деньги списали за обе машины. По графикам он увидел: в 7 утра база данных начала тормозить и запросы записи в базу стали работать секундами вместо миллисекунд. По логам Вася обнаружил, что у этих пользователей было по два одинаковых запроса с разницей в несколько секунд. И тут он понял: приложение не блокирует кнопку «заказать такси» после отправки запроса, и, когда, запросы начали тормозить, пользователи стали жать на кнопку еще раз, думая, что первый раз она не нажалась. К этому моменту причина медленных запросов уже была найдена и устранена, но нет гарантий, что подобное не повторится когда-нибудь.

image

Но команде пришлось еще несколько недель получать подобные жалобы и просить пользователей обновить приложение. Приложение стало блокировать кнопку: этот фикс зарелизился через несколько дней.

В подземном переходе

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

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

Из нескольких вариантов исправления Вася выбрал такой: перед созданием заказа в базе он селектит из базы заказы пользователя с такими же параметрами from и to за последние 5 минут. Выбрали вариант править на сервере, так как это можно сделать в тот же день, не дожидаясь долгой раскатки приложения. Вася написал автотесты, и, случайно, запустил их параллельно: один из тестов упал. Если такой заказ найден, то сервер отдает ошибку 500. По результатам случившихся багов Вася понял, что и сеть может «моргать», и база данных может тормозить, увеличивая окно гонки, поэтому случай вполне реальный. Вася понял, что есть гонка между селектом и инсертом в базу при параллельных запросах от одного пользователя. Как это чинить правильно, было непонятно.

Лимиты на число активных заказов

По совету более опытного программиста Вася посмотрел на проблему с другой стороны и обошел гонку, используя такой алгоритм:

  1. начать транзакцию;
  2. UPDATE active_orders SET n=1 WHERE user_id={user_id} AND n=0;

  3. если update изменил 0 записей, то отдать HTTP код 409;
  4. вставить объект заказа в другую таблицу;
  5. завершить транзакцию.

Фикс на сервере зарелизили в тот же день, дубли миновали, а после выкатки приложения пользователи перестали видеть ошибки. Приложение при получении 409 кода ответа перезапрашивало список активных заказов. Вася с Федей вернулись к своим фичам.

Мультизаказ

Вася удивлен: как же так, я же спрашивал, и вы говорили мне, что это не понадобится?! Прошел месяц, и к Васе пришел новенький менеджер: за сколько дней можно сделать фичу «мультизаказ»: чтобы пользователь мог заказать две машины такси? Менеджер удивился: разве это не просто поднять лимит с 1 до 2? Вася сказал, что это не быстро. Вася даже не представлял, как вообще можно решить эту задачу, не вводя дублей. Но мультизаказ полностью ломал Васину схему защиты от дублей.

image

Ключ идемпотентности

Идемпотентным называют такой метод API, повторный вызов которого не меняет состояние. Вася решил изучить, кто как борется с такими проблемами наткнулся на понятие идемпотентности. Например, при повторном вызове идемпотентного API создания заказа — заказ не будет создаваться еще раз, но API может ответить как 200, так и 400. Здесь есть тонкий момент: результат идемпотентного вызова может меняться. При обоих кодах ответа API будет идемпотентно с точки зрения состояния сервера (заказ один, с ним ничего не происходит), а с точки зрения клиента поведение существенно разное.

Это не означает, что вы не можете сделать GET неидемпотентным, а POST идемпотентным. Также Вася узнал, что HTTP методы GET, PUT, DELETE формально считаются идемпотентными, тогда как POST и PATCH нет. Но это то, на что полагается множество программ, например, прокси-серверы могут не повторять POST и PATCH запросы при ошибках, тогда как GET и PUT могут повторить.

Вася решил посмотреть примеры и наткнулся на понятие idempotency key в некоторых публичных API.

Рекомендуется использовать UUID V4. Яндекс.Касса позволяет клиентам слать вместе с формально неидемпотентными (POST) запросами заголовок Idempotency-Key с уникальным ключом, сгенерированным на клиенте API. Ключи хранятся в течение 24ч. Stripe аналогично позволяет клиентам слать вместе с формально неидемпотентными (POST) запросами заголовок Idempotency-Key с уникальным ключом, сгенерированным на клиенте API. Среди неплатежных систем Вася нашел client tokens у AWS.

Вася добавил в запрос POST /v1/orders новое обязательное поле idempotency_key, и запрос стал таким:

{ "from": "Москва, ул. Садовническая набережная 82с2", "to": "Аэропорт Внуково", "idempotency_key": "786706b8-ed80-443a-80f6-ea1fa8cc1b51"
}

При повторных попытках создания заказ клиент шлет тот же ключ идемпотентности. Клиент стал генерировать ключ идемпотентности как UUID v4 и слать его на сервер. Если это ограничение не дало сделать инсерт, то код обнаруживал это и отдавал ошибку 409. На сервере ключ идемпотентности инсертится в базу в поле, на котором есть ограничение базы данных по уникальности. По совету Феди этот момент был переделан в сторону упрощения клиента: отдавать стали не 409, а 200, будто бы заказ успешно создан, тогда на клиентах не надо учиться обрабатывать код 409.

image

Баг при тестировании

При тестировании приложения нашли следующий баг: После этого лимит просто подняли с 1 до 2 и поддержали изменение в приложении.

  1. пользователь хочет создать заказ, запрос приходит на сервер, заказ создается, тестировщики эмулируют сетевую ошибку и ответ приложение не получает;
  2. пользователь видит сообщение об ошибке, по какой-то причине перед этим еще меняет точку назначения, и только после нажимает на кнопку создания такси еще раз;
  3. приложение не меняет ключ идемпотентности между запросами;
  4. сервер обнаруживает, что заказ с таким ключом идемпотентности уже есть и отдает 200;
  5. на сервере создан заказ со старой точкой назначения, а пользователь думает что он создан с новой точкой назначения, и уезжает не туда.

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

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

Вася добавил серверную валидацию, предложенную Федей. В итоге вдвоем они придумали следующее решение: приложение не дает изменить параметры заказа и бесконечно пытается создать заказ, пока получает коды ответа 5xx или же сетевые ошибки.

Полезное код-ревью

На код-ревью реализованного решения нашли два проблемных сценария.

Сценарий 1: два такси

  1. клиент отправляет запрос на создание заказа, запрос выполняется десятки секунд по каким-то причинам, заказ медленно создается;
  2. пользователь не может ничего сделать в приложении, при этом и такси не заказывается, тогда он решает полностью выгрузить приложение из памяти;
  3. пользователь заново открывает приложение, оно делает запрос GET /v1/orders, и создающегося в данный момент заказа не получает, так как он еще не создался до конца;
  4. пользователь думает, что приложение сглючило и делает заказ еще раз, на этот раз заказ создается быстро;
  5. создание первого заказа отвисло, и заказ создался до конца;
  6. к пассажиру приезжает два такси.

Сценарий 2: приехало отмененное такси

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

Вася с Федей рассматривали простые варианты, как поправить обе проблемы:

  1. сценарий 1: клиент хранит у себя все создающиеся в данный момент заказы даже между рестартами приложения. Клиент показывает их в интерфейсе сразу после старта, продолжая попытки их создания, при условии что прошло не слишком много времени с момента их создания.
  2. сценарий 2: перейти от удаления записей из таблицы заказов к выставлению поля deleted_at=now() — так называемому soft delete. Тогда ограничение уникальности ключа идемпотентности работало бы и для отмененных заказов.
  3. сценарий 3: отделить абстракцию обеспечения идемпотентности запросов от абстракции ресурсов и хранить использованные ключи идемпотентности ограниченное время отдельно от ресурса, например, 24ч.

API GET /v1/orders отдавало бы версию списка заказов. Но старшие товарищи предложили более общее решение: версионировать состояние списка заказов. При создании заказа клиент передает в отдельном поле или заголовке If-Match версию, о которой он знает. Это версия всего списка заказов пользователя, а не конкретного заказа. То есть клиент в запросе к серверу говорит ему, какое состояние заказов он знает. Сервер атомарно с изменением увеличивает версию при любых изменениях заказов (создание, отмена, изменение). Версионирование решает обе найденные проблемы, и именно его Вася с Федей и поддержали. И если это состояние заказов (версия) расходится с тем, что хранится на сервере, то сервер отдает ошибку «заказы были изменены параллельно, перезагрузите информацию о заказах».

Время делать выводы

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

Идемпотентность удаления

По логам Вася нашел, что это случилось в API отмены заказа. В один день Васе в телеграм приходит нотификация о том, что в API был код ответа 404.

Внутри строка с заказом просто удалялась. Отмена заказа делалась через запрос DELETE /v1/orders/:id. В soft delete (выставление deleted_at=now()) необходимости не было.

Приложение, не уведомляя пользователя, сразу сделало перезапрос и получило 404: первый запрос уже выполнился и удалил заказ. В данной ситуации приложение послало первый запрос на отмену, но он стаймаутил. Пользователь же увидел сообщение «неизвестная ошибка сервера».

Оказывается, идемпотентным должны быть не только API создания, но и удаления ресурсов, — подумал Вася.

Но это создавало риск скрыть и пропустить возможные проблемы. Вася рассматривал вариант отдавать 200 всегда, даже если DELETE запрос в базе не удалил ничего. Поэтому он решил сделать soft delete и переделать API отмены:

  1. из базы данных он стал селектить все, даже уже отмененные заказы с данным id;
  2. если заказ уже был удален, и это было в пределах последних n минут (то есть, на обычных перезапросах), то сервер стал отдавать 200;
  3. в остальных случаях сервер отдает 410 с ошибкой «заказа не существует». Вася решил попутно заменить 404 на 410 как более подходящий, так как код 404 означает, что это ошибка временная, а запрос можно потом повторить. Код 410 же означает, что ошибка постоянная, и повтор запроса выдаст тот же результат.

Больше подобных проблем с отменой заказа не всплывало.

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

Изменение точки B

Вася решил проверить по коду, есть ли идемпотентность у API изменения поездки: он уже осознал, что идемпотентным должно быть абсолютно любое API.

image

При этом посылается запрос PATCH /v1/orders/:id: В приложении пассажир может изменить точку B.

{ "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.

image

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

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

  1. при создании заказа счетчик никак не меняется;
  2. в очереди заданий появляется новая процедура, которая фетчит все заказы пользователя из обоих хранилищ, рассчитывает метрику завершенных заказов и сохраняет ее в базу;
  3. задание в очередь кладется из API завершения заказа: при повторных вызовах API в худшем случае несколько раз выполнится задание в очереди, что нестрашно.

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

Все проверил

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

Идемпотентность при внешних операциях

Дубли SMS

Реагировать нужно немедленно, пост уже собрал сотни лайков. В середине рабочего дня к столу Васи прибегает обеспокоенный менеджер: в фейсбуке медийная личность написала гневный пост о том, что наше такси-приложение завалило его десятком одинаковых SMS.

image

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

В терминах распределенных систем, Вася перешел от "at least once delivery" к "at most once delivery". На исправление понадобилось два дня: для задач, отправляющих SMS, email и пуши, изменилась логика пометки задачи выполненной: пометка стала делаться в самом начале выполнения. Были настроены мониторинги, продуктово было согласовано, что недоставка нотификаций лучше, чем их дублирование.

Заключение

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

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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть