Отказоустойчивость в маштабной распределенной системе
В предыдущей публикации Бена Шмауса мы поделились принципами реализации нашего предохранителя. В этом посте Бен рассказывает, как API Netflix взаимодействует с десятками систем в нашей сервис-ориентированной архитектуре, что делает его более уязвимым к любым системным сбоям или задержкам на уровне лежащего в его основе стека.
Остальная часть этого поста содержит более подробное техническое описание того, как наш API и другие системы изолируют сбои, сбрасывают нагрузку и остаются устойчивыми к сбоям.
Отказоустойчивость — это требование, а не функциональность
API Netflix получает более 1 миллиарда входящих вызовов в день, которые, в свою очередь, разветвляются на несколько миллиардов исходящих вызовов (в среднем 1:6) к десяткам базовых подсистем с пиковыми значениями нагрузки более 100 000 запросов к зависимостям в секунду.
Все это происходит в облаке на тысячах инстансов EC2.
С таким количеством переменных, периодический сбой гарантирован, даже если каждая зависимость сама по себе имеет отличную доступность и лучшее время безотказной работы (uptime).
Без принятия мер по обеспечению отказоустойчивости для 30 зависимостей, каждая из которых имеет время безотказной работы 99,99%, приведут к простою более чем на 2 часа в месяц (99,99%30 = 99,7% uptime = 2+ часа в месяц).
Когда одна зависимость API дает сбой при высокой нагрузке с увеличением задержки (вызывая блокировку потоков запросов), она может быстро (секунды или доли секунды) занять все доступные потоки запросов Tomcat (или другого контейнера, такого как Jetty) и вырубить весь API.
Таким образом, требуется, чтобы высоконагруженные и высокодоступные приложения встраивали отказоустойчивость в свою архитектуру, а не ожидали, что инфраструктура решит эту проблему за них.
Реализация Netflix DependencyCommand
Сервисно-ориентированная архитектура Netflix позволяет каждой команде свободно выбирать наилучшие транспортные протоколы и форматы (XML, JSON, Thrift, Protocol Buffers и т. д.) в соответствии со своими потребностями, поэтому подходы к реализации могут различаться в зависимости от сервиса.
В большинстве случаев команда, предоставляющая сервис, также распространяет и клиентскую библиотеку Java.
По этой причине такие приложения, как API, фактически рассматривают базовые зависимости как сторонние клиентские библиотеки, реализации которых являются «черными ящиками». Это, в свою очередь, влияет на то, как достигается отказоустойчивость.
В свете приведенных выше архитектурных соображений мы решили реализовать решение, в котором используется комбинация подходов к отказоустойчивости:
- сетевые тайм-ауты и повторные попытки
- отдельные потоки для каждой зависимости в пулах потоков
- семафоры (через tryAcquire , а не блокирующий вызов)
- предохранители
У каждого из этих подходов к отказоустойчивости есть свои плюсы и минусы, но в сочетании друг с другом они обеспечивают всеобъемлющий защитный барьер между запросами пользователей и лежашим в основе зависимостями.
Реализация Netflix DependencyCommand оборачивает вызов зависимости, связанной с сетью, с предпочтительным выполненим в отдельном потоке и определяет резервную логику, которая выполняется (шаг 8 на блок-схеме ниже) при любом сбое или отказе (шаги 3, 4, 5a, 6b ниже) независимо от того, какой тип отказоустойчивости (тайм-аут сети или потока, отклонение пулом потоков или семафором, предохранитель) вызвал его.
Мы решили, что преимущества выделения вызовов зависимостей в отдельные потоки перевешивают недостатки (в большинстве случаев). Кроме того, поскольку API постепенно движется в направлении увеличения параллелизма, было беспроигрышным вариантом добиться как отказоустойчивости, так и прироста производительности за счет параллелизма с одним и тем же решением. Другими словами, накладные расходы на отдельные потоки во многих случаях превращаются в плюс за счет использования параллелизма для параллельного выполнения вызовов и ускорения предоставления пользователям возможностей Netflix.
Таким образом, большинство вызовов зависимостей теперь направляются через отдельный пул потоков, как показано на следующей диаграмме:
Если зависимость становится латентной (наихудший тип отказа для подсистемы), она может насытить все потоки в своем собственном пуле потоков, но потоки запросов Tomcat будут истечены тайм-аутом или будут немедленно отклонены, а не заблокированы.
В дополнение к преимуществам которые дала изоляция и одновременное выполнение вызовов зависимостей мы также использовали отдельные потоки, чтобы включить свертывание запросов (автоматическое пакетирование) для повышения общей эффективности и сокращения задержек пользовательских запросов.
Семафоры используются вместо потоков для выполнения зависимостей, которые не выполняют сетевые вызовы (например, выполняющие только поиск в кэше в памяти), поскольку накладные расходы для отдельного потока слишком высоки для таких типов операций.
Мы также используем семафоры для защиты от недоверенных резервных копий. Каждая DependencyCommand может определить резервную функцию (более подробно обсуждается ниже), которая выполняется в вызывающем пользовательском потоке и не должна выполнять сетевые вызовы. Вместо того, чтобы полагаться на то, что все реализации будут правильно соблюдать этот контракт, он также защищен семафором, поэтому, если выполняется реализация, включающая сетевой вызов и ставшая латентной, сам резервный вариант не сможет отключить все приложение, поскольку оно будет ограничено количеством потоков, которые оно сможет заблокировать.
Несмотря на использование отдельных потоков с тайм-аутами, мы продолжаем принудительно устанавливать тайм-ауты и повторные попытки на сетевом уровне (через взаимодействие с владельцами клиентских библиотек, мониторинг, аудит и т. д.).
Тайм-ауты на уровне потоков DependencyCommand являются первой линией защиты независимо от того, как настроен или ведет себя базовый клиент зависимостей, но сетевые тайм-ауты по-прежнему важны, иначе сильно латентные сетевые вызовы могут бесконечно заполнять пул потоков зависимостей.
Предохранитель срабатывает, когда команда DependencyCommand превышает определенный порог ошибок (например, 50% ошибок в течении 10 секунд), и затем отклоняет все запросы до тех пор, пока проверки работоспособности не завершатся успешно.
Это используется в основном для снижения нагрузки на базовые системы (т. е. сброса нагрузки), когда у них возникают проблемы, для уменьшения задержки пользовательских запросов за счет быстрого отказа (или возврата резервного варианта), когда мы знаем, что это может привести к сбою, вместо того, чтобы каждый пользовательский запрос ждал истечения тайм-аута.
Как мы отвечаем на запрос пользователя, когда происходит сбой?
В каждом из вариантов, описанных выше, тайм-аут, отклонение пулом потоков или срабатывание семафора или предохранителя приведут к тому, что запросы от наших клиентов не получат оптимальные ответы.
Немедленный сбой («быстрый сбой») вызывает исключение, из-за которого приложение сбрасывает нагрузку до тех пор, пока зависимость не вернется в нормальное состояние. Это предпочтительнее, чем «нагромождение» запросов, поскольку потоки запросов Tomcat остаются доступными для обслуживания запросов для исправных зависимостей и обеспечивают быстрое восстановление после восстановления отказавших зависимостей.
Однако зачастую существует несколько предпочтительных вариантов предоставления ответов в «резервном режиме», чтобы уменьшить влияние сбоя на пользователей. Независимо от того, что вызывает сбой и как он перехватывается (тайм-аут, отклонение, короткое замыкание и т. д.), запрос всегда будет проходить через резервную логику (шаг 8 на блок-схеме выше), прежде чем вернуться к пользователю, чтобы дать DependencyCommand возможность сделать что-то другое, кроме «быстрого отказа».
Некоторые подходы к запасным вариантам, которые мы используем, в порядке их влияния на пользовательский опыт:
- Кэш: извлечение данных из локального или удаленного кеша, если зависимость в реальном времени недоступна, даже если данные в конечном итоге устарели.
- Согласованность в конечном итоге (Eventual Consistency): запись в очередь (например, в Amazon SQS ) для сохранения после того, как зависимость снова станет доступной.
- Заглушенные данные (Stubbed Data): возврат к значениям по умолчанию, когда персонализированные параметры не могут быть получены
- Пустой ответ («Fail Silent»): возвращает нулевой или пустой список, который пользовательский интерфейс затем может игнорировать.
Вся эта работа направлена на то, чтобы поддерживать максимальное время безотказной работы для наших пользователей, сохраняя при этом максимальное количество функций, чтобы они могли наслаждаться максимально богатым опытом работы с Netflix. В результате наша цель состоит в том, чтобы резервные копии давали ответы, максимально приближенные к тому, что могла бы дать реальная зависимость.
Пример использования
Ниже приведён пример объединения потоков, сетевых тайм-аутов и повторных попыток:
На приведенной выше диаграмме показан пример конфигурации, в которой зависимость не имеет причин достигать 99,5-го процентиля и таким образом, обрывает ее на уровне сетевого тайм-аута и немедленно повторяет попытку с ожиданием средней задержки большую часть времени, и все это выполняется в пределах тайм-аута потока 300мс.
Если у зависимости есть законные причины иногда достигать процентиля 99,5 (т. е. отсутствие кеша при ленивой генерации), то тайм-аут сети будет установлен выше, например 325мс с повторными попытками 0 или 1, а тайм-аут потока установлен выше (350+ мс).
Пул потоков имеет размер равным 10 для обработки пакета запросов 99-го процентиля, но когда все в порядке, этот пул потоков обычно будет иметь только 1 или 2 активных потока в любой момент времени для обслуживания в основном медианных вызовов 40мс.
При правильной настройке, тайм-аут на уровне DependencyCommand должен возникать редко, но защита существует на случай, если что-то, кроме сетевой задержки, повлияет на время, или в худшем случае комбинация подключение + чтение + повторная попытка + подключение + чтение по-прежнему превышает настроенный общий тайм-аут.
Агрессивность конфигураций и компромиссы в каждом направлении отличаются для каждой зависимости.
Конфигурации можно изменять в режиме реального времени по мере необходимости по мере изменения характеристик производительности или при обнаружении проблем без риска отключения всего приложения в случае возникновения проблем или неправильных настроек.
Заключение
Подходы, обсуждаемые в этом посте, оказали огромное влияние на нашу способность переносить сбои системы, инфраструктуры и приложений и быть устойчивыми к ним, не влияя (или ограничено влияя) на взаимодействие с пользователем.
Несмотря на успех этой новой системы обеспечения отказоустойчивости DependencyCommand за последние 8 месяцев, нам еще многое предстоит сделать для улучшения наших стратегий отказоустойчивости и производительности, особенно по мере того, как мы продолжаем добавлять функциональные возможности, устройства, клиентов и международные рынки.
Источник: https://netflixtechblog.com/fault-tolerance-in-a-high-volume-distributed-system-91ab4faae74a