Как стать автором
Обновить

YARL: как Яндекс построил распределённый Rate Limiter с нулевым влиянием на время ответа сервисов

Время на прочтение14 мин
Количество просмотров29K
Всего голосов 67: ↑66 и ↓1+78
Комментарии26

Комментарии 26

Да, YARL написан на Go.

Похоже, что примерно так и получилось. Я, к сожалению, не знаю истории развития и заморозки лимитера на эрланге.

А исходники закрыты?

На текущий момент да. Как дальше сложится пока не знаю.

А GC в Go вас не смутил и не мешает? Основная цель ведь максимально быстро давать ответ.

Нет, не мешает. Никаких специальных приседаний для борьбы с GC мы не делали.

GC начиная с версии 1.5-1.6 уже не такой злой, каким был. Это раньше он мог на 50-100мс залочить исполнение программы и хрустеть там себе памятью.

Сейчас он блокирует мир на очень короткое время и влияния GC мы пока на метриках не видим.

Я немного пропустил, кто организует утечку?
Выбирается некий "мастер YARL", который применяет на себе spent_quota -=(limit*seconds_since_last_update) а потом синхронизируется со всеми серверами, или это делается как-то иначе?

все на самом деле чуть-чуть хитрее. Нижняя граница ведра вычисляется как unix_timestamp*limit, а верхняя как unix_timestamp*limit + low_burst, т. е. они динамические, а суммарное значение счетчика строго возрастает. Получается, что "утечка" организуется благодаря текущему времени, за счет смещения границ ведра. В текущее значение при синхронизации с рутом добавляется то, что насчиталось с момента предыдущей синхронизации. Если это значение ниже нижней границы, то "доливаются" запросы до нижней границы. Синхронизация выполняется параллельно с несколькими рутами, и просто выбирается наибольшее значение счетчика.

Счетчик не переполнится?
Он же как-то должен сбрасываться, в противном случае при больших весах запроса (размер запроса в байтах может исчисляться миллионами) мы можем накопить очень много. Особенно если unix_timestamp - это начиная с 1970-ого, а не с запуска YARL.

да, есть возможность переполнения. Так как счетчик 64 битный, то размер лимита по сути ограничен 2^32. Нам на самом деле хватает, в случае чего можно выбрать более крупную единицу измерения(кб, мб)

Понял, спасибо!

Что такое "постреливать запросы клиента"?

Это значит кидать в клиента HTTP 429.

Да.

Ответить на запрос клиента полноценно почти всегда означает напрячь базу, хранилище или кеш, сходить в сервис авторизации и т.п. то есть обработка дорогая, если сравнивать со статическим ответом «приходите позже» с 429 HTTP-кодом.

Спасибо за статью, пришел к такому же решению. Делал свой middleware на go c локальными счетчиками и синхронизацией, идентификатор брал из jwt-токена или сессии. Надо будет обдумать ваше решение с lowBurst и highBurst, мне помимо счетчиков нужен был именно sliding window rate limiter, но я вроде не стал его реализовывать.

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

Я правильно понимаю, что в вашем случае задача была измерять квоту на длинной дистанции (суммарное потребление чего-то за неделю/месяц/квартал)?

Если да, то такие штуки мы тоже делаем, но не через YARL. Это задача, которой я очень вскользь коснулся, когда говорил об учёте потребления дисков (объём объектов в бакетах). У такой задачи безусловно есть свои особенности, но обычно там сильно меньше требования и к скорости реакции на событие, и к скорости подсчёта потребления. Его можно хоть в фоне делать отдельной джобой по крону.

YARL затачивался именно под производные величины по времени. Кажется, математически это записывается как-то так: \frac{d}{dt}

Такие штуки очень резко меняются и там за лишнюю минуту времени реакции можно весь сервис успеть в ступор поставить. Поэтому важно как можно раньше понимать, что нужно начинать отстреливать клиентов.

В нашем случае мы тоже не стреляем запросы до синхронизации: пока синхронизация не пройдёт, каждая отдельная тачка в кластере просто не знает, сколько всего прилетело в кластер. Просто мы синкаемся раз в секунду, поэтому реакция выглядит вполне шустро.

Да, я думаю, это частый кейс, квоты на API, например, бесплатный тариф 10 тыс. запросов в месяц. Делали чтобы каждый сервис часто не дергал базу, синхронизация была раз в минуту чтобы у пользователя графики были красивые. Отдельное ограничение на количество запросов тоже делали, но с фиксированным окном. Я понял в чем различие в вашим кейсом, у нас нет своих кластеров, но если было бы нужно, мы могли бы тоже настроить чтобы через пару минут поднимались доп. контейнеры в облаке. Раз в секунду слишком часто для нас.

Интересное решение! Спасибо за описание!

Спасибо, мы старались.

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

До самой публикации сомневался, "полетит ли".

Спасибо за статью. Я правильно понимаю, что каждый сервис локально у себя хранит данные в виде user_id => его текущие квоты по данному сервису?

И да, и нет.

YARL встраивается в приложение. Либо в nginx с помощью модуля, либо в go-код прям вкомпиливанием пакета. Т.е. приложение в итоге знает обо всех квотах и счетчиках, которые имеют отношение к приложению.

Но модуль/пакет YARL при этом ничего не знает о логике приложения, и приложение не знает о кишках YARL'а. Общение происходит через простой интерфейс: "вот тебе уникальное имя квоты (текстовое), накрути/проверь/залимитируй".

Приложение, имея доступ к информации о запросе, может из этой информации сгенерить какое-то уникальное имя квоты. Тут мы в S3 можем использовать User ID, имя бакета, тип HTTP-запроса или любое другое свойство, которое из этого запроса можно достать. Из этих данных приложение генерит имя квоты и скармливает YARL'у.

Так что приложение хранит у себя в памяти алгоритм генерации имени квоты для запроса, а модуль YARL хранит в памяти 2 map'ы: ``<quota uniq name> => <quota info>`` и ``<counter ID> => <counter value>``. По имени квоты он достаёт из map'ы её параметры (лимит, high/low burst и ID счетчика), а по текущему значению счетчика понимает, нужно ли блокировать запрос.

В такой схеме достаточно в базе YARL завести квоту с правильным именем, чтобы начать лимитировать запросы с определенными свойствами. Например, если приложение для чтения любого объекта из бакета myawesomebucket проверяет квоту "s3_myawesomebucket_object:get", то достаточно добавить в базу YARL запись с таким именем и запросы автоматом начнут ограничиваться через пару-тройку секунд.

Благодарю за развернутый ответ.

Limiter хранит все квоты и счётчики в памяти и регулярно синхронизируется с корневым сервером (YARL root), получая от него оперативную информацию о свежих значениях.

а как боретесь с выпадением/обновлением root'а? Что будет, если он окажется перегружен?

Про выпадение/обновление:

Никак не боремся. Система устойчива к исчезновению любого из root-серверов, она этого даже не замечает.

Каждый инстанс сервиса (в нашем случае хост с сервисом S3) просто отправляет свои счётчики во все root-серверы, о которых она знает, независимо друг от друга. Т.е. мы в конфиге S3 прям прописываем: вот тебе 3 root'а - ходи в них. Когда этот инстанс от каждого из root'ов получает свежие значения счетчиков для всего кластера, он выбирает самое большое значение и использует его.

Синхронизация происходит в фоне, так что залипший синхронизирующий запрос к одному из root'ов никак не сказывается на скорости обработки клиентского запроса. Выпал root - все синхронизации к нему будут таймаутиться, от него к каждому инстансу не будут приходить счётчики кластера. Инстанс будет просто опираться на данные от двух оставшихся root'ов. Выпадет второй root - будем лететь на одном. Выпадут все - лимитирование из распределенного превратится в локальное: синхронизация вырубится и каждая из тачек продолжит накручивать локальные счётчики (сколько конкретно в неё пришло), но не будет знать об общей нагрузке на кластер. В этом случае нам проще считать, что лимитирование вырубается, т.к. у нас в кластере больше 300 машин: чтобы накрутить тот же счётчик до условных 1 000RPS без синхронизации, нужно на весь кластер равномерно подать 300*1000 = 300 000 RPS.

Как только root вернётся - инстансы начнут с ним синхронизироваться и счётчики root'а быстро вернутся к актуальным значениям.

Про "перегруженный" root:

В нашей архитектуре все root'ы обрабатывают одинаковую нагрузку. Если будет тупить какой-то один или два из трёх - это никак не скажется на лимитах. От таких тупящих root'ов на инстансы будет приходить меньшее значение счётчиков и они просто будут отбрасываться (из трёх разных чисел для одинакового счётчика от трёх рутов мы просто выбираем больший). Могут оказаться перегруженными все 3 рута одновременно. Мы пока до такого не доходили, но если такое случится, то YARL начнёт "занижать" нагрузку на весь кластер: часть хостов не смогли отправить счётчики -> суммарный счётчик для кластера складывается из меньшего количества значений -> оценка нагрузки на кластер меньше реальной -> мы лимитируем запросы позже, чем должны.

То есть снаружи эта ситуация будет выглядеть так, будто мы равномерно увеличили лимиты всем клиентам.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий