Мы масштабировали GitHub API с помощью сегментированного реплицированного ограничителя скорости в Redis.

Мымасштабировалиgithubapiспомощьюсегментированногореплицированногоограничителяскоростивredis

Около года назад мы перенесли старый ограничитель скорости, чтобы обслуживать больший объем трафика и обеспечить более устойчивую архитектуру платформы. Мы внедрили реплицированный бэкэнд Redis с сегментированием на стороне клиента. В конце концов, это сработало отлично, но мы извлекли некоторые уроки по ходу дела.

Проблема

У нас был старый ограничитель скорости, который был достаточно простым:

  • Для каждого запроса определите «ключ» для текущего ограничения скорости
  • В Memcached увеличьте значение этого ключа, установив его на 1, если не было никакого текущего значения
  • Кроме того, если его еще не было, установите значение «reset at» в Memcached, используя соответствующий ключ (например, « # {key}: reset_at «)
  • Если при увеличении значение «сбросить в» осталось в прошлом, игнорируйте существующее значение и установите новое «сброс в»
  • В начале каждого запроса, если значение для ключа превышает предел, а «reset at» находится в будущем, тогда отклонить запрос

(Возможно было еще нюанс к нему, но это основная идея.)

Однако у этого ограничителя были две проблемы:

  • Наша архитектура Memcached должна была измениться. Поскольку он в основном использовался как слой кэширования, мы собирались перейти с единого общего хранилища Memcached на один Memcached для каждого центра обработки данных. Хотя это нормально сработало бы для кэширования приложений, это привело бы к очень странному поведению нашего ограничителя скорости, если бы клиентские запросы направлялись в разные центры обработки данных.
  • «Постоянство» Memcached у нас не работало. Бэкэнд Memcached совместно использовался ограничителем скорости и другими кешами приложений, что означало, что при заполнении он иногда удалял данные ограничителя скорости, даже когда он все еще был активен. (В результате клиенты будут получать «свежие» окна ограничения скорости, когда они не должны. Иногда только одно будет исключен – они сохранят то же «использованное» значение, но получат новые, будущие, «сбрасываемые» значения!)

Предлагаемое решение

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

  • Используйте Redis, поскольку он имеет более подходящую систему сохранения и простые настройки сегментирования и репликации
  • Shard внутри приложение: приложение будет выбирать для каждого ключа, из какого кластера Redis читать и писать
  • . привязанный к процессору характер Redis, поместите одну первичную (для записи) и несколько реплик (для чтения) в каждый кластер
  • Вместо того, чтобы писать в базе данных «сбросить в», используйте истечение срока действия Redis, чтобы значения исчезали, когда y больше не применяется
  • Реализуйте логику хранения в Lua, чтобы гарантировать атомарность операций (это было улучшением по сравнению с предыдущим дизайном)

Один вариант, который мы рассматривался , но отказался от использования нашего хранилища KV с поддержкой MySQL ( GitHub :: KV ) для хранения. Мы не хотели добавлять трафик к уже занятым первичным серверам MySQL: обычно мы используем реплики для запросов GET, но для обновления ограничения скорости потребуется доступ на запись к первичному серверу. Выбрав другой серверный модуль хранилища, мы могли бы избежать дополнительного (и значительного) трафика записи в MySQL.

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

Релиз

Чтобы развернуть это изменение, мы изолировали текущую логику сохраняемости в MemcachedBackend класс и построил новый RedisBackend класс для ограничителя скорости. Мы использовали флаг функции для доступа к новому бэкэнду. Это позволило нам постепенно увеличивать процент клиентов, использующих новый бэкэнд. Мы могли изменить процентное соотношение без развертывания, что означало , если что-то пойдет не так, мы сможем быстро вернуться к старой реализации.

Релиз прошел гладко, и когда это было сделано, мы удалили флаг функции и класс MemcachedBackend и интегрированный RedisBackend напрямую с Throttler класс, который ему делегировал.

Затем отчеты об ошибках начали поступать в….

Ошибки

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

  1. Некоторые клиенты заметили, что их X-RateLimit-Reset значение заголовка «качнулось» – оно могло отображать - 05 - 01 11: 01: 00 за один запрос, но 5000 - 04 - 04 10: 01: 04 по другому запросу (с одной секундой разница).
  2. У некоторых клиентов были свои запросы отклонено из-за превышения лимита, но в заголовках ответа указано X-RateLimit-Remaining: 601419 . Это не имело смысла: если перед ними было полное окно ограничения скорости, почему запрос был отклонен?

Как странно!

Исправление 1: Управляйте «сбросом в» в коде приложения

Мы были оптимистично настроены по поводу использования встроенного в Redis времени жизни (TTL) для реализации нашей функции «сброса в». Но оказалось, что моя реализация вызвала описанное выше «колебание».

Сценарий Lua вернул значение TTL предельного значения скорости клиента, а затем в Ruby это было добавлен в Time.now.to_i , чтобы получить метку времени для Заголовок X-RateLimit-Reset . Проблема заключалась в том, что время проходит между вызовами TTL (в Redis) и Time.now.to_i (в Ruby). В зависимости от того, сколько времени и где оно приходилось на второй границе часов, итоговая временная метка может отличаться. Например, рассмотрим следующие вызовы:

TTL (Redis)

5

0,1

0.

0,1

В этом случае, поскольку вторая граница произошла между вызов TTL и Time.now , итоговая временная метка была на одну секунду больше , чем предыдущие.

Мы могли бы попытаться повысить точность этой операции (например, Redis PTTL), но там бы по-прежнему было некоторое колебание, даже если оно было значительно уменьшено.

Другой возможностью было вычислить время, используя только Redis вместо того, чтобы смешивать вызовы Ruby и Redis для его создания. Команда Redis TIME могла быть использована как источник истины. (Старые версии Redis не допускали TIME в сценариях Lua, но Redis 5+ Мы избегали этого дизайна, потому что его было бы труднее протестировать: используя время Ruby как источник истины, я мог путешествовать во времени в своих тестах с Timecop, утверждая, что просроченные ключи обрабатывались правильно без фактического ожидание вызовов Redis к системным часам, чтобы вернуть истинное время в будущем. (Мне все еще пришлось ждать Redis, чтобы протестировать EXPIRE очистку базы данных, но поскольку expires_at пришел из Ruby-land, я мог ввести очень короткие окна срока действия в упростить тестирование.)

Вместо этого мы решили сохранить время «сброса в» из Ruby в базе данных. Таким образом, мы могли быть уверены, что он не будет раскачиваться. (Колебание было результатом вычисления ) – но чтение из базы данных гарантировало бы стабильное значение.) Вместо чтения TTL из Redis мы сохранили другое значение в базе данных (фактически удваивая объем хранилища, но все в порядке).

Мы по-прежнему применяли TTL для ключей ограничения скорости, но они были установлены на одну секунду после время «сброса в». Таким образом, мы могли бы использовать собственную семантику Redis для очистки «мертвых» окон ограничения скорости.

Исправление 2: Срок действия учетной записи в репликах

Как ни странно, многие клиенты сообщили отказы , которые включали X-RateLimit-Remaining: заголовки. Что происходит!?

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

  1. В начале запроса проверьте текущее значение лимита скорости для клиента. Если он превышает максимально допустимый предел, подготовьте ответ об отклонении.
  2. Перед доставкой ответа увеличьте текущее значение значение ограничения скорости и используйте ответ для заполнения X-RateLimit -... заголовки.

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

Затем на шаге 2 будет выполнено Redis начальный. Во время этого вызова базы данных Redis истекает срок действия данных предыдущего окна и возвращает данные для свежих ограничение скорости. Это известное ограничение Redis: у реплик не истекает срок действия данных до тех пор, пока они не получат инструкции сделать это от своих первичных файлов, а первичные реплики не истекают сроком действия ключей до тех пор, пока к ним не будет получен доступ ( Проблема с GitHub ). (Фактически, первичные делают случайным образом выбирают ключи из времени время, истекая их соответствующим образом, см. « Как Redis Expires Keys ».)

Для решения этой проблемы требовалось две вещи:

  • По сути, то же самое исправление, что и выше: вместо того, чтобы полагаться на TTL Redis, чтобы истечь старый предел скорости home windows, нам нужно было управлять этой функцией в приложении. (Приложение должно быть готово к чтению устаревших данных с реплик, а затем игнорировать их.)
  • Даже после исправления это требовало лучшего дизайна: в случае запросов с ограничением скорости мы должны избегать второго обращения к базе данных. Окно клиента может истечь между двумя вызовами, что приведет к несогласованному ответу, описанному выше. Это исправление потребовало улучшения кода Ruby, который готовил ответы, чтобы ответ из шага 1 выше использовался для заполнения X-RateLimit -... заголовки.

Финальные скрипты

Вот сценарии Lua, которые мы получили для реализации этого шаблона:

- RATE_SCRIPT: - подсчитать запрос для клиента - и вернуть текущее состояние для клиента - переименовать входы для ясности ниже native rate_limit_key = KEYSlocal increment_amount = количество единиц (ARGV ) native next_expires_at = tonumber (ARGV [2]) native current_time = tonumber (ARGV [3]) native expires_at_key = rate_limit_key .. ": exp" native expires_at = tonumber (redis.name ("get", expires_at_key)) если не expires_at или expires_at

Заключение

Мы многому научились из этого нового подхода, но мы все еще учитываем один недостаток: текущая реализация не увеличивает «текущее» предельное значение скорости до тех пор, пока запрос не будет законченный. Мы делаем это, потому что не берем с клиентов плату за ответы (это может произойти, когда клиент предоставляет E-Tag). Лучшая реализация могла бы увеличить значение при запуске запроса , затем возвращает клиенту деньги, если ответ 937 . Это предотвратит некоторые крайние случаи, когда клиент может превысить свой лимит, когда последний разрешенный запрос все еще обрабатывается.

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

Leave a comment

Your email address will not be published. Required fields are marked *

Вызов Redis начинается задержка
задержка Time.now возвращается сумма TTL и Time.now
11: 04: 05. 2 0,1
10: 01: 10. 4 38: 04: 11. 1

(затем, полсекунды спустя)
11: 01: 10. 9

5

10: 01: 11. 05 11: 01: 45. 06