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

Коротко про отказоустойчивость

Для начала — краткое и очень упрощённое пояснение, что же такое отказоустойчивость системы. Система является отказоустойчивой, если отказ любого её компонента не влияет на общую работоспособность. Например, отказ системы телеметрии не должен влиять на работу системы платежей. Однако обычные практики вроде горизонтального масштабирования или резервных инстансов могут не подойти, если объектом отказа является внешний компонент, отказоустойчивость которого мы по тем или иным причинам гарантировать не можем. Примерами таких «неудобных» компонентов могут быть внешние БД, публичные API и т. д.

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

О нашей проблеме

Архитектура
Архитектура

Цель нашей системы — следить за изменениями цен акций и уведомлять пользователей в случае «скачков» — крупных изменений цен. Для обеспечения необходимого уровня отказоустойчивости мы выбрали микросервисную архитектуру на базе Azure Cloud, где оркестратором выступает Azure Service Fabric, а брокером сообщений — Azure Service Bus. Данные с биржи поступают в нашу систему через Market Data Handler и затем по Azure Service Bus попадают к подписчикам топика ‘Price Change’. Одним из подписчиков является сервис, который отвечает за отправку уведомлений и использует сервисы СМС-информирования и email-рассылок.

Какое-то время вся система работала как часы, но однажды мы заметили, что почтовый сервис начал «плеваться» ошибками, а некоторые уведомления пропадают. Дело также усугублялось тем, что один и тот же метод API для различных сообщений мог как успешно исполняться (с HTTP-кодом 200 — Success), так и отказывать (с HTTP-кодом 500 — Internal Server eError).

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

P.P.S. Тем, кто не знаком с Azure Service Bus Queues и Topics/Subscriptions, возможно, будет полезно прочесть короткую статью на msdn.

Стратегии работы с отказами

Fire-and-forget

Изначально мы применяли самый примитивный механизм разрешения отказов почтового сервиса — fire-and-forget, или в просторечии — «игнорируй проблему, и она уйдёт». Смысл таков: в случае неудачного исполнения запроса логируем ошибку и продолжаем работать дальше. Но поскольку потеря уведомлений является критичным фактором для нашей системы, от этой стратегии пришлось отказаться.

Пример кода

Плюсы

Минусы

Простота

Неприменим для систем с гарантированной обработкой событий

Скорость обработки

Потенциально большое количество неисполненных запросов

Паттерн Circuit Breaker

Для решения некоторых проблем, связанных с механизмом fire-and-forget, можно применить паттерн Circuit Breaker (подробнее на msdn). Его смысл заключается в минимизации количества запросов до тех пор, пока мы не убедимся, что сторонний сервис восстановился.

Паттерн Circuit Breaker (диаграмма взята с https://commons.wikimedia.org/wiki/File:Circuit_breaker_pattern_state_diagram.svg)
Паттерн Circuit Breaker (диаграмма взята с https://commons.wikimedia.org/wiki/File:Circuit_breaker_pattern_state_diagram.svg)

Обычно паттерн Circuit Breaker реализуется с потерей неуспешных запросов, т.е. в связке со стратегией fire-and-forget. Однако подход можно модифицировать: раз в таймаут повторно исполняем последнее неуспешно выполненное сообщение, в то время как остальные кладём во внешнее хранилище или очередь. Таким образом, одновременно с меньшим числом неуспешных запросов к сервису этот подход позволяет гарантировать обработку запросов. Тем не менее, это чревато неэффективным расходованием ресурсов или переполнением используемого хранилища/очереди.

Использовав Circuit Breaker, мы снизили нагрузку на почтовый сервис, но лишились возможности параллельной обработки уведомлений, что нас также не устроило.

Пример кода

Плюсы

Минусы

Предотвращает множество неуспешных запросов

При возникновении отказов производительность сильно уменьшается

Скорость

При размыкании цепи параллельная обработка невозможна

Schedule and Retry

Если fire-and-forget не подходит, а Circuit Breaker не даёт нужной производительности, на помощь приходит стратегия повторений запросов. Дело в том, что Circuit Breaker лучше всего подходит для обработки сценариев, при которых сервис недоступен. Однако проблемы бывают и другого характера: так, используемый нами почтовый сервис зачастую был развёрнут с багами, ошибками конфигурации, проблемами с подпиской. Повторение запросов через определённое время позволило нам в автоматизированном режиме ожидать решения проблемы, лежащей на стороне почтового сервиса.

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

Подытоживая, стоит учитывать, что не всякие ошибки говорят о необходимости в повторении запроса. Так, обычные ошибки 4xx для REST-запросов, как правило, говорят о некорректной конфигурации клиента, и результат этого запроса вероятнее всего не изменится с течением времени. В отличие от них, ошибки 5хх (например 500 Internal Server Error) зачастую возникают из-за проблем со стороны сервиса. И если мы отправим запрос повторно к моменту, когда сервис починят, запрос сможет завершится успешно.

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

Наш пример частичной недоступности внешнего сервиса
Наш пример частичной недоступности внешнего сервиса

Перепланирование обработки задач с использованием очередей

Пример использования очереди сообщений
Пример использования очереди сообщений

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

Алгоритмы вычисления задержки перед следующей обработкой (подробнее см. Polly):

  • constant backoff — постоянная величина, например 5 с,

  • jitter backoff — случайная величина в постоянном интервале, например в промежутке (1 с, 10 с) с нормальным распределением,

  • linear backoff — линейно растущая величина,

  • exponential backoff — экспоненциально растущая величина,

  • exponential with jittered backoff — экспоненциально растущая величина со случайным отклонением.

Различия между алгоритмами вычисления задержки (ниже — лучше)
Различия между алгоритмами вычисления задержки (ниже — лучше)

Для нашей системы мы выбрали exponential with jittered backoff, поскольку он позволяет минимизировать нагрузку на внешний сервис и распределяет пиковую нагрузку. В качестве значения максимального времени повтора были выбраны одни сутки, так как к этому времени уведомления становятся неактуальными. Для сохранения информации и возможности ручной обработки инцидентов для уведомлений с исчерпанным количеством повторов используется отдельная очередь — например Dead-Letter-Queue, доступная для каждой Azure Service Bus Queue.

Специфичные для Azure Service Bus Queue проблемы:

  1. Если Azure Service Bus сконфигурирован на детектирование дубликатов сообщений, каждому сообщению на переобработку необходимо иметь уникальный идентификатор, что усложняет сбор метрик.

  2. Полноценное обеспечение атомарности перепланирования сообщения возможно только с использованием единого Message Queue.

Пример кода

Плюсы

Минусы

Позволяет производить параллельную обработку запросов

Требуется очередь сообщений

Нативно поддерживает масштабирование и отказоустойчивость

Каждая повторная обработка сообщения приводит к дополнительным вызовам сервиса

После решения проблем на стороне сервиса сообщения в очереди обрабатываются не сразу

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

Перепланирование с помощью существующего топика
Перепланирование с помощью существующего топика

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

Комбинация Reschedule и Circuit Breaker

Гибридный подход с Reschedule и Circuit Breaker
Гибридный подход с Reschedule и Circuit Breaker

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

Стоит заметить, что оправдан вопрос: почему мы не использовали несколько Circuit Breaker'ов на разные паттерны использования почтового сервиса, таким образом обрабатывая сообщения быстрее? Это было невозможно, поскольку предоставляемый API содержал в себе много несвязанных между собой параметров. Каждое сочетание параметров потребовало бы отдельной цепи, многократно усложняя разработку, поддержку и диагностику сервиса.

Проблемы, связанные с повторной обработкой

Идемпотентность

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

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

  2. Почтовый сервис принял запрос и успешно отправил уведомление клиентам.

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

  4. Дополнительные запросы на отправку этого уведомления не привели к приёму дубликатов сообщения клиентами.

Пример извлечения ключа идемпотентности по ключевым полям: котировка, цена и временная отметка
Пример извлечения ключа идемпотентности по ключевым полям: котировка, цена и временная отметка

Идемпотентность сервисов зачастую заключается в использовании дополнительного параметра — ключа идемпотентности. Пример такого API — https://stripe.com/docs/api/idempotent_requests. Чтобы детерминировано определить ключ идемпотентности как для изначального сообщения, так и для повторно обработанного, можно использовать хеш его содержимого или хеш уникальных для сообщения полей.

Порядок и актуальность сообщений

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

  1. Сообщение А принято в 10:00.

  2. Сообщение А не удаётся доставить, из-за чего следующая отправка запланирована на 11:00.

  3. Сообщение Б принято в 10:30 и содержит в себе актуальнейшую информацию по теме сообщения А.

  4. Сообщение Б успешно отправлено.

  5. Наступает 11:00, сообщение А отправляется успешно с неактуальной информацией.

Пример использования кэша для предотвращения обработки неактуальных сообщений
Пример использования кэша для предотвращения обработки неактуальных сообщений

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

Ограничения на повторную обработку

Вполне ясно, что нет смысла бесконечно класть сообщения для переобработки обратно в очередь, поскольку это в какой-то момент времени либо приведёт к её переполнению, либо к большим излишним тратам ресурсов. Как правило, превысив какой-то разумный лимит на переобработку (например 10), сообщение попадает в Dead-Letter-Queue — особую очередь, которая будет проверяться в автоматизированном или ручном режиме. Также, при использовании задержек перед повторной обработкой, как правило, можно эвристически определить, когда сообщение станет неактуальным. Например, уведомление клиента о позавчерашнем значительном изменении цены на акцию может вызвать только негатив.

Переприоритизация обработки сообщений с использованием очередей с приоритетами

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

Пример использования одной очереди сообщений без задержек
Пример использования одной очереди сообщений без задержек

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

Пример использования очередей сообщений с приоритетами
Пример использования очередей сообщений с приоритетами

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

Перепланирование с помощью очередей с приоритетом
Перепланирование с помощью очередей с приоритетом
Пример обработки отказов с использованием очередей с приоритетом
Пример обработки отказов с использованием очередей с приоритетом

Плюсы

Минусы

Позволяет обрабатывать запросы параллельно

Требуется очередь сообщений с поддержкой приоритетов (Azure Service Bus не поддерживает приоритеты из коробки)

Нативно поддерживает масштабирование и отказоустойчивость

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

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

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

Применения в монолитных архитектурах

В то время как fire-and-forget и Circuit Breaker — постоянные гости в монолитных архитектурах, очереди сообщений пользуются меньшей популярностью. Дело в том, что обычно монолитные архитектуры имеют целью уменьшение числа используемых внешних ресурсов и задержек из-за передачи данных по сети. Руководствуясь этим принципом, как правило, рекомендуется использовать очереди сообщений в памяти приложения. Это, конечно, снижает отказоустойчивость, зато является самым производительным подходом, не требующим дополнительных затрат на инфраструктуру.

Об очередях сообщений

Самыми популярными очередями сообщений являются Apache Kafka, Rabbit MQ, AWS SQS, Azure Message Queue.

Технология

Поддержка задержки перед обработкой

Поддержка
приоритетов

Azure Message Queue

+

-

Rabbit MQ

+

+

AWS SQS

+

-

Apache Kafka

-

-

Закономерный вопрос: что делать, если избранная технология очереди сообщений не поддерживает приоритеты, а нам очень хочется? Например, что делать с популярной Kafka, которая не поддерживает ни задержки, ни приоритеты? В таком случае можно использовать несколько очередей для сообщений с разными приоритетами или задержками. Например, очередь с приоритетами можно эмулировать созданием очередей для каждого уровня приоритета: ‘message-queue-retry-1’, ‘message-queue-retry-2’. Для эмуляции задержек возможно создание очередей для каждого фиксированного значения задержки: ‘message-queue-1min’, ‘message-queue-5min’, и т.д. Добавив к сообщению метаданные о минимальном времени начала обработки, можно последовательно извлекать сообщения из очереди, блокируя поток исполнения и сохраняя таким образом последовательность сообщений.

Эмуляция очередей сообщений с приоритетами
Эмуляция очередей сообщений с приоритетами

Кроме того, кому-то может быть полезно знание паттерна bucket priority. Подробнее — здесь: https://github.com/riferrei/bucket-priority-pattern.

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

Заключение

Существует множество способов обработки отказов сервиса. Наиболее распространён способ fire-and-forget, подкупающий своей простотой. Следующим уровнем обработки, уменьшающим число неуспешных запросов, является паттерн Circuit Breaker. Если необходимо добиться успешной обработки каждого запроса, можно использовать очереди для хранения запланированных на переобработку сообщений. Для регулирования компромисса между числом и частотой запросов к сервису следует выбирать подходящую функцию для задержки перед переобработкой. Для большинства вариантов использования подойдёт постоянная или экспоненциально растущая задержка. Чтобы минимизировать время обработки сообщений, можно использовать очереди сообщений с поддержкой приоритетов, нивелировав таким способом задержку перед переобработкой.

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

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


  1. SkryabinD
    07.09.2021 16:12
    +1

    Если сторонний сервис вернул вам 500, то это не значит, что он не отправил письмо. Делая 10 попыток, вы рискуете отправить клиенту 10 одинаковых писем, если у стороннего сервиса нет защиты от повторной отправки. И еще, возможно, заплатить за эти 10 писем.


    1. exotic Автор
      07.09.2021 16:46

      Абсолютно верно. Поэтому я говорю о важности идемпотентности — повторный вызов сервиса не должен приводить к дубликатам писем. С другой стороны, даже если внешний сервис не предоставляет никаких ключей идемпотентности в своих API, 500 ошибка скорее идентифицирует некорректное поведение и провал запрошенной операции — что говорит в пользу Retry.


      1. SkryabinD
        07.09.2021 17:09

        Когда пришло 500, значит, они там что-то не обработали. Может быть как раз не записался у них ключ идемпотентности в базу после отправки )

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


    1. i_d_1
      07.09.2021 19:04

      client-generated ID


      1. exotic Автор
        07.09.2021 20:29

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

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


        1. i_d_1
          15.09.2021 12:41

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

          В случае с рассылкой сообщений мы можем организовать таблицы так [сообщение][ключ-идемпотентности] <-> пользователь. У нас на момент повторной отправки сообщения окажутся две записи. Следовательно, мы сможем отправить сообщение только одному пользователю.


  1. mvv-rus
    08.09.2021 04:42
    +2

    Самыми популярными очередями сообщений являются Apache Kafka, Rabbit MQ, AWS SQS, Azure Message Queue

    Или — просто SMTP-сервер, самый простейший. Потому что многое желательное из того, что описано статье (за «все» не скажу, там надо анализировать подробнее), является стандартными возможностями самого протокола SMTP.
    «На земле» сервер SMTP можно было поднять на любом сервере — в Windows Server он является стандартным компонентом, и никаких дополнительных денег MS за него не просит, а в Linux Postfix AFAIK (однако не специалист я по всяким разным дистрибутивам Linux, чтобы утверждать это с уверенностью) тоже входит в стандартный комплект.
    Но вот как дело обстоит в облаке — за это я не скажу.
    Сам я неоднократно использовал именно компонент SMTP-сервера из Windows Server как раз для безотказного приема уведомлений от всяких капризных служб (внутренний мониторинг железок, не слишком хорошо написанные программы), потому как штатный MS Exchange, бывает, считает, что он сейчас перегружен и возвращает 4xx код (ну, вообще-то это — штатное поведение SMTP, но почему-то не все его учитывают).


    1. exotic Автор
      08.09.2021 22:49

      Спасибо за интересное замечание. Знаешь ли ты какие есть недостатки у этого подхода с использованием, например, MS Exchange? Поправь или дополни пожалуйста, если где буду не прав:

      1. Время приёма сообщения может варьироваться в зависимости от настроек батчинга, т.е. например через 5 секунд от времени отправки.

      2. Производительность самого протокола достаточно лимитированная, из источников в интернете ~10.

      3. В отличие от Message Queue, где автоматически реализованы peek-lock/иные механизмы, в MS Exchange нам придётся реализовывать всё самим?