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

задняя часть

Концепция: плавкие предохранители и ограничение тока

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

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

Эти два соображения и являются темой нашей платформы стабильности: автоматический выключатель и ограничение тока. В Интернете ходит поговорка о том, что автоматический выключатель, ограничение тока и переход на более раннюю версию — это три оси распределенной архитектуры, что свидетельствует о ее важности.

Предохранитель, автоматический выключатель, также называемый автоматическим выключателем. Это утверждение заимствовано из схемы, которая фактически представляет собой модернизированную версию предохранителя. После перегорания предохранителя его можно только заменить, в то время как автоматический выключатель не требует замены после его отключения и может быть сброшен вручную. Фьюзы использовались в программных комплексах, вероятно, ещеRelease It!: Design and Deploy Production-Ready Softwareпредставлены в этой книге. Мартин Фаулер, автор книги «Рефакторинг», писал об этой концепции, см.CircuitBreaker, и былHystrixПроект процветал.

Основная логика автоматического выключателя может быть представлена ​​в виде простого конечного автомата:

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

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

Концепция ограничения тока относительно проста, просто рассчитайте QPS и принимайте соответствующие решения. Здесь фактически два типа сценариев, в зависимости от того, где используется ограничитель тока, является ли он «инициатором» или «получателем» трафика, логика обработки различается. Для сценария микросервиса на стороне сервера используется ограничитель тока для ограничения скорости вызывающего абонента, это так называемый "получатель трафика". После превышения порога его обычно можно сбросить напрямую. Назовем его "ограничение права вето". поток". Например, при потреблении MQ-сообщений или отправке Push, во избежание зависания нижестоящих сервисов, от которых он зависит, ограничивает скорость собственного потребления/отправки Push, это так называемый «инициатор трафика». , если порог превышен. Обычно мы выбираем ожидание, то есть блокировку вызова текущего ограничителя, и только когда мы возвращаемся из вызова, мы продолжаем выполнять действие, которое мы называем «блокировка ограничения тока». С точки зрения реализации, общие алгоритмы ограничения тока включают скользящее окно, ведро маркеров, дырявое ведро и т. д., которые мы можем выбрать.

Стабильные платформы: требования и дизайн

Во-первых, нам нужна глубокая интеграция с сервисной структурой. Что касается слияния, необходимо поддерживать настройку порога слияния для каждого вызываемого интерфейса. Что касается ограничения тока, необходимо поддерживать установку разных порогов ограничения тока для разных вызывающих абонентов по интерфейсу. Также должно поддерживаться ограничение тока предохранителем не-интерфейсов, в частности, ограничение тока не-интерфейсов должно поддерживать как ограничение тока типа вето, так и ограничение тока блокирующего типа. Наша текущая ситуация заключается в том, что платформа управления службами проанализировала IDL всех служб и предоставила интерфейсы, поэтому очень удобно получать информацию об интерфейсах служб. Для ограничения тока вызывающим абонентом реальность не так хороша, и вызываемый абонент пока не может получить идентификатор службы вызывающего абонента. После расследования было обнаружено, что эта функция может поддерживаться с помощью механизма багажа opentracing. Однако это требует некоторой трансформации нашей сервисной инфраструктуры и базовой библиотеки, поэтому на втором этапе необходимо поддерживать функцию «ограничения тока вызывающей стороной».

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

В-третьих, микросервисы обычно развертываются в кластерах.Говоря о сервисных возможностях, мы часто по умолчанию имеем в виду сервисные возможности кластеров. Если мы предоставим возможности ограничения тока на уровне кластера, это будет соответствовать этой точке зрения, и пользователи смогут игнорировать влияние расширения и сокращения услуг, и опыт будет лучше. Однако, учитывая дополнительную сложность реализации и накладные расходы на ограничение тока на уровне кластера, например, требуется внешнее хранилище для сохранения состояния и синхронизации чтения и записи состояния несколькими узлами для поддержания согласованности данных, и, например, сеть накладные расходы на чтение и запись внешнего хранилища, может Поскольку задержка самого ограничителя тока будет достигать уровня мс (в то время как накладные расходы на доступ к памяти незначительны), мы, наконец, решили пока обеспечить только одноузловое ограничение тока. Кроме того, мы также исследовали схему ограничения тока кластера Sentinel, и его токен-сервер мало чем отличается от службы. И мы считаем, что SDK должна быть библиотекой, которая «без меня».
(Слияние — это перспектива с одним узлом, и такой проблемы нет.)

Что касается дизайна системы, мы решили:
1. Фон управления полностью независим от SDK. Они согласны только с методом хранения конфигурации предохранителя и ограничения тока (мы сохраняем конфигурацию в etcd).
2. SDK делится на основной API и периферийный API. Основной API включает основную логику автоматических выключателей и ограничителей тока, а периферийный API инкапсулирует основной API, например, добавляет такие функции, как загрузка конфигурации из etcd и мониторинг обновлений конфигурации. Периферийные API упрощают интеграцию SDK с сервисными платформами, требуя всего около десятка строк кода. Это также обеспечивает независимость стабильной платформы. Основной API можно использовать в других конкретных сценариях, таких как SlidingWindow, который используется другими проектами для реализации функций квот ресурсов.

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

предохранитель

Выключатель удерживает скользящее окно и некоторые другие внутренние состояния. Скользящее окно имеет 10 ячеек, и каждая ячейка отвечает за длительность 1 с. Автоматический выключатель управляет переходом состояния на основе статистики этих 10 с и успеха или неудачи каждого запроса.

Поскольку время доступа к нижестоящим службам неизвестно, когда запрос завершен, автоматический выключатель может подвергнуться переходу состояния, что требует особого внимания. Например, запрос к нисходящему потоку очень медленный, состояние фьюза закрыто при его инициации, а фьюз стал открытым или даже полуоткрытым, когда запрос завершен. Для открытого состояния просто игнорируйте его. Для состояния half_open этот запрос нельзя использовать для определения того, восстановлена ​​ли нижестоящая служба (называется пробным запросом). Чтобы справиться с этой ситуацией, необходимо пометить каждый запрос как зонд.

Блокирующий ограничитель

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

Наша логика реализации такова. Каждый LeakyBucketRateLimiter содержит канал и имеетleak()Способ завершает действие «выщелачивания».leak()выполняется в отдельной процедуре go, основанной наLeakyBucketRateLimiterПорог qps для подсчета тиков, в каждом тиковом интервале пропускает «каплю воды». Так называемая недостающая «капля воды» на самом деле просто считывает элемент в канале. И тип считываемого элемента тоже канальный,leak()После считывания в него записывается значение для пробуждения вызывающих абонентов, которые могут быть заблокированы.

И метод, вызываемый вызывающей стороной,Limit(), который создает канал и записывает его в канал LeakyBucketRateLimiter. После записи вызывающий абонент читает созданный им канал. Здесь вызывающий абонент может заблокироваться в двух местах. Во-первых, при записи в канал LeakyBucketRateLimiter, если он заполнен, он блокирует операцию записи, только когдаleak()Разблокировка выполняется только тогда, когда данные считываются из канала LeakyBucketRateLimiter, чтобы сделать его неудовлетворительным. Второе место блокировки — когда вызывающая сторона читает созданный им канал, если запись в канал LeakyBucketRateLimiter прошла успешно, ноleak()Вызывающий блокируется, если записанный канал не был обработан напрямую.leak()Обработанный.

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

Для работы с изменениями конфигурации требуется некоторая дополнительная обработка. Когда настроенное пороговое значение qps изменяется,leak()Тик изменится. Наш подход заключается в том, чтобы добавить канал изменений, и при изменении порога qps сразу же записывать данные в канал изменений. иleake()Читать несколько каналов одновременно через select..case и пересчитывать тик, когда в канале изменения есть данные.

Кроме того,leak()Выполняется в отдельной процедуре go. Мы надеемся, что после того, как текущий ограничитель будет израсходован, все ресурсы, включая эту процедуру go, можно будет высвободить. Поэтому добавляется закрытый канал, вызывающийClose()метод, который записывает значение в этот канал,leak()Выйдите сразу после чтения, тем самым освободив подпрограмму go, в которой она находится.

Наконец,leak()Будет «пропускать каплю воды» каждый тик, а когда порог qps большой, тик будет маленьким. На этом этапе может стать заметной точность и производительность таймера (мы используемtime.Tick()). Когда такт очень мал, точность таймера может быть недостаточной, и производительность самого ограничителя тока становится существенной, что влияет на эффект ограничения тока. Конечно, в сценарии с ограничением по току точность таймера не в центре внимания, в настоящее время необходимо уделить внимание проблеме производительности. Оптимизировать эту задачу не составляет труда. Просто измените «отсутствует одна капля за тик» на «отсутствует N капель за тик». Мы можем выбрать тик, поддерживаемый таймером (чем меньше, тем лучше), и рассчитать количество капель воды, которые должны вытечь за этот тик. По сути, это компромисс между точностью и производительностью.

Основной код выглядит следующим образом:

Отмененный ограничитель тока

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

Точно так же ограничитель тока удерживает скользящее окно. Скользящее окно имеет 10 ячеек, и каждая ячейка отвечает за длительность 0.1с.Текущий ограничитель считает количество запросов в эту 1с.Если порог qps не превышен, то он пройдет, а если превысит, то будет вернуть ошибку.
Реализация ограничителя тока типа вето очень проста и не нуждается в дополнительном описании.

И предохранители, и ограничители тока типа вето используют скользящие окна.Вот краткое введение в реализацию скользящих окон.

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

SlidingWindow ленив, только при доступе к ячейке будет определяться, истек ли срок действия ячейки или нет, на основе текущего времени, времени начала ячейки и продолжительности ячейки. Если срок его действия истек, сначала сбросьте его. Lazy SlidingWindow, так что нам не нужно использовать отдельную процедуру go для обновления содержимого SlidingWindow время от времени, чтобы SlidingWindow не нужно было использовать блокировки. Кроме того, когда в SlidingWindow необходимо использовать «текущее время», оно также передается параметрами, что минимизирует внутреннее состояние SlidingWindow и упрощает его тестирование.

Периферийный API

Периферийный API — это всего лишь слой инкапсуляции основного API для удобства использования.

Взяв предохранитель в качестве примера, мы определяемRegistryинтерфейс, который имеет реализациюEtcdRegistry. представлено в модулеInit()функция для созданияEtcdRegistryinstance и назначьте его частному глобальномуRegistryпример.Init()Необходимо передать идентификатор службы, чтобы можно было загрузить конфигурацию каждой службы в фоновом режиме управления. Другой открытый API модуля:func Do(ctx context.Context, name string, run runFunc, fallback fallbackFunc) error,nameУкажите вызываемую службу и имя интерфейса, runFunc — это обычная логика, а fallbackFunc — логика итоговой строки. Это согласуется с интерфейсом, предоставляемым hystrix-go.Do()Внутри соответствующий фьюз будет найден по названию, и логика вызова фьюза аналогичнаDo()метод.EtcdRegistryдержать несколькоCircuitBreaker, когда он будет создан, он откроет подпрограмму для отслеживания изменений в конфигурации в etcd.Если есть изменения, он вызовет соответствующийCircuitBreakerметод изменения его конфигурации.

Использование глобальных переменных здесь также для удобства. Пользователю нужно только позвонитьInit()иDo()функция, нет необходимости создавать свои собственныеEtcdRegistryи слушать etcd. Выставляйте как можно меньше API и скрывайте остальные как детали реализации. Это упрощает интеграцию с сервисной структурой и требует рассмотрения гораздо меньшего количества деталей.

Основной код:

Периферийный API ограничителя тока аналогичен и не нуждается в дополнительном описании.

Эффект после выхода в интернет

Выйдя в интернет, посмотрев показатели мониторинга соответствующих сервисов, обнаруживается, что количество процессов Go и количество объектов кучи уменьшилось со значительным отрывом, и даже показатели некоторых сервисов улучшились по всем аспектам, и использование ЦП, памяти и время отклика уменьшились.Время отклика стало более плавным, а количество сбоев заметно уменьшилось. Как показано на рисунке:

В день запуска (время запуска около 11:40 19.08.2020):

Время увеличено до одного дня до запуска и двух дней после запуска:

Видно, что даже после нескольких дней эксплуатации эффект все равно очевиден.

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

Разница до и после запуска в том, что hystrix-go заменяется нашим SDK. Кроме того, добавлен ограничитель тока. Конечно, текущий ограничитель может только увеличить накладные расходы во всех аспектах, а не уменьшить их (наблюдаемый сервис не запускает текущий лимит, и нагрузка на сервис не будет снижена из-за текущего лимита). Итоговый вывод: наш SDK превосходит hystrix-go по производительности. По крайней мере, с точки зрения подпрограмм go, hystrix-go запускает как минимум две подпрограммы go за вызов и использует множество средств синхронизации, таких как каналы. В нашей реализации автоматического выключателя есть только одна глобальная процедура включения. Таким образом, процедура go и связанные с ней накладные расходы уменьшаются в размере. Кроме того, наш автоматический выключатель использует только блокировки в качестве средства синхронизации и не использует каналы, что может сэкономить некоторые накладные расходы.

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

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

С другой стороны, это функция ограничения тока кластера. Микросервисы всегда были развернуты в кластерах.Служба по умолчанию, о которой мы говорим, — это ее способность предоставлять услуги в виде кластера.Переключение на перспективу с одним экземпляром при установке текущего ограничения всегда немного неудобно. Некоторые проблемы регулирования кластера упоминались ранее, и будет интересно попытаться решить эти проблемы. Sentinel предлагает решение для сервера токенов, и мы можем придумать несколько других идей. Например, использование общей инфраструктуры, такой как redis + Lua или nginx + Lua, также немного снижает сложность проблемы.

Последняя мысль касается «ограничения скорости» и «ограничения», они оба называются ограничением скорости, но это разные понятия. Платформы стабильности ограничены по скорости, ограничены по скорости и не зависят от времени. Когда скорость ограничена, длина скользящего окна является деталью реализации, которая скрыта от внешнего мира.Разная длина скользящего окна влияет на точность измерения, но нас интересует сама «скорость». Лимит другой, и лимит ориентируется на «количество» потребления ресурсов. Квоты часто также дают фактор времени, что еще больше усугубляет путаницу. «Количество запросов в минуту не превышает 60», что не эквивалентно «QPS не превышает 1», первое — ограничение, второе — ограничение скорости. Почти все выражения, в которых говорится «не более YY в период времени XX», можно рассматривать как ограничение, а «количество запросов в секунду не должно превышать XX» — это ограничение скорости. Разница между ними по сути аналогична разнице между «скоростью» и «средней скоростью», упомянутой в школьном физике. Кроме того, «предел» часто выражается несколькими правилами, такими как «не более чем 60 запросов в одну минуту» и «не более чем 500 запросов в десять минут», и «не более чем 2000 запросов в один час». Кроме того, «квота» также включает в себя «субъект квот», такие как ограничения на IP / пользователя, а также статус потребностей различных субъектов, которые будут храниться независимыми.

Подводя итог, следующие пункты обычно ограничены:

  • Описание периода времени, включающее единицы, превышающие «секунды», например «одна минута ххх».
  • Включает несколько правил, например, что такое минута и что такое час
  • Вовлечение субъектов, таких как введение ограничений на IP/пользователя

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

использованная литература