Около года назад мы перенесли старый ограничитель скорости, чтобы обслуживать больший объем трафика и обеспечить более устойчивую архитектуру платформы. Мы внедрили реплицированный бэкэнд 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 заключается в том, что этот путь уже давно пройден. Мы могли бы почерпнуть вдохновение из двух замечательных существующих ресурсов:
- Собственная документация Redis, которая включает некоторые шаблоны ограничителя скорости
- Сообщение в техническом блоге Stripe: « Масштабирование вашего API с помощью ограничителей скорости », который включает Ruby и Redis пример реализации
Релиз
Чтобы развернуть это изменение, мы изолировали текущую логику сохраняемости в MemcachedBackend
класс и построил новый RedisBackend
класс для ограничителя скорости. Мы использовали флаг функции для доступа к новому бэкэнду. Это позволило нам постепенно увеличивать процент клиентов, использующих новый бэкэнд. Мы могли изменить процентное соотношение без развертывания, что означало , если что-то пойдет не так, мы сможем быстро вернуться к старой реализации.
Релиз прошел гладко, и когда это было сделано, мы удалили флаг функции и класс MemcachedBackend
и интегрированный RedisBackend
напрямую с Throttler
класс, который ему делегировал.
Затем отчеты об ошибках начали поступать в….
Многие интеграторы очень внимательно следят за использованием своего лимита скорости. В течение нескольких недель после выпуска мы получили два действительно интересных отчета об ошибках:
- Некоторые клиенты заметили, что их
X-RateLimit-Reset
значение заголовка «качнулось» – оно могло отображать- 05 - 01 11: 01: 00
за один запрос, но5000 - 04 - 04 10: 01: 04
по другому запросу (с одной секундой разница). - У некоторых клиентов были свои запросы отклонено из-за превышения лимита, но в заголовках ответа указано
X-RateLimit-Remaining: 601419
. Это не имело смысла: если перед ними было полное окно ограничения скорости, почему запрос был отклонен?
Как странно!
Исправление 1: Управляйте «сбросом в» в коде приложения
Мы были оптимистично настроены по поводу использования встроенного в Redis времени жизни (TTL) для реализации нашей функции «сброса в». Но оказалось, что моя реализация вызвала описанное выше «колебание».
Сценарий Lua вернул значение TTL предельного значения скорости клиента, а затем в Ruby это было добавлен в Time.now.to_i
, чтобы получить метку времени для Заголовок X-RateLimit-Reset
. Проблема заключалась в том, что время проходит между вызовами TTL (в Redis) и Time.now.to_i
(в Ruby). В зависимости от того, сколько времени и где оно приходилось на второй границе часов, итоговая временная метка может отличаться. Например, рассмотрим следующие вызовы: