Отправляем запрос на 20 000 000 евро, на перестановку 900 ордеров на бирже. Что может пойти не так?
Сегодня я расскажу, как не терять пару миллиардов клиентских денег, когда уж очень нужно что-то массово сделать на бирже. Этот текст про неявную и, казалось бы, незаметную проблему, которая ждет нас в недрах работы с любыми запросами, которые могут исполниться не до конца – в частности, с HTTP-запросами. Удивительно, как мало об этой проблеме думают и насколько мало инструментов для её решения.
Задача была такова – реализовать массовое управления биржевыми ордерами, причём не только в рамках одной биржи, а в целом по всей планете. И чтобы оно точно отработало.
В повествовании будут клиенты, серверы и котики. С котиками всегда интереснее.
Варианты запросов
О запросах
Сначала определим какого типа запросы могут быть. Посмотрим со стороны клиента.
Для начала – GET запросы. С ними, чаще всего, всё ок. Обычно, полученное однажды, можно запросить ещё раз. В очень редких случаях мы имеем какие-то особые ссылки, которые можно запросить лишь один раз - в них содержатся уникальные коды восстановления и подобное. Но из-за поисковых роботов и прочих систем сбора данных, просто используют время жизни ссылки вместо количества посещений. В итоге GET-запросы обычно вполне безопасны и стабильны. Так что тут особо навороченной надежности не нужно. Если от клиента отправляется GET запрос и что-то пошло не так – клиент отправляет запрос ещё раз.
Но есть запросы, которые меняют наши данные. Обычно это что-то из POST, PUT, PATCH и DELETE. И вот тут повторная отправка данных может нам навредить. Что если это будет повторная покупка акций на бирже? Совсем не то что мы хотели.
Тонкости DELETE
С изменениями все понятно, но почему и удаления опасны? Может показаться что запросы типа удаления, в случае двойного запроса, нам не страшны. Но есть и негативные варианты.
Простой пример - очереди задач. Если нам нужно удалить задачу под конкретным уникальным номером – всё хорошо. Но клиент может отправить запрос на удаление первого элемента очереди. И, если что-то пошло не так, и запрос отправлен ещё раз - есть вероятность что вместо одной задачи мы удалим две. А если задача финансово-чувствительна – это недопустимо.
Именно по-этому, DELETE-запросы также попадают в категорию потенциально опасных при двойном исполнении.
О запросах меняющих данные
Тонких моментов много, даже в случае если клиент передает айди.
Возьмем тот же пример с очередью задач и решим отредактировать одну из них. Как нас может сломать двойное редактирование? Вроде как, айди задачи должно нас защищать – ну отредактируем дважды одно и тоже, результат то тот же.
Но увы - не всё так просто. Сервер может не только изменить данные, но и выполнить дополнительные действия. И, если действия в виде двойного логирования нас не пугают, то вот что-нибудь меняющее поведение - очень. Что если одно из полей это статус задачи, и, на смену статуса у нас припасена бизнес-логика? Код выполнится дважды.
Впрочем, мы можем извернуться и запретить исполнение кода если статус меняется на такой же. И всё будет хорошо до первой гонки потоков.
Веселье добавится, когда между двумя нашими запросами успеет вклиниться третий, от другого клиента, который поменяет данные на другое значение. А уж если бизнес-логика предполагает перемещение в какой-либо очереди...
В общем, можно долго придумывать костыли, но единственный безопасный способ – исключить ошибочное двойное исполнение в принципе. Нам нужно добиться того чтобы проблема двойного исполнения отсутствовала как класс.
Варианты ошибок
Два из трёх
Поговорим о вариантах с ошибками. Будем смотреть со стороны клиента.
Есть две основных ошибки, которые сразу приходят на ум.
Первый вариант - клиент отправляет запрос и он не доходит по какой-то причине. Сеть отвалилась в моменте, повредился пакет, слишком большой поток данных.
Второй вариант - серверный. Запрос дошел, но имеется проблема на серверной стороне: внутренняя ошибка и подобные серверные ситуации.
На самом деле, в обоих вариантах, проблем нет. Если клиент сделал запрос и не смог его отправить, то клиент просто отправит его ещё раз. Возможно проблемы с сетью, бывает. А в случае когда запрос дошел, но не был обработан на сервере из-за ошибки - клиент получит ответ с описанием ошибки. Делаем запрос ещё раз как только сервер будет снова в состоянии работать. Могут быть небольшие вариации, но общий концепт именно такой. Или запрос не дошел, или на сервере не прошел, всё ок, никаких двойных запросов... казалось бы.
Но дьявол кроется в деталях.
Детали
Главная боль находится в одном из незаметных шагов, который может свести с ума тех кто не сталкивался и не разбирал такие ошибки. Да, они редки. И вроде бы с виду очевидная вещь, но, чаще всего, скрывается среди других проблем.
Вот клиент отправляет запрос. Может произойти так, что он успешно дошел, сервер всё обработал, отправил ответ назад, и уже сам ответ не вернулся.
В качестве ответа, клиент получает ошибку сети, решает что запрос не обработан, и отправляет его ещё раз. Ведь это просто проблема с сетью, что может пойти не так? Тут то нас и поджидает сюрприз.
Запрос может быть вполне адекватным. По бизнес-логике нам может быть разрешено отправлять запросы дважды, с одними и теми же параметрами. Так было в примере с удалением первой задачи из очереди, параметры запроса не меняются. Но мы то хотели удалить лишь одну задачу.
Как отличить повторный запрос в ответ на ошибку и повторный запрос на ещё одно действие?
О коробочных решениях
В веб-фреймворках и библиотеках для запросов в сеть почти никогда не встречается инструментарий для решения такой проблемы. Вы сможете с ходу вспомнить где есть механизм защиты от этого? А ведь очень болезненная ошибка. Но при этом, реально редкая, слишком редкая для большинства сайтов.
В принципе, в “обычном интернете” и правда можно было бы таким пренебречь. Отправить два котика в ленту соцсети вместо одного - не выглядит фатальной проблемой. А вот два раза купить акций на внушительную сумму - уже повод задуматься.
Решения
Хорошая новость в том что решение всех этих проблем - существуют. Более того, оно достаточно протестировано чтобы использовать его в промышленном софте. Решение состоит из двух частей: первое это уникализация запросов, а вторая – повторение ответа для клиента.
Разберем обе части решения.
Уникализирование
Уникализация запросов – каждый запрос должен быть уникальным в рамках завершенного кванта взаимодействия. Завершенным будет такое взаимодействие, при котором был получен ответ о том, что запрос был обработан.
При этом не важно, произошла ли ошибка, но важно, чтобы это означало завершение кванта - сервер должен явно ответить об этом. То есть ошибка сети не считается завершением, как и 500: ошибка с неизвестным результатом. А вот ошибки из 4хх уже да, либо 5хх – с указанием что запрос был в обработке, но сервер ничего с ним не делал.
Это важный момент, потому что если квант не был завешен - нужно отправлять запрос до тех пор, пока не будет получен результат. Либо, пока мы не решим, что находимся в неопределенном состоянии, и нужно решать его другими способами, а запросы пора прекратить.
Простейшим решением будет снабжение каждого запроса UUID в параметре либо заголовке. Мы помечаем запросы и всегда знаем, чем в итоге всё закончилось. При этом, если сервер получает запрос с уже обработанным UUID - он не пытается второй раз сделать обработку, а отправляет кэшированный ответ, это как раз вторая часть решения - про повторение ответа.
Повтор ответа
Повторение ответа клиенту - мы не шлем ошибку в ответ, в случае, когда пришел запрос в том же кванте (с тем же UUID запроса и т.п.). Мы понимаем, что по каким-то причинам ответ не был доставлен, и отправляем его снова.
Это очень полезное поведение. Мы не терзаем клиента непонятными ошибками, потому что, по сути своей, данные были доставлены на сервер, обработаны сервером и результат был отправлен и доставлен назад клиенту. Отказ от ошибок такого рода резко улучшает взаимодействие.
Мы можем логировать повторный запрос или отправить метрику, например, чтобы инженеры сопровождения обратили внимание, если тысячи таких повторных запросов вдруг стали приходить на сервер. Возможно где-то отвал сегмента сети, но клиент в итоге прозрачно получает ответ и мы едем дальше.
При этом хранить ответы для повторного запроса вечно совсем не обязательно. Можно определить бизнес-кейсовый таймаут, это может быть как 10 минут, так и час, а может и день – всё будет зависеть от специфики. Но общая суть одна - какое-то время мы храним ответы на запросы. И, если клиент пришёл с тем же айди, мы вернем ему ответ для его запроса, даже если это произошло несколько раз.
Слои и безопасность
Бонус - двухслойное хранение. Может существовать двойной уровень кеша - срок хранения UUID может быть дольше, чем хранение ответов на запрос.
В таком случае, мы имеем возможность ответить клиенту в определенном временном промежутке. А если запрос пришел после удаления данных из кешей, но до удаления UUID - оповестить о не валидном запросе клиента. И, вероятно, оповестить и службу безопасности?
Возможно это баг, а может и MITM. Такое поведение можно расценивать, как подозрительную активность - возможно, кто-то перехватил данные и решил сходить с ними ещё раз, используя сессии и прочее. Можно использовать, как дополнительный пункт в системе безопасности.
Дополнительные варианты
UNIX-time
Существуют дополнительные варианты решения этой проблемы. Иногда можно встретить варианты когда вместо UUID используется таймштамп отправки запроса с клиента - UNIX дата в виде числа секунд, либо миллисекунд, с условной даты начала компьютерной эры.
Получается меньше байт данных чем UUID.
Есть один минус: может случиться коллизия, если запросы отправляются слишком часто.
Но, если сервер имеет рейт-лимит на количество запросов в секунду и подобные системы защиты от нагрузок - проблемы не будет.
Цикличное переполнение
Можно использовать инкремент числа с периодическим переполнением в ноль. Это ещё больше сэкономит трафик. Однако клиенту нужно помнить, что уже было отправлено, использовать счетчик.
Этот вариант для самых экономных. Можно сэкономить очень солидно в байтах, особенно если у нас не только HTTP. Но также не лишен минусов – требует дополнительного контроля. В момент исполнения и потока запросов нужно помнить о возможном конфликте. Меньше размер числа до переполнения - выше шанс коллизий.
Транзакционность
Иногда айди запроса используется ещё и для определения очередности операций - возможно, запрос не сразу выполняется и может быть отправлен по разным каналам с разным временем доставки. Либо какое-то время хранится в буфере/пулле входящих запросов. Тогда в момент окончательного исполнения запросы будут сложены в очередь по порядковому номеру и выполнены друг за другом.
Мы также можем обеспечить единственность исполнения, отклоняя запросы с одинаковым id, исполняя только самый первый или только самый последний из одинаковых. Такое можно увидеть в больших распределенных системах. Но там первичной может быть чуть иная идея – обеспечение транзакционности, а бонусом - решение и нашей проблемы удвоения запросов.
Без кэшей?
Мы можем попытаться придумать вариант без хранилища кэшей ответов. Мы сначала в одну сторону отправляем запрос, в ответ получаем то что сервер принял этот запрос, ждем на сервере ответа от клиента, что он принял ответ об успешном запросе...
И по итогу сколько бы мы таких уточняющих запросов не слали - так не сработает, потому что один из запросов может оставить нас в непонятном состоянии.
Потому кэши нам точно нужны.
На клиенте
Кстати, использовать наш подход мы можем и на стороне клиента, будь то сайт или мобильное приложение, или десктоп.
Из возможных проблем – работа с нестабильными бекендами, которые по какой-то причине могут присылать данные дважды: например, нотификации.
Если мы не можем контролировать отправку - мы можем контролировать получение. И причины могут быть теми же - потерей информации об успешной доставке где-то по пути.
Стандарты
Для идентификаторов запросов в рамках самого HTTP есть условно стандартизированный заголовок X-Request-ID. Можно включать его в запросы и ответы.
Однако, вариант с айди внутри тела также распространен - у нас не всегда может быть только HTTP, либо наша бизнес-логика предполагает чтение только тела запроса.
Ещё встречаются прокси с потерей заголовков по пути, либо наш заголовок может быть перезаписан.
Способ работы с айди стоит выбирать исходя из конкретного бизнес-кейса.
Что там по фреймворкам
Может показаться что решение очевидно - используем встроенные возможности фреймворков, либо внешнюю библиотеку или модуль. К сожалению, не всё так просто.
Далеко не во всех фреймворках существует такая возможность. Библиотеки с таким кодом тоже не набирают популярности, да и часто там просто реализована передача параметра в заголовке и всё. Причина всего этого очень проста - дело в том что нам нужно работать сразу с тремя точками: сделать отправку на клиенте, обработку на сервере, а ещё сделать так, чтобы у нас была база данных для кеша ответов. Слишком составная задача для единичного решения. Результатом является отсутствие коробочного решения в большинстве случаев.
Но есть и положительные моменты. Хорошая новость заключается в том, что если вы думали, что полезного можно написать для open-source, то это достаточно свободная ниша, и, кто знает, возможно, именно ваше решение станет базовым стандартом.
А сейчас в этом лишь хаос, при этом проблема совсем не надумана и последствия измеряются реальными деньгами.
Суммарно
Ну и соединяем воедино.
Смотрим со стороны клиента.
В базовом виде мы прикладываем к каждому запросу ID запроса. Если получаем ответ что запрос принят/обработан - идем дальше по логике кода, квант взаимодействия завершен. Если ответа нет или ответ говорит, что пока квант не завершен - пробуем ещё раз. Если ответа долго нет - оповещаем пользователя/код о том, что результат не определён.
А теперь смотрим с сервера.
На сервере мы проверяем ID запроса, если такого не было - исполняем, пишем в кэши ответ с ключом в виде этого ID и отправляем ответ. Если встречаем запрос с ID из кэша - просто отдаем ответ из кэша, если ответ уже готов. Если он не готов - ставим запрос в очередь и отвечаем по мере завершения. Отвечаем только последнему запросу с одинаковым ID, предыдущие считаем потерянными. Через безопасное время удаляем старые кэши ответов.
И всё - все проблемы решены, двойные запросы из-за потерянного ответа нам больше не грозят, успех!
Эта же статья, но на английском.
Комментарии (9)
Shurajan
05.07.2024 13:57А не рациональнее ли FIX использовать - старый и выверенный протокол используемый всеми биржами мира, ну кроме крипто, да и тут вопрос времени. Для fix разработанныскоростные решения на cpp и отлажены механизмы обработки сбоев.
Format-X22 Автор
05.07.2024 13:57Не во всех кейсах применимо, в интернете победил HTTP. А где-то и JSON-RPC. В протоколах где проблема решена на уровне самого протокола - всё хорошо, а для остальных есть решение из статьи.
rukhi7
05.07.2024 13:57Отправляем запрос на 20 000 000 евро, на перестановку 900 ордеров на бирже
Не надо даже спрашивать: "в чем сила? "
Captain_Jack
05.07.2024 13:57+1Похоже вы только что переизобрели задачу двух генералов, идемпотентность и дедупликацию.
Сверху можно вам ещё насыпать exactly once semantics, чтоб было совсем хорошо.
Вы говорите, что все вокруг, включая фреймворки, игнорируют эти вопросы. Вроде бы никто не игнорирует, просто вы не по тем ключевикам поискали и не нашли принятых в индустрии подходов.
Советую почитать таки про задачу двух генералов и основные выводы из неё, потому что всё остальное, включая существующие решения, отталкивается от неё.
Format-X22 Автор
05.07.2024 13:57Было бы приятно совсем-совсем новое изобрести, но всё что описано тут - основывается как раз на том что вы описали. Проблеме несколько тысяч лет, просто раньше гонцы были. Впрочем, айти это автоматизация реального мира, так или иначе.
Про фреймворки - я всё же вижу низкую популярность, во многих бекендах такое вообще не применяется. С другой стороны - не везде и финансы. Но если вспомнить что некоторое количество лет назад иногда в Яндекс Такси дважды вызывалась машина - это вот этот самый баг, и много лет был.
Так что я постарался подсветить проблему ещё раз, но чуть под другим углом и другими словами. Думаю лишнем не будет, уверен что для хорошего процента читателей это было пунктом для запомнить что и так бывает и нужно кейс такой продумывать.
Тут на Хабре иногда дважды комменты публикуются. Я бекенд не смотрел, но что-то мне подсказывает что это вот оно самое о чем в статье.
Captain_Jack
05.07.2024 13:57Насколько я разобрался, проблема в общем случае нерешаема. И можно только в каждой конкретной ситуации применять свои решения, которые сработают в частном порядке.
Возможно поэтому нет одного общепринятого решения этой проблемы, и нет такого инструмента, который бы это решение предлагал.
Captain_Jack
05.07.2024 13:57+1И да, многим командам разработки дешевле не обращать на такие проблемы внимания и править неконсистентности вручную силами саппорта, чем устранять эти проблемы на уровне разработки. Потому что проблем этих много, как вы описали, и они сложные, их с наскока не решить. А убытков от них может быть не так и много.
Очень интересный опыт, если в вашем случае это очень важно для бизнеса. Это похоже на хайлоад, там проблемы похожей сложности. И тоже большинство не заморачивается за оптимизацию, пока может себе позволить.
positroid
Стоило бы здесь употребить термин идемпотентность, применительно к запросам у некоторых систем (юкасса, например) есть ключ идемпотентности в качестве параметра, в терминах статьи это как раз ID запроса - цели и логика его применения аналогична.
Также в статье упоминается проблема конкурентных запросов (race condition), но в итоговом блоке она уже не рассматривается. Было бы полезно почитать и про решение этой проблемы, особенно при заявленном масштабе в планету
Format-X22 Автор
Да, про конкурентные запросы тоже хорошая тема для статьи и есть что рассказать. Очень вероятно что напишу статью про то как там решать проблемы консистентности.