Разбираемся с работой брокеров, или Что такое гарантия доставки сообщений и как с этим жить…

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

В эпоху мгновенной коммуникации гарантия доставки сообщений становится не просто плюсом, но и неотъемлемой частью репутации сервиса. Как быть уверенным, что ваше сообщение дойдет до адресата именно тогда, когда это нужно? Привет, Хабр, меня зовут Сергей Коник, я работаю в Самолете старшим разработчиком в продукте 10D и одна из проблем, с которой сталкивались наши программисты, – это потеря важных сообщений при общении между сервисами. Сегодня расскажу основы, как с этим работать.

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

Общение между сервисами можно организовать двумя основными способами - синхронно и асинхронно. Синхронное общение, построенное на обмене HTTP сообщениями через REST API. Оно предполагает, что мы посылаем напрямую запрос серверу и ожидаем получения ответа. Это удобно, когда нам необходимо тут же получить результат операции, но этот подход очень хрупок при возникновении ошибок. Если получатель будет недоступен, то нам нужно будет как-то организовать обработку такого случая, что само по себе является сложной задачей, т.к. иногда нужно откатывать предыдущие состояния.

Асинхронное общение подразумевает обмен сообщениями через брокер. В работе любого брокера сообщений используются две основные сущности: producer (издатель сообщений) и consumer (потребитель/подписчик). Одна сущность занимается созданием сообщений и отправкой их другой сущности-потребителю. Если подписчик недоступен, то сообщение будет ожидать, когда сервис вновь станет доступен и обработает это сообщение. Как минус такой подход не дает возможности сразу получить результат операции.

Но сегодня мы поговорим про один из самых важных аспектов в работе любого брокера сообщений – это гарантия доставки сообщений. Да и не только брокера. Это касается как синхронного(через HTTP), так и асинхронного общения между сервисами(через брокер). Простыми словами: как нам быть уверенными, что один сервис отправил, а другой получил сообщение, причём это всё в условиях возможного отключения то одного, то другого сервиса от сети и т.п.

Содержание

  • Что такое доставка сообщений на примере распределённых веб-приложений

  • Нестабильность сети и задача двух генералов

  • Чем отличаются типы доставок сообщений

    • At most once – при отсутствии retry механизма

    • At least once – при наличии retry механизма

    • Exactly once - при наличии идемпотентного retry-механизма

    • Главная проблема exactly once

  • Общение между микросервисами и паттерн Transactional Outbox

  • Дополнительные материалы

Что такое доставка сообщений на примере распределённых веб-приложений

Приложения, взаимодействующие по сети, могут работать не так очевидно, как кажется изначально. Соединение может обрываться, из-за чего компоненты системы недополучают информацию и не могут выполнять свои задачи. Понимание проблем взаимодействия таких систем — это важный пункт, чтобы предотвратить проблемы ещё на этапе проектирования. Такой подход позволяет не терять деньги бизнесу, когда клиенты не получают услугу из-за ошибок. Здесь я расскажу про основные проблемы, которые могут произойти при обмене сетевыми сообщениями с бэкендом на REST API или же вообще любым другим серверным софтом. Сперва посмотрим, какие проблемы свойственны самым простым программам. Например, мобильному приложению, которое общается с бэкенд-сервером через HTTP протокол.

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

Бэкенд-сервис, который предоставляет API, и база данных расположены на одном сервере, поэтому мы будем считать их за единый узел (node) и условно задержка общения между ними минимальная.

Мы написали небольшой API эндпоинт api/v1/orders, который по запросу пользователя создает новый заказ в базе данных и делает некоторые операции с другими сущностями в БД. 

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

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

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

Итак, мы создали распределенную систему. У нас есть мобильное приложение, которое отправляет по HTTP-протоколу сообщение в REST API и далее на стороне сервера создаётся запись в базе данных. Ну, по крайней мере, верхнеуровнево это так должно работать.

Распределенную систему можно охарактеризовать следующими факторами:

  • несколько компонентов работают как один для достижения единой цели;

  • общение между компонентами происходит через сеть;

  • компоненты системы могут работать параллельно(приложение и сервер работают по отдельности);

  • у компонентов системы есть свое локальное состояние, которое меняется в зависимости от результата взаимодействия с другим компонентом.

  • есть одно центральное состояние (БД) и локальное состояние компонентов, которые синхронизируются с центральным. 

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

Теперь давайте представим, что вы открываете наше мобильное приложение и нажимаете на кнопку создания заказа.  HTTP-клиент в вашем смартфоне посылает сообщение по сети удаленному серверу. В какой-то момент ближайшая вышка связи перестает работать на пару секунд и сообщение не достигает конечного адресата. Порой мы рассчитываем на то, что наше приложение всегда будет доступно, однако это не так. И сбои в сетевой инфраструктуре — это естественное явление.

Нестабильность сети и задача двух генералов

Нестабильность сети — это одно из фундаментальных свойств текущей инфраструктуры интернета. В любой момент с кабелем или базовой станцией может случиться любая поломка, и нет никаких гарантий, что это не случится. Акула перегрызет подводный кабель либо он выйдет из строя из-за брака, в датацентре выключится свет, Аннушка прольёт масло на стойку… В общем, неважно, что именно может произойти, у нас нет 100% гарантий, что сетевое соединение будет непрерывно. Важно, что это фундаментальная проблема, с которой приходится жить всем. Чтобы проиллюстрировать проблему нестабильности сети, обычно рассказывают про задачу двух генералов. Эта задача рассматривает проблемы синхронизации между двумя системами в условиях крайне ненадежного канала связи. Сейчас отвлекитесь немного, включите своё воображение и почитайте сказку.

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

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

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

Какое решение у этой проблемы? Оно вероятностное. Можно послать больше гонцов, чтобы уменьшить вероятность потери сообщений и увеличить вероятность получения сообщения.

В контексте компьютерных систем это реализуется через retry-механизм (повторяющийся) – когда клиент посылает свой запрос повторно в случае сетевой ошибки. Ведь при отправке сообщения серверу может произойти несколько сценариев, связанных с нестабильностью сети. Например, сеть может стать недоступной на некоторое время перед отправкой сообщения. Либо же после. А затем соединение восстановится. Такие моменты приводят к тому, что сообщение либо не отправляется вовсе, либо отправляется позже через какое-то время. Например, при просмотре коротких роликов в Youtube Shorts в случае сетевой ошибки показывается кнопка «Retry» для повторного запроса.

Retry-механизм — это компонент, который с некоторой периодичностью повторяет некое действие в случае ошибки. Например, при отправке запроса на сервер в случае, если мы не получаем ответ, то посылаем сообщение вновь в надежде, что сервер в этот раз ответит. С таким механизмом вы можете столкнуться, когда пишете HTTP-запрос на фронтенде. Клиенты имеют настройку для такого механизма. Этот компонент немного повышает надежность нашей системы, за счет чего мы и можем говорить о некоторых гарантиях доставки, которые он может предоставлять.

Чем отличаются типы доставок сообщений

Под доставкой мы будем понимать полный цикл получения и обработки сообщения сервером. Обработка сообщения может быть какая угодно, например, создание записи о заказе в базе данных, раз уж мы в начале статьи про это говорили. Retry-механизм позволяет нам дать вероятностные оценки относительно того, каким образом будет доставлено сообщение, то есть тип доставки сообщения.

Мы можем столкнуться со следующими ситуациями:

  • Retry-механизма отсутствует. Тогда сообщение либо доставляется один раз, либо не доставляется вовсе (максимум один раз);

  • Retry-механизм присутствует. Сообщение будет повторяться, пока не достигнет адресата или пока не закончится количество попыток повтора;

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

  • At Most once (не более 1 раза) 

  • At least once (хотя бы 1 раз)

  • Exactly once (строго 1 раз).

Семантика доставки

Сообщение может потеряться

Сообщение может дублироваться

Дубликаты убираются (дедупликация)

at most once

да

нет

нет

at least once

да (если retry остановится)

да

нет

exactly once

да(если retry остановится)

да

да

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

At most once – при отсутствии retry механизма

Без retry-механизма у клиента при ошибке сообщение теряется и сервер никогда его не получает. Заново сообщение не отправляется. Если же ошибок нет, то сообщение приходит к серверу. Этот тип доставки может как доставить сообщение, так его и потерять. Давайте это проиллюстрируем на примере. Допустим, мы отправили HTTP сообщение серверу. Возможны три ситуации:

  1. Сообщение могло потеряться на пути к серверу, повторной отправки нет (получено и обработано 0 сообщений);

2. Cообщение было получено сервером и обработано, но конечный ответ не получен, повторной отправки нет (получено и обработано 1 раз);

3. Cообщение получено сервером, обработано и конечный ответ получен (получено и обработано 1 раз);

Исходя из возможных вариантов выше мы делаем вывод, что сообщение доставляется либо 0, либо 1 раз. Мы допускаем, что оно может потеряться и этого стоит ожидать. Сообщение доставляется от 0 до 1 раза (вероятно). Такой тип доставки называется at most once, сообщение доставится максимум 1 раз, а может и ни одного.

Где приемлемо использовать at most once:

  • отправка логов серверу - когда потеря сообщений не играет большой роли;

  • прокрутка списка фотографий из ленты новостей - когда важнее загрузить информацию быстрее с возможной потерей;

At least once – при наличии retry механизма

С retry-механизмом при сетовой ошибке сообщение заново отправляется через некоторые промежутки времени. Если же ошибок нет, то сообщение приходит к серверу. Допустим, мы отправили HTTP сообщение серверу. Опять же, возможны несколько ситуаций:

  1. При первой отправке сообщение дошло до сервера(дошло 1 раз);

2. При первой отправке сообщение потерялось на пути к серверу, сервер сообщение не получил, retry-механизм через какое-то время повторно послал сообщение. При повторной отправке сообщение дошло до сервера (дошло 1 раз);

3. При первой отправке сообщение дошло до сервера, сервер сообщение получил и создал запись в БД. Обратный ответ клиент не получил. Retry механизм считает, что сообщение потерялось, и через какое-то время повторно послал сообщение. При повторной отправке сообщение дошло до сервера, обработалось, ответ клиенту дошел, но из-за неидемпотентного API в БД создалось 2 записи(дошло 2 раза);

4. При первой отправке сообщение дошло до сервера, сервер сообщение получил и создал запись в БД. Обратный ответ клиент не получил. Retry-механизм считает, что сообщение потерялось, и через какое-то время повторно послал N сообщений в ответ на N потерянных ответов. При N-ой отправке сообщение дошло до сервера, обработалось, ответ клиенту дошел, но из-за неидемпотентного API в БД создалось N записей (дошло N раз);

Исходя из возможных вариантов выше мы делаем вывод, что сообщение доставляется от 1 до N раз. Мы допускаем, что сообщение дублируется, сервер может получить несколько одинаковых сообщений. Сообщение доставляется от 1 до N раз.
Такой тип доставки называется at least once (сообщение доставляется хотя бы 1 раз)

На самом деле я здесь немного слукавил. Сообщение может вообще не дойти до адресата, если закончатся попытки доставить сообщение. И тогда это уже будет доставляться от 0 до N раз, но в целом принято такую схему называть At least once. И в таком контексте мы просто присваиваем следующую смысловую нагрузку такой гарантии: вероятнее всего, у нас будет либо 1, либо N сообщений (дубликатов) в конечной системе. Ключевое слово здесь "вероятнее". Главное условие для этого – поддерживать retry-механизм рабочим. 

Где приемлемо использовать at least once:

  • stripe посылает на вебхук сообщение о совершенной оплате до тех пор, пока вебхук не подтвердит обработку;

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

Exactly once - при наличии идемпотентного retry-механизма

Идемпотентность это свойство системы при повторной операции с одинаковыми параметрами давать тот же результат, что и в первый раз. Примером может послужить кнопка покупки товара. Мы не хотим, чтобы за товар списывались деньги дважды в случае сетевых ошибок. Это неприемлемо. В API платежных систем типа Stripe можно заметить в документации упоминание некоего idempotency key при взаимодействии с API. Это уникальный ключ идемпотентности – один из компонентов реализации идемпотентного API, чтобы операции по списанию денег не списывали деньги клиента несколько раз из-за ошибки сети. 

Если правильно обрабатывать дублирующиеся сообщения, то количество записей в БД у нас всегда будет равно одному. Сделать это можно с помощью использования механизма retry и идемпотентного API. Для реализации идемпотентности обычно используют некий уникальный ключ, который генерируется на клиенте. Задача бэкенда в этом случае сделать так, чтобы этот уникальный ключ не обрабатывался повторно. Тогда API можно назвать идемпотентным. 

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

Где приемлемо использовать exactly once:

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

  • операции с оплатой. Мы ожидаем, что при оплате заказа по кнопке деньги не спишутся несколько раз.

Главная проблема exactly once

Я привел достаточно банальный пример, когда вся обработка заключается в том, чтобы сохранить какую-то запись в БД. Как правило, приложениям требуются задачи посложнее. Например, после сохранения заказа в базе, нужно переслать заказ подрядчику и отправить пользователю письмо с информацией о заказе. Можем ли мы сделать так, чтобы письмо не дублировалось и всегда приходило, когда сделан заказ? Кратко - да, но это очень сложно, но иногда этого невозможно достичь вовсе.

Мы хотим сделать сохранение заказа и отправку письма атомарной операцией. Если все завернуть в транзакцию БД, то может быть момент, когда транзакция при коммите откатится, а письмо уже выслано. Логика нарушена. И как бы не изворачивались, но из-за нестабильности сети гарантировать доставку сообщения по цепочке дальше очень сложно. В этом и кроется суть сложности exactly once. Она очень тяжело достижима, а если и достижима, то в очень ограниченных и простых вариантах.

Общение между микросервисами и паттерн Transactional Outbox

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

Мы упираемся в ту же самую проблему – дальнейшие операции по сети нестабильны. Как это решить? Ввести новый вид retry-механизма в нашем бэкенде. Более продвинутый, чем в нашем клиенте на фронтенде. Например, через реализацию паттерна Transactional Outbox. Но об этом я постараюсь рассказать в одном из следующих материалов.

Это была вводная теоретическая часть перед реализацией паттерна Transactional Outbox в следующей части. Текущий текст должен познакомить вас с понятием распределенной системы, нестабильностью сети и объяснить, что в зависимости от retry механизма сообщения могут по-разному доходить до конечного сервера и обрабатываться там же.

Дополнительные материалы

Вот, что ещё можно почитать и посмотреть, если вам интересна доставка сообщений:

  • Designing A Retry Mechanism For Reliable Systems (Code Curated)

  • Delivery Guarantees and the Ethics of Teleportation (Confluent)

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

  • Distributed Systems 1.1: Introduction (YouTube)

  • At-least-once Delivery (Cloud computing fundamentals)

Источник: https://habr.com/ru/companies/samolet/articles/785382/


Интересные статьи

Интересные статьи

Я постарался придумать самое простое объяснение дизайн-токенов на примере житейских ситуаций. Что это такое, как работают и зачем они нужны — в этой статье.Про принципы наименований, экспорт токенов и...
Всем доброе утро!Сегодня мы поднимем такую интересную тему, как DevOps Governance в Enterprise. В современных реалиях не так просто прийти к бизнесу и сказать: "Давайте всё автоматизируем, и всё будет...
Меня зовут Александр Никитин, руководитель направления цифровых двойников UMNO.digital (ГК НефтеТрансСервис). С 2018 года я специализируюсь на разработке цифровых двойников для промышленных предприяти...
На примере Telegram-каналов двух банков, разбираем две глобальные стратегии продвижения в мессенджере и их результаты. О подходах к работе, изменении ключевых показателей и неоднозначности выводов — ч...
И то, и другое — интерпретаторы командной строки в линуксе. То есть если вы откроете командную строку и введете любую команду, да хоть: Читать далее ...