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


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


image


Про API


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


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

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


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

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


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

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


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


image


В 8 утра Васю разбудил звонок от саппорта: двое пользователей пожаловались на то, что к ним приехало две машины вместо одной, и деньги списали за обе машины. Быстро делая кофе, Вася сел за ноутбук, подключился по VPN и начал копать логи, графики и код. По логам Вася обнаружил, что у этих пользователей было по два одинаковых запроса с разницей в несколько секунд. По графикам он увидел: в 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 будет идемпотентно с точки зрения состояния сервера (заказ один, с ним ничего не происходит), а с точки зрения клиента поведение существенно разное.


Также Вася узнал, что 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.


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 версию, о которой он знает. Сервер атомарно с изменением увеличивает версию при любых изменениях заказов (создание, отмена, редактирование). То есть приложение в запросе к серверу говорит ему, какое состояние заказов оно знает. И если это состояние заказов (версия) расходится с тем, что хранится на сервере, то сервер отдает ошибку «заказы были изменены параллельно, перезагрузите информацию о заказах». Версионирование решает обе найденные проблемы, и именно его Вася с Федей и поддержали. Также стоит отметить, что версия может быть как числом (номером последнего изменения), так и хэшом от списка заказов: так, например, работает параметр fingerprint в Google Cloud API для изменения тегов инстансов.


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


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


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


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


Отмена заказа делалась через запрос 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


В приложении пассажир может изменить точку 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.


image


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


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


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

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


Все проверил


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


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


Дубли SMS


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


image


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


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


Заключение


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


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


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

Комментарии (125)


  1. KonkovVladimir
    12.03.2019 13:21

    Я все ждал что в конце концов Вася перейдет с Postgresql на MySQL…


    1. kzhyg
      12.03.2019 13:25

      Зачем?


      1. AotD
        12.03.2019 13:58

        Возможно отсылка к метаниям Uber с mysql и postgresql =)


        1. RiseOfDeath
          13.03.2019 09:56

          А что там было?
          Я как-то на докладе слышал историю от mail.ru про их переход с mysql на postgesql. В числе прочего там были забавные баги mysql (вроде повтора значений, в некоторых условиях, автоинкрементного поля)


          1. jirfag Автор
            13.03.2019 10:52

            Перевод статьи про миграцию Uber с Postgres на MySQL


          1. AotD
            13.03.2019 11:14

            Там ребята давным давно сидели на Postgres, потом им захотелось в микросервисы и MySql. А потом подумалось — почему б с нова не пересесть с иглы MySQL на православный Postgres, но наверченная архитектура и специфичные вещи MySQL в дополнение к особенностям Postgesql с репликацией (и не только) заставили отказаться от этого решения.
            Вольный краткий пересказ, на истинность не претендую =)


    1. zzzmmtt
      12.03.2019 14:28

      Или откажется от REST в пользу RPC (SOAP).


      1. armo
        12.03.2019 15:33
        +1

        Поясните, как бы это решило проблемы, описанные в статье?


        1. zzzmmtt
          12.03.2019 15:36

          Примерно также, как переход с постгри на мускуль. Правда попутно ещё бы добавило боли в разработке как сервера, так и клиента (но это не точно).


          1. rstm-sf
            12.03.2019 17:28

            Извиняюсь за оффтоп, но


            постгри

            так лучше не писать и не говорить


            PostgreSQL создана на основе некоммерческой СУБД Postgres… более раннего проекта Ingres… Название расшифровывалось как «Post Ingres»

            https://ru.wikipedia.org/wiki/PostgreSQL


            1. zzzmmtt
              12.03.2019 17:37

              Добавлю оффтопа тогда, MySQL некоторые называют майсиквел, вот так, как мне кажется, точно лучше не говорить (да и мускуль тоже не очень то хорошо, если уж на то пошло).
              Да я просто привык уже говорить мускуль и постгря, это большинству разрабов сталкивавшихся с СУБД понятно. Многие разрабы, с которыми мне доводилось работать, говорили либо постгря, либо постгрес, лишь единицы называли полностью постгреэскуэл(ь), и ни разу не слышал «Пост-Грэс-Кью-Эл»


              1. haoNoQ
                12.03.2019 18:21

                Википедия вполне признаёт /?si?kw?l/ "sequel":


                SQL was initially developed at IBM by Donald D. Chamberlin and Raymond F. Boyce after learning about the relational model from Ted Codd in the early 1970s. This version, initially called SEQUEL (Structured English Query Language)...


              1. kost
                12.03.2019 21:18
                -1

                мускуль и постгря, это большинству разрабов...

                Вы добавили непонятное слово «разраб».


                1. nfw
                  13.03.2019 09:23

                  Что тут не понятного, раб на один раз.


            1. 2PAE
              13.03.2019 11:35

              Россию тоже не принято называть Рашей, однако, кому это мешает?

              image

              Везде есть свой сленг. И ИМХО по русски более правильно будет сформированное слово «постгри» чем «постгрес». Но это тема другого сайта.


  1. n0wheremany
    12.03.2019 13:49

    Вопрос по архитектуре: Почему не использовать в данном случае локальную базу и синхронизацию с сервером? Есть какие-то подводные камни?


    1. jirfag Автор
      12.03.2019 14:06

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


  1. martyncev
    12.03.2019 14:26

    Васю то хоть не уволили в итоге?


    1. Daniyar94
      12.03.2019 16:12

      Из статьи стало понятно, что Васе нужен опыт


      1. martyncev
        12.03.2019 20:43
        +3

        Согласен, но для стажера он мыслит весьма не плохо) Раз умеет решать проблемы по мере поступления… У нас стажеры часто вступают в ступор…


      1. QtRoS
        13.03.2019 00:25
        +4

        Что-то мне подсказывает, что за Васей скрыт реальный опыт разработчиков API такси…


        1. Stas911
          13.03.2019 06:53

          Кто ни разу не ронял прод — пусть первый бросит в меня камень…


        1. jirfag Автор
          13.03.2019 10:06

          Опыт около-реальный и не только по работе в Яндекс.Такси. Но в Яндекс.Такси до продакшена при мне ничего из подобного не доходило, все отлавливалось на этапе дизайн-ревью.


  1. Roman_Ryzhiy
    12.03.2019 14:57

    Насколько я помню, ваши коллеги из Яндекс.Кассы проблему решили на высоком уровне kassa.yandex.ru/docs/checkout-api/#idempotentnost


  1. redmanmale
    12.03.2019 15:04
    +3

    продуктово было согласовано, что недоставка нотификаций лучше, чем их дублирование

    Мне кажется, лучше получить две смски о том, что такси приехало, чем ни одной.


    1. nonname
      12.03.2019 16:22
      +1

      Ну я бы поспорил. Если брать такси, то допустим мы можем потерять 1% нотификаций, при этом за рулём такси у нас пока что не робот и если что он позвонит клиенту, или дать ему кнопку повторной нотификации. Так же клиент обычно ждет такси и сам поглядывает где там оно и ему эти уведомления тоже не критичны, получается что проблемы это если и создает, то не очень сильно. Зато получить 100 дублей раз в минуту, часа в 3 ночи, когда уже лёг спать.


      1. vintage
        12.03.2019 19:32

        Если сообщение не жалко потерять, то его не стоит и посылать. Конкретно с ЯТ-ом меня подбешивает получать по 4 нотификации после каждой поездки. И оценку поставь, и выписку глянь, и от банка уведомление о списании почитай, а списаний было аж 2 штуки.


      1. Matisumi
        13.03.2019 13:48

        Зачем вызывать такси, если уже лег спать?


        1. abmanimenja
          13.03.2019 13:54

          Зачем вызывать такси, если уже лег спать?

          Гостям, чтобы они уже убрались добухивать у себя дома.


          1. Matisumi
            13.03.2019 14:44

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


            1. abmanimenja
              13.03.2019 15:58

              Дверь они тоже сами за собой запрут, как уйдут?)

              А что у вас она автоматически не захлапывается?


              1. Matisumi
                13.03.2019 16:08

                Нет, она захлопывается, но потянув за ручку извне ее можно открыть.


        1. nonname
          13.03.2019 15:24

          Вдруг вам во сне захотелось куда-то поехать.
          Ну а если серьезно, то уведомления могут приходить и после поездки, очевидно же.


    1. jirfag Автор
      12.03.2019 16:44

      Хорошее замечание, спасибо!

      В простейшей реализации выбор стоит не между 0 и 2 SMS, а между 0 и n (например, 100) SMS. Я сталкивался на одном из прошлых мест работы с тем, как пользователю приходят десятки сообщений из-за подобной логики.

      Можно пытаться запоминать в базе число попыток отправить SMS и применять at least once семантику на первые 3 попытки, а потом отбрасывать. Но здесь есть схожая проблема: что если произошел таймаут при запоминании факта попытки отправки SMS: выбирать at least once, или at most once семантику.

      Обычно, семантика выбирается в зависимости от важности конкретного типа SMS.


      1. Dogrtt
        12.03.2019 17:24

        Да уж, ждём 100% покрытие планеты ультранадежным и ультраскоростным соединением. Все беды от плохой связи…


        1. iivvaall
          12.03.2019 18:02

          Нет. Беда скорее всего в отсутствие идемпотентности в отправке СМС.


      1. Daniyar94
        12.03.2019 19:24

        Как Apache Kafka решила эту проблему? Они гарантируют exactly once semantics с версии 0.11


        1. arandomic
          12.03.2019 19:52

          Как то так
          На самом деле введением кучи ограничений на consumer-ов и небольшим количеством сносок к термину «exactly once»


        1. bgnx
          12.03.2019 19:57

          Exactly once требует согласованности продюсера и консьюмера. Если внешний апи на (на который происходит отправка смс) не предоставляет возможности передать локальный айдишник чтобы случае сбоя или обрыва связи проверить по нему произошла ли обработка запроса или нет то exactly onсe семантику в этом случае никак не получить. А kаfka гарантирует exactly once семантику только между своими же нодами.


  1. CHolfield
    12.03.2019 16:07
    -2

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


    1. Matisumi
      13.03.2019 14:31

      Ух ты вау, в институтах учат рисовать на блок-схеме сбои сети и таймауты?


      1. CHolfield
        13.03.2019 14:37

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


  1. Dogrtt
    12.03.2019 17:21

    Да уж, век живи…
    Вот, когда читал доки мелкомягких по REST API, особо не обратил внимание на принцип идемпотентности, а зря. Казалось бы, ан нет…


  1. Occama
    12.03.2019 18:06

    По-хорошему, Васе нужно было на нулевом шаге сообщить менеджерам, что нужен аналитик и, в идеале, архитектор, чтобы Васе, вместо экстренного гуглежа всего подряд после очередного бага, нужно было бы просто запрограммировать формализованную бизнес-логику =) Понятно, что это исключительно иллюстративно, однако не дай Кнут программисту с опытом меньше ~10 лет влететь на такую позицию. Да и с опытом лучше не надо.


    1. abmanimenja
      13.03.2019 09:08

      По-хорошему, Васе нужно было на нулевом шаге сообщить менеджерам, что нужен аналитик

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


      1. Occama
        13.03.2019 09:21

        Если вам нравится работать практически без ТЗ — это не значит, что это хорошая практика =)


  1. bgnx
    12.03.2019 19:06
    +1

    Мне кажется в плане решения проблем с идемпотентностью http и rest вносят большую путаницу чем rpc. С rpc все запросы по умолчанию неидемпотентны и это заставило бы Васю уже на этапе проектирования задуматься о повторных запросах из-за плохой связи. И с rpc такая проблема решается даже проще чем c http, ее можно вообще решить на уровне транспорта и упростить бизнес-логику. С rpc можно использовать вебсокеты которые в отличие от http имеют строгую очередность — запросы приходят на сервер точно в таком же порядке в каком были отправлены от клиента. А это значит что нам не нужно хранить день или сколько там idempotency key от всех http-запросов клиента а достаточно хранить только айдишник последнего запроса клиента и потом когда клиент после разрыва связи снова соединится с сервером он должен в первую очередь загрузить этот айдишник и проверить совпадает ли он с последним отправленным запросом и если совпадает тогда отменить запросы которые ждут повторной отправки. И все это можно решить на уровне транспорта и автоматически ретраить запросы при обрыве связи а на уровне бизнес-логики будет просто отправка запроса и отображение крутилки пока этот запрос не выполнится


    1. Stas911
      13.03.2019 06:59

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


  1. alexhott
    12.03.2019 20:11

    1 приложение при отправке запроса генерит уникальный ключ
    2 Приложение получает ответ-подтверждение запроса (в ответе содержится уникальный ключ)
    Если ответ не получен — повторяем запрос, пока не получим ответ(естетвенно обрабатываем ошибки таймауты и т.п. чтобы пользователь не озверел от ожидания)
    3 показывает пользователю что заказ принят
    4 Получив ответ посылает запрос об успешном получении ответа с заказом
    5 сервер считает заказ принятым только если от приложения пришел запрос об успешном получении ответа
    шаги 4 -5 могут идти фоново, пользователь думает что все в порядке
    приложение периодичеки обновляет информацию о заказе
    если шаг 4 так и не прошел до сервера, то через 1-2 минуты статус заказ меняется на ошибку(очень маловероятно). Если пользователь закрыл приложение а заказ не потвердился — звоним ему, пишем смс


    1. jirfag Автор
      12.03.2019 20:33
      +1

      Кажется, вы пытаетесь решить задачу двух генералов :)

      Здесь та же проблема: на шаге 5 сервер мог получить подтверждение о получении заказа, но клиент об этом не узнает (не получив ответ). Если тогда через 1-2 минуты клиент покажет ошибку — пользователь подумает, что такси заказать не удалось, но машина неожиданно приедет.


      1. alexhott
        12.03.2019 21:04
        -2

        вероятность 0,0001% считаю допустимой


        1. abyrvalg
          13.03.2019 05:10

          При 10.000.000 заказов в месяц это 1000 «обманутых» клиентов.
          Если каждый из них позвонит в техподдержку и проматерится всего лишь 3 минуты, нагрузка на колл-центр будет 1000*3/60 = 50 часов.
          Процентов 20 из этих клиентов рано или поздно оставит в интернетах комментарий в стиле «вот уроды», процентов 5 не поленится поставить приложению минимальную отметку. Счастливчики, попавшие на этот баг второй раз, 100% уйдут к конкурентам.


          1. alexhott
            13.03.2019 10:21

            при 10 млн заказов 50 часов работы колцентра это мизер
            а нарвавшихся 2 раза будет 10 из 10 млн
            если они такие везунчики то уйдя к конкурентам нарвутся итам на что-нибудь


            1. abyrvalg
              13.03.2019 10:43
              +1

              Можно, я буду пересылать вам вакансии своих конкурентов? Вдруг какая понравится...


  1. musicriffstudio
    12.03.2019 21:18

    все намного проще.

    Клиент выгружает не заказ, а заявку на создание заказа.

    А создание заказа по заявке это отдельное действие которое сервер может делать сразу или хоть по шедулеру раз в сутки. С проверками дублей заявок или еще чего-то. С обновлением статуса накопившихся заявок или еще какой дополнительной работой.

    Вы недоотделили клиента от сервера.


    1. jirfag Автор
      12.03.2019 21:57
      +1

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

      Не понимаю идеи, чем заявки на создание заказа помогают?


      1. musicriffstudio
        12.03.2019 22:53

        тем что это отдельный процессы

        7. по этой дублирующей заявке позже приезжает второе такси


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

        Второе такси приедет если вы намеренно отключите все проверки.

        В вашем же варианте куча каких-то ухищрений и странных терминов.


        1. jirfag Автор
          12.03.2019 23:26

          на любое количество одинаковых заявок создается один заказ

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


          1. musicriffstudio
            12.03.2019 23:50

            адрес и фамилия клиента. Если на него уже есть незавершенный заказ то новый не создавать, заявке отказать.

            Куда проще-то.

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


            1. jirfag Автор
              13.03.2019 00:01

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


              1. musicriffstudio
                13.03.2019 00:16
                +2

                вобщет это работает и работало всегда.


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


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


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


                1. Maximuzz
                  13.03.2019 02:27

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


                  1. musicriffstudio
                    13.03.2019 10:00

                    существует проблема отображения бизнес-логики на код.


                    1. Maximuzz
                      13.03.2019 16:16

                      есть ощущение как пользователя, что все как то не очень в яндексе с этим делом.


                1. BOM
                  13.03.2019 14:21

                  Case 1:
                  Клиент заказал такси, а затем кто-то из его же дома и квартиры попросил его заказать еще одну машину. А потом еще одну. Абсолютно нормальная ситуация, которая иногда случается. По-крайней мере, у меня. Система ругается и говорит, что это невозможная ситуация и приезжает одна машина.

                  Case 2:
                  Человек встречает гостей и в конце вечера трое из них независимо друг от друга заказывают такси. Приезжает одно такси на всех, потому что у всех один адрес.


                  1. musicriffstudio
                    13.03.2019 15:10
                    -3

                    вероятно вы не понимаете каких-то базовых вещей.

                    Прием заявки это отдельная маленькая операция — просто принять как есть и положить в базу.

                    А уж создание заказа по заявкам это отдельный процесс, со всеми проверками, возможно продолжительный по времени.

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


                  1. abmanimenja
                    13.03.2019 15:59

                    Абсолютно нормальная ситуация, которая иногда случается. По-крайней мере, у меня

                    Конечно, разъезжаются люди с вечеринки, например.


                    1. hoobastank
                      13.03.2019 20:17

                      Или с концерта/футбола (для большей масштабности).


                  1. Andruh
                    13.03.2019 18:24

                    Case 1 (несколько машин от одного клиента)
                    Отображать клиенту что одна машина заказана, перед оптправлением заявки спрашивать: хотите заказать 2-ю машину, отправлять на сервер заявку на заказ _второй_ машины.

                    Case 2
                    Как уже было сказано, заявка идентифицируется id клиента и адресом, три человека получат три разных машины.


              1. keydet
                13.03.2019 01:25

                order handling можно разбить на order negotiation и order fulfillment. Таким образом, заказ на третью машину будет принят, но его можно результировать в нотификацию о лимите вместо выполнения.


              1. Andruh
                13.03.2019 18:20
                +2

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


      1. izyk
        12.03.2019 23:48

        А если как в TCP сделать? Интернет работает, никто не жалуется.
        Клиент заказ N1 -> Сервер подтверждение N1 -> Клиент адрес для N1 -> Сервер номер такси для N1 -> Клиент отобразил заказ.
        Первые два этапа установление соединения, только потом данные или разрыв соединения по таймауту или инициативе клиента, сервера. Клиент может одновременно открыть хоть 1, 2… 10 заказов ничего страшного. Все заказы уникальны, сервер и клиент знают их номера, договариваются на первых двух этапах.


        1. jirfag Автор
          12.03.2019 23:58
          +1

          Интересная аналогия.
          Если сравнивать операцию создания заказа с открытием TCP соединения, то в случае ее неидемпотентности либо приезжает лишнее такси, либо создается лишнее соединение. Первое намного критичнее второго.
          Если сравнивать операцию изменения заказа с посылкой данных по TCP соединению, то у TCP есть sequence numbers (в какой-то степени аналогия с версионированием списка заказов и ключа идемпотентности) для защиты от дублирования, переупорядочивания и тп.


          1. izyk
            13.03.2019 00:24

            либо создается лишнее соединение
            Исключено т.к. соединение либо создается, либо нет, и такси ищется только один раз. Пока клиент не получит номер такси, он номер заказа не меняет. Только если решил сделать ещё один заказ, установить два соединения.
            Клиент заказ N1 -> Сервер подтверждение N1 -> Клиент адрес для N1 -> Сервер (соединение установлено) Ищем такси -> Клиент (соединение установлено) отобразил заказ, номер такси.

            Только, если клиент действительно хочет сделать два заказа, клиент работает с двумя номерами заказа. Повторюсь, пока клиент не получит номер такси, он номер заказа не меняет.
            Для защиты еще можно ввести команду список заказов, но это, возможно, лишнее. А вот PING заказа на сервере, каждые 1-2 минуты клёвское дело, тем более статус заказа в любом случае мониторить.


      1. DarkWanderer
        13.03.2019 10:37

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


      1. Durimar123
        13.03.2019 17:46

        Ввести такое понятие как кеш заявок — это строка из суммы кодов всех текущих заявок.
        Он(кеш) отправляется при каждой посылке пакета в обе стороны, клиенту для установки, на сервер для контроля.

        при п1 — кеш = ""
        при п5 у клиента кеш = "" а на сайте «заявка1фывфвыфв»
        из за разности кеша заявка 5 не выполняется и клиенту отсылается текущее состояние с текущем кешом «заявка1фывфвыфв»


  1. ecmaeology
    12.03.2019 22:20

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


    1. jirfag Автор
      12.03.2019 23:44
      +1

      Спасибо! Я дописал в этот абзац следующее предложение:

      Также стоит отметить, что версия может быть как числом (номером последнего изменения), так и хэшом от списка заказов: так, например, работает параметр `fingerprint` в Google Cloud API для изменения тегов инстансов.


    1. vassabi
      12.03.2019 23:56

      … прямо мини-блокчейн «на двоих»! (только логика не «большинство узлов», а «сервер главнее»)


  1. kirillmesyats
    12.03.2019 23:29

    Не пойму почему в случае со сценарием 2, когда отменили такси, оно приедет… Ведь запись удалили из БД.


    1. jirfag Автор
      12.03.2019 23:46

      На шаге 3 клиент делает повторный запрос заказа такси, так как на шаге 1 клиент не получил ответа от сервера. И повторный запрос успешно выполняется, что приводит к приезду такси.


      1. kirillmesyats
        13.03.2019 07:16

        Вы наверно про другую ошибку, когда приехало 2 такси. Я говорю про случай, когда клиент сделал заказ, получил ошибку, потом или он через пуш или диспетчер отменили заказ, он удалился из бд а клиент сделал новый заказ. Почему приехало отменённое такси если записи в бд нету?


        1. jirfag Автор
          13.03.2019 09:15

          сценарий 2 сюда скопирую для удобства:


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

          Почему приехало отменённое такси если записи в бд нету

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


        1. aleztr
          13.03.2019 10:16
          +1

          Клиент — это приложение, а не пользователь.
          То есть, на шаге 3 приложение отправило повторный запрос и создало еще один заказ.
          Т.к. клиент (приложение) не в курсе того, что заказ был создан на шаге 1.


          1. jirfag Автор
            13.03.2019 10:27

            точно, спасибо, оно вносило путаницу, переименовал почти везде в статье клиент в приложение.


  1. Thoth777
    13.03.2019 00:12
    +1

    Каждая команда (на клиенте) имеет свой уникальный уид, и вот как делаем:

    — Отправка заказа на сервер: клиентское приложение стучит на сервер до тех пор, пока не сработает заранее определенный таймаут или не получит нужный ответ
    — На сервере проверяем есть ли уже команда с таким уид, если нет, то выполняем
    — Если это команда на создание заказа, выставляем ее статус как пока не подтвержденная
    — Клиентское приложение спрашивает о состоянии заказа с уид: его просит подтвердить заказ (приложение должно само отправить true например). Заказ помечается как созданный и по нему дальше производятся действия.

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

    Для клиента это будет выглядеть как спиннер, который «подождите, проводится операция», который потом меняется или на «таймаут, нажмите снова» или на «ок, выполнено».

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

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


    1. jirfag Автор
      13.03.2019 09:33

      Уникальный уид у каждой команды — аналог ключа идемпотентности из статьи.


      — На сервере проверяем есть ли уже команда с таким уид, если нет, то выполняем

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


      — Клиентское приложение спрашивает о состоянии заказа с уид: его просит подтвердить заказ (приложение должно само отправить true например). Заказ помечается как созданный и по нему дальше производятся действия.

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


      1. Thoth777
        13.03.2019 16:34

        Для этого нужна распределенная транзакция, что далеко нетривиально.

        Использовать key-value хранилище, умеющее в шардинг, их много на выбор.

        Если пользователь должен интерфейсно нажать какое-то подтверждение у заказа, тогда это защитит от ряда дублей, но ценой лишнего клика.

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

        Все сценарии можно проработать.


        1. jirfag Автор
          13.03.2019 17:30

          Использовать key-value хранилище, умеющее в шардинг, их много на выбор.

          Чем шардинг тут поможет? Как вы атомарно запишите в две разные субд? Либо вероятностный 2PC использовать, либо через очередь добиваться eventual consistency, либо подобные варианты, это сильно сложнее описанного в статье.


  1. TimsTims
    13.03.2019 00:38

    Но мультизаказ полностью ломал Васину схему защиты от дублей
    А разве нельзя было сделать так: раз приложение знает, что у же есть какой-то заказ, а пользователь хочет сделать второй, то этот самый пользователь указывает: «да, я хочу вторую машину». Приложение заказа такси шлёт в api дополнительный параметр: это дополнительная вторая машина &additional_taxi=2.
    Бэкенд делает такую блокировку:
    UPDATE active_orders SET n=2 WHERE user_id={user_id} AND n=1;
    И если всё ок, то делает второй заказ. Нужен третий? Делаем n=3. Четвертый? Да пожалуйста! Пока-что не вижу здесь никаких проблем.
    И у нас больше нет проблемы с ключами идемпотентности, т.к. сразу с тех пор, как вы их внедрили, у вас кажется появилось еще больше проблем, чем было до этого.
    Например вот:
    Например, при повторном вызове идемпотентного API создания заказа — заказ не будет создаваться еще раз...
    Вы придумали использовать уникальный ключ идемпотентности, который будет одинаковый для всех заказов этого клиента в течение 24 часов. То есть придумываете ID, который должен быть каждый раз уникальным, но он у вас не уникален. А затем вы боретесь с тем, что этот ваш неуникальный ключ всегда повторяется, и используете кучу костылей для исправления этой неуникальности.
    … Вася предложил Феде генерировать новый ключ идемпотентности в таком случае. Но Федя объяснил, что тогда может быть дубль: при сетевой ошибке запроса создания заказа клиент не может знать, был ли действительно заказ создан.
    Как по мне, это самая хорошая идея — генерировать новый ключ, но не при каждом чихе, а лишь когда сервер точно-точно ответил, что всё ок, заказ создан, и что теперь то можно клиенту наконец-то сгенерировать новый ключ для всех будущих обращений. А если связь лаганула, ответ от сервера не дошел до клиента, и клиент не знает, что заказ сделан, то при следующем возобновлении связи, либо каждые N секунд спрашивать у сервера: «Ну что там с моим ключем идемпотентности? Заказ в итоге создался? А то ответа от тебя не получил...» и в любом случае ответа сервера(да заказ создался/нет заказ не создался), сгенерировать новый ключ для будущих заказов.
    А то вы сначала делаете уникальный ID, а затем мучаетесь от того, что этот ID неуникален следующие 24 часа, и снова вводите почти те же костыли что и вводили до этого.


    1. Thoth777
      13.03.2019 01:34

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


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

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


    1. jirfag Автор
      13.03.2019 09:54

      Спасибо, схема с &additional_taxi={n} выглядит интересно. Подозреваю, что дубли и гонки могут быть в сценариях подобных такому:


      1. пользовател создал заказ №1
      2. пользователь отправляет запрос на создание заказа №2 с параметром &additional_taxi={2}
      3. заказ создается, ответ от сервер не доходит до клиента, клиент начинает ретраить запрос
      4. параллельно диспетчером/водителем отменяется заказ №1, что уменьшает n в таблице active_orders c 2 до 1
      5. запрос на создание заказа №2 отретраился и успешно создал дубль

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

      Не совсем: ключ идемпотентности надо генерировать новый под каждый уникальный (в интерфейсе) заказ, а не использовать один для всех заказов.


  1. nanshakov
    13.03.2019 00:58

    Здравствуйте. Спасибо за статью. С помощью какого интструмента можно эмулировать сетевую ошибку ?


    1. jirfag Автор
      13.03.2019 10:01

      Здравствуйте.
      Во-первых, стоит эмулировать как медленные запросы, приводящие к таймаутам, так и просто быстрый connection refused.
      Во-вторых, для ручного эмулирования плохой сети на серверной машине я обычно использовал iptables, есть еще netem.
      В-третьих, в девелоперской консоли хрома можно сэмулировать медленное мобильное соединение.
      В-четвертых, можно протестировать один раз потерю пакетов, но код дописывается постоянно, баги возникают постоянно, и оно уже не тестируется. Есть chaos engineering: эмулировать сетевые ошибки (и не только: падения сервисов и тп) в продакшене регулярно. Например, есть chaos monkey и chaoskube.


    1. babylon
      13.03.2019 22:17
      +1

      Статью однозначно в закладки, как и автора. Видно практика!


  1. lil_Toady
    13.03.2019 03:03

    Подозреваю что описан ваш реальный опыт, так как попадался на двойной заказ такси с Uber Russia в декабре:
    Вызвал такси, через пару минут приложение перестало показывать водителя, подумал что водитель отменил, а я не заметил сообщения, заказал еще раз. В итоге в истории две поездки с разницей в несколько минут: первая показывает правильные адреса, но оборвалась на пол пути; вторая доехала куда надо, но в адресах показывает что заказ был с запада Москвы на юг Гонг Конга.


    1. jirfag Автор
      13.03.2019 10:05

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


  1. kashey
    13.03.2019 04:57

    Помню как «Федя» это волшебное слово повторял 10 раз в день(грустно вздыхая) и даже в викторинах загадывал. Давно это было, но все мучает вопрос — долгое ли время проблемы, описанные в статье, оставались проблемами?


    1. jirfag Автор
      13.03.2019 10:09

      Если вы про продакшн Яндекс.Такси, то не видел чтобы подобные проблемы проходили дальше дизайн-ревью. История все таки выдуманная и около-реальная.


  1. vintage
    13.03.2019 08:15
    +2

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


    Однако гуиды, хеши… блокчейн бы ещё сюда прикрутили. Всё, что тут надо — это отразить предметную область в ресурсах. При работе с активными заказами пользователь не "создаёт заказы", а "вызывает такси номер 1", "номер 2" и тд.


    PUT /taxi/1
    
    when \2019-01-01T01:00:00+03:00
    from \Москва, ул. Садовническая набережная 82с2
    to \Аэропорт Внуково

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


    PUT /taxi/2
    
    when \2019-01-01T02:00:00+03:00
    from \Аэропорт Внуково
    to \Москва, ул. Садовническая набережная 82с2


    1. jirfag Автор
      13.03.2019 10:18

      Пользователь вызвал такси (у него номер 1), заказ завершился, после этого пользователь еще раз вызвал такси (у него тоже номер 1). Как теперь поменять для самого первого заказа оценку, например? PUT /taxi/1 будет менять уже другой заказ.


      1. vintage
        13.03.2019 11:42

        Это уже поездки:


        GET /trip
        
        /trip/34676374
            departure_at \2019-01-01T02:00:00+03:00
            arrived_at \2019-01-01T03:00:00+03:00
            from \Аэропорт Внуково
            to \Москва, ул. Садовническая набережная 81
            vote 3
        /trip/652455
            departure_at \2019-01-01T01:00:00+03:00
            arrived_at \2019-01-01T02:00:00+03:00
            from \Москва, ул. Садовническая набережная 82с2
            to \Аэропорт Внуково

        PUT /trip/652455
        
        vote 5

        Обратите внимание, что структура данных у них совсем разная.


    1. mayorovp
      13.03.2019 16:04

      Лучше всё-таки создавать заказы, а не вызывать такси. Потому что заказ и его номер — вещь интуитивно понятная, а такси и его номер — что-то странное.


      1. vintage
        13.03.2019 18:01

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


        1. mayorovp
          14.03.2019 09:20

          В таком случае у вас появляется новая сущность — «номер слота». Зачем?


          1. vintage
            14.03.2019 09:33

            Странный вопрос. Вся статья о том зачем всё это нужно.


            1. mayorovp
              14.03.2019 09:35

              Не увидел в статье ничего про «слоты». Про слоты только вы в комментарии написали.


              1. vintage
                14.03.2019 09:49

                Слоты отвечают на вопрос «Как?», а не «Зачем?».


                1. mayorovp
                  14.03.2019 10:06

                  На вопрос «как» отвечает ваше предложение использовать оператор PUT вместо POST.

                  Так всё-таки, зачем слоты? Что в них удобного? Чем плох генерируемый на клиенте номер заказа без возможности переиспользования?


                  1. vintage
                    14.03.2019 11:43
                    -1

                    Может тем, что пользователь думает в терминах слотов, а не заказов. Для него это «первое такси», а зачастую и «единственное такси», а не «заказ номер 491857881».


                    1. mayorovp
                      14.03.2019 13:11

                      Вы каких-то странных пользователей находите.


                      1. vintage
                        14.03.2019 17:04

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


                        1. mayorovp
                          14.03.2019 17:19

                          А зачем пользователю видеть номер этого заказа?


  1. Thisnickname2019
    13.03.2019 10:19

    А если клиенту показать всю кучу заказов, и пусть ручками удалит не нужные?


    1. jirfag Автор
      13.03.2019 10:22

      Пользователь может не заметить — он уже мог свернуть приложение, телефон мог сесть.


  1. binque
    13.03.2019 10:35

    Приложение, не уведомляя пользователя, сразу сделало перезапрос и получило 404

    А зачем делать повторный запрос, не уведомляя пользователя? Таймаут — это, скорее всего, проблема с соединением, которую должен решить пользователь. Подойти к окну, включить вайфай. Если ответы так и не будут доходить, то рано или поздно об этом все равно придется сообщить пользователю. И лучше сделать это раньше, чтобы не заставлять его ждать впустую, не питать ложных надежд, не заставлять перезапускать приложение. Тогда бы и этой проблемы с появлением ошибки при успешном выполнении не возникло бы.
    Я просто хочу понять вашу логику. За пост спасибо, интересно.


    1. jirfag Автор
      13.03.2019 10:39

      На масштабах Яндекс.Такси таймауты происходят много раз каждую секунду даже при взаимодействии между микросервисами: где-то у сетевиков роутер подлагнул, где-то на сервере базы данных обращение к диску тормозит из-за битого сектора, где-то CPU перегружен у машины и тд.
      Если рассмотреть пользователя такси, то это обычно мобильное приложение. Мобильная сеть обычно ненадежна и имеет большой latency. Легко попасть в зону плохого покрытия, зайти в переход. Особенно проблема актуальна в городах/странах где плохо с 3G.


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


      1. binque
        14.03.2019 13:03

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


        1. abmanimenja
          14.03.2019 13:07

          то как часто ему приходится делать запросы повторно?

          Часто. Очень часто.


  1. Scf
    13.03.2019 10:59

    Более простое решение — не показывать сетевые ошибки в GUI. Если до сервера достучаться не удалось, отображать "заказ отправляется" и дать возможность отменить отправку заказа. В логике, соответственно, повторять идемпотентный запрос до получения ответа от сервера. Если пользователь отменил отправку заказа, отправить запрос отмены заказа, так же с ретраями.


  1. Exponent
    13.03.2019 12:46

    Вася бы мог обучить ИИ, который бы учитывал особенности бизнеса, и все запросы клиентов пропускать через ИИ. Тем более что у Васи есть куча собранной информации, есть на чем учить. И далее Васе было бы проще менять логику.


  1. vlsinitsyn
    13.03.2019 12:55

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

    А вот случае такси, как такое возможно? Что мешает сделать уникальный ключ в таблице по клиенту, from, to, и доверься надежности БД? Если insert прошел — ок. Если нет — ошибка -дубликат. Про мульти заказ не понятно тоже. Если подразумевается заказ на разные адреса, это это просто два разных заказа. Если по одному маршруту — так это просто атрибут типа автомобиля в одном заказе (типа сколько человек надо перевезти). Можно чтобы мини бас приехал и всех забрал, а можно чтобы одна или несколько машин. Клиенту это параллельно. Ему важно сколько человек должны уехать.


  1. sneakyfildy
    13.03.2019 17:34

    А может помочь ситуации, если между клиентом и сервером гонять всегда полное состояние сущности «пользователь-заказы»? И пока в базе не закончится какой-либо длительный процесс, связанный с пользователем, не убирать флаг «in_progress» и таким образом никогда не разблокировать UI при текущем процессе?
    — И, соответственно, до получения состояния с сервера по умолчанию считать, что «заказ» невозможен.


    1. jirfag Автор
      13.03.2019 19:54

      Полная блокировка UI может решать часть проблем, в статье это и описано:


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

      Но есть и другие точки входа в изменение заказов:


      1. водитель/диспетчер меняет/отменяет заказ
      2. кнопки действий в пушах
      3. второе устройство


  1. Bsplesk
    14.03.2019 23:38

    Норм статья, но читать и описывать «алгоритмы» исключительно plain/text не всегда идеальный вариант. Всё таки стажёру Васи неплохо бы освоить язык блок-схем. Интересно в ya документацию ведут? или лучшая документация это код?