Идемпотентность — звучит сложно, говорят о ней редко, но это касается всех приложений, использующих API в своей работе.
Меня зовут Денис Исаев, и я руковожу одной из бэкенд групп в Яндекс.Такси. Сегодня я поделюсь с читателями Хабра описанием проблем, которые могут возникнуть, если не учитывать идемпотентность распределенных систем в своем проекте. Для этого я выбрал формат вымышленных историй о стажёре Васе, который только-только учится работать с 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 с уникальным ключом, сгенерированном на клиенте API. Рекомендуется использовать UUID V4. Stripe аналогично позволяет клиентам слать вместе с формально неидемпотентными (POST) запросами заголовок Idempotency-Key с уникальным ключом, сгенерированном на клиенте API. Ключи хранятся в течение 24ч. Среди неплатежных систем Вася нашел 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.
Баг при тестировании
После этого лимит просто подняли с 1 до 2 и поддержали изменение в приложении. При тестировании приложения нашли следующий баг:
- пользователь хочет создать заказ, запрос приходит на сервер, заказ создается, тестировщики эмулируют сетевую ошибку и ответ приложение не получает;
- пользователь видит сообщение об ошибке, по какой-то причине перед этим еще меняет точку назначения, и только после нажимает на кнопку создания такси еще раз;
- приложение не меняет ключ идемпотентности между запросами;
- сервер обнаруживает, что заказ с таким ключом идемпотентности уже есть и отдает 200;
- на сервере создан заказ со старой точкой назначения, а пользователь думает что он создан с новой точкой назначения, и уезжает не туда.
Сначала Вася предложил Феде генерировать новый ключ идемпотентности в таком случае. Но Федя объяснил, что тогда может быть дубль: при сетевой ошибке запроса создания заказа клиент не может знать, был ли действительно заказ создан.
Федя заметил, что хоть это и не решение, но для раннего обнаружения таких багов на сервере следовало проверять, что параметры входящего запроса совпадают с параметрами существующего заказа с таким же ключом идемпотентности. Например, AWS отдает ошибку IdempotentParameterMismatch в таком случае.
В итоге вдвоем они придумали следующее решение: приложение не дает изменить параметры заказа и бесконечно пытается создать заказ, пока получает коды ответа 5xx или же сетевые ошибки. Вася добавил серверную валидацию, предложенную Федей.
Полезное код-ревью
На код-ревью реализованного решения нашли два проблемных сценария.
Сценарий 1: два такси
- клиент отправляет запрос на создание заказа, запрос выполняется десятки секунд по каким-то причинам, заказ медленно создается;
- пользователь не может ничего сделать в приложении, при этом и такси не заказывается, тогда он решает полностью выгрузить приложение из памяти;
- пользователь заново открывает приложение, оно делает запрос GET /v1/orders, и создающегося в данный момент заказа не получает, так как он еще не создался до конца;
- пользователь думает, что приложение сглючило и делает заказ еще раз, на этот раз заказ создается быстро;
- создание первого заказа отвисло, и заказ создался до конца;
- к пассажиру приезжает два такси.
Сценарий 2: приехало отмененное такси
- клиент отправляет запрос на создание заказа, заказ создается, но мобильная сеть лагает, и клиент не получает ответ об успешном создании заказа;
- диспетчер, либо сам клиент через пуш по какой-то причине отменяет заказ: отмена заказа сделана как удаление строки из таблицы базы данных;
- клиент шлет повторный запрос на создание заказа: запрос успешно выполняется и создается еще один заказ, так как ключ идемпотентности, хранившийся в прошлом заказе, больше не существует в таблице.
Вася с Федей рассматривали простые варианты, как поправить обе проблемы:
- сценарий 1: клиент хранит у себя все создающиеся в данный момент заказы даже между рестартами приложения. Клиент показывает их в интерфейсе сразу после старта, продолжая попытки их создания, при условии что прошло не слишком много времени с момента их создания.
- сценарий 2: перейти от удаления записей из таблицы заказов к выставлению поля deleted_at=now() — так называемому soft delete. Тогда ограничение уникальности ключа идемпотентности работало бы и для отмененных заказов.
- сценарий 3: отделить абстракцию обеспечения идемпотентности запросов от абстракции ресурсов и хранить использованные ключи идемпотентности ограниченное время отдельно от ресурса, например, 24ч.
Но старшие товарищи предложили более общее решение: версионировать состояние списка заказов. 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 отмены:
- из базы данных он стал селектить все, даже уже отмененные заказы с данным id;
- если заказ уже был удален, и это было в пределах последних n минут (то есть, на обычных перезапросах), то сервер стал отдавать 200;
- в остальных случаях сервер отдает 410 с ошибкой «заказа не существует». Вася решил попутно заменить 404 на 410 как более подходящий, так как код 404 означает, что это ошибка временная, а запрос можно потом повторить. Код 410 же означает, что ошибка постоянная, и повтор запроса выдаст тот же результат.
Больше подобных проблем с отменой заказа не всплывало.
Идемпотентность изменения
Изменение точки B
Вася решил проверить по коду, есть ли идемпотентность у 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 завершения заказа: при повторных вызовах API в худшем случае несколько раз выполнится задание в очереди, что нестрашно.
Через полчаса Васю спросил его руководитель: зачем это делать? После небольшого обсуждения у них появилось взаимное понимание, что редкое расхождение счетчиков приемлемо. И переделывать схему для точного подсчета метрики нецелесообразно для бизнеса на данном этапе.
Все проверил
Как ответственный стажер-разработчик, Вася проверил все места, где API может быть неидемпотентно. Но точно ли он проверил все, что нужно?
Идемпотентность при внешних операциях
Дубли SMS
В середине рабочего дня к столу Васи прибегает обеспокоенный менеджер: в фейсбуке медийная личность написала гневный пост о том, что наше такси-приложение завалило его десятком одинаковых SMS. Реагировать нужно немедленно, пост уже собрал сотни лайков.
Вася внимательно просмотрел код отправки SMS: сначала в очередь клалась задача, затем при исполнении задачи делался запрос в SMS шлюз. Ни там, ни там не было перезапросов в случае ошибок. Откуда же могли взяться дубли, может быть проблема у шлюза или оператора? Затем Вася обнаружил, что во время дублей consumer очереди многократно крэшился. Его осенило: задача берется из очереди, выполняется, и помечается помеченной только в конце исполнения.
На исправление понадобилось два дня: для задач, отправляющих SMS, email и пуши, изменилась логика пометки задачи выполненной: пометка стала делаться в самом начале выполнения. В терминах распределенных систем, Вася перешел от "at least once delivery" к "at most once delivery". Были настроены мониторинги, продуктово было согласовано, что недоставка нотификаций лучше, чем их дублирование.
Заключение
С помощью выдуманных историй я попытался объяснить, почему так важно, чтобы API были идемпотентными. Показал, какие есть нюансы на практике.
В Яндекс.Такси мы всегда думаем об идемпотентностью наших API. В небольшом проекте допустимо было бы не тратить время на проработку редких случаев. Но Яндекс.Такси это десятки миллионов поездок ежемесячно. Поэтому у нас есть процедура дизайн-ревью архитектуры и API. Если что-то неидемпотентно, есть гонки, либо логические проблемы, то API не пройдет ревью. Для разработчиков это означает, что приходится внимательно относиться к деталям и продумывать множество граничных случаев. Это нетривиальная задача, и особенно сложно покрывать такие граничные случаи автотестами.
Случаются ли таймауты, перезапросы, дубли, когда у приложения нет миллионов пользователей? К сожалению, да. Описанные ситуация типичны для распределенных систем: сетевые ошибки происходят регулярно, железо регулярно выходит из строя и т.д. Хорошо спроектированная система считает подобные ошибки нормальным поведением и умеет их компенсировать.
Автор: Денис Исаев