Эта статья может быть полезна для тех, кто, как и мы, пострадал от нестабильной работы внешних 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 реализуется с потерей неуспешных запросов, т.е. в связке со стратегией 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 проблемы:
Если Azure Service Bus сконфигурирован на детектирование дубликатов сообщений, каждому сообщению на переобработку необходимо иметь уникальный идентификатор, что усложняет сбор метрик.
Полноценное обеспечение атомарности перепланирования сообщения возможно только с использованием единого Message Queue.
Плюсы | Минусы |
Позволяет производить параллельную обработку запросов | Требуется очередь сообщений |
Нативно поддерживает масштабирование и отказоустойчивость | Каждая повторная обработка сообщения приводит к дополнительным вызовам сервиса |
После решения проблем на стороне сервиса сообщения в очереди обрабатываются не сразу |
Внимательный читатель может заметить, что мы могли бы переиспользовать существующий топик, и конечная диаграмма выглядела бы так:
Проблема данного решения заключается в том, что неуспешно обработанное сообщение отправляется обратно в топик. Если помимо нашего сервиса есть ещё и другие подписчики на топик, им будут поступать дубликаты неуспешно обработанных сообщений, что в общем случае нежелательно.
Комбинация Reschedule и Circuit Breaker
Для оптимизации числа неуспешных запросов можно было бы использовать Circuit Breaker. Логично предположить, что если один из запросов выполнился неуспешно, то последующий завершится с тем же результатом. Используя эту эвристику, некоторое время все последующие за неуспешным запросы будут сразу отправлены в очередь на переобработку. В этом случае мы жертвуем скоростью доставки уведомлений, но в то же время снижаем нагрузку на почтовый сервис, устраняя таким образом возможную причину отказа. Однако мы использовать эту стратегию, конечно, не стали — деньги клиентов дороже. Главная проблема этого подхода заключается в том, что какие-то из сообщений рискуют вообще никогда не быть обработанными.
Стоит заметить, что оправдан вопрос: почему мы не использовали несколько Circuit Breaker'ов на разные паттерны использования почтового сервиса, таким образом обрабатывая сообщения быстрее? Это было невозможно, поскольку предоставляемый API содержал в себе много несвязанных между собой параметров. Каждое сочетание параметров потребовало бы отдельной цепи, многократно усложняя разработку, поддержку и диагностику сервиса.
Проблемы, связанные с повторной обработкой
Идемпотентность
Идемпотентность заключается в том, что повторный вызов одной и той же операции не должен вызывать изменений состояния сервиса или приводить к другим побочным эффектам. Пример:
Начался синхронный запрос на отправку уведомления в почтовый сервис.
Почтовый сервис принял запрос и успешно отправил уведомление клиентам.
В момент отправки ответа об успешной операции произошёл разрыв сети, из-за чего изначальный запрос на отправку уведомления был признан неуспешным.
Дополнительные запросы на отправку этого уведомления не привели к приёму дубликатов сообщения клиентами.
Идемпотентность сервисов зачастую заключается в использовании дополнительного параметра — ключа идемпотентности. Пример такого API — https://stripe.com/docs/api/idempotent_requests. Чтобы детерминировано определить ключ идемпотентности как для изначального сообщения, так и для повторно обработанного, можно использовать хеш его содержимого или хеш уникальных для сообщения полей.
Порядок и актуальность сообщений
Перепланирование сообщений для последующей обработки приводит к переупорядочиванию сообщений. Таким образом мы можем сначала успешно обработать актуальный запрос и только затем неактуальный. Для почтового сервиса пример выглядел бы так:
Сообщение А принято в 10:00.
Сообщение А не удаётся доставить, из-за чего следующая отправка запланирована на 11:00.
Сообщение Б принято в 10:30 и содержит в себе актуальнейшую информацию по теме сообщения А.
Сообщение Б успешно отправлено.
Наступает 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. Если необходимо добиться успешной обработки каждого запроса, можно использовать очереди для хранения запланированных на переобработку сообщений. Для регулирования компромисса между числом и частотой запросов к сервису следует выбирать подходящую функцию для задержки перед переобработкой. Для большинства вариантов использования подойдёт постоянная или экспоненциально растущая задержка. Чтобы минимизировать время обработки сообщений, можно использовать очереди сообщений с поддержкой приоритетов, нивелировав таким способом задержку перед переобработкой.
В нашем случае использование механизма перепланировки обработки сообщений позволило гарантировать доставку уведомлений и в то же время минимизировать стоимость системы. И мы, и заказчик остались довольны.