![Ограничение в действии: RPS кратно растет, но время ответа базы не превышает установленного предела Ограничение в действии: RPS кратно растет, но время ответа базы не превышает установленного предела](https://habrastorage.org/getpro/habr/upload_files/ddd/987/e16/ddd987e1666f2cf7d106f29e687da3be.png)
При росте нагрузки одна из частей системы может подтормаживать. Часто уязвимым местом оказывается база данных. Так произошло и в нашем случае.
Я работаю в Mindbox в команде, которая отвечает за выдачу товарных рекомендаций. Наша база периодически деградировала, заливать ее деньгами (скейлить) не хотелось, а кешировать запросы не позволяла специфика данных.
В этой статье расскажу про комплексное решение, к которому мы пришли — динамически ограничивать конкурентные запросы и менять лимит в зависимости от времени ответа базы.
С чем работали
Mindbox — сервис для маркетологов. Одна из функций — подбор персональных рекомендаций: система анализирует активность пользователя и выбирает товары, которые могут его заинтересовать. Эти предложения лежат в базе данных.
В базу поступает два типа запросов: синхронные и асинхронные.
Синхронные. Например, пользователь зашел на сайт — должны подгрузиться рекомендованные товары. Такие запросы:
Создают невысокую и стабильную нагрузку — около 150 RPS.
Нужно обработать как можно скорее. Если ответ будет поступать за треть секунды — это уже плохо, ведь все это время пользователь ждет.
Отправляет пользователь — настроить логику их повторения нельзя.
Асинхронные. Например, интернет-магазин запускает рассылку на миллион клиентов, в письма подставляются рекомендованные товары. Они:
Создают большую нагрузку — до 8 тысяч RPS. И случается она периодически — 1–2 раза в день.
Можем обрабатывать долго. Даже если письма будут готовиться минуту, бизнес ничего не потеряет.
Отправляет внутренний сервис — можно настроить логику ретраев.
![Как растет RPS к базе, когда большой клиент запускает рассылку с блоками рекомендации Как растет RPS к базе, когда большой клиент запускает рассылку с блоками рекомендации](https://habrastorage.org/getpro/habr/upload_files/370/512/af2/370512af2007efe3c43bd7089d13af1c.png)
Все запросы поступают в единую базу данных. С синхронными она справляется, но как только вместе с ними поступают и асинхронные, база «перегревается»: время ответа увеличивается втрое и синхронные запросы страдают.
Задача — добиться, чтобы асинхронные запросы не тормозили базу, а синхронные обрабатывались по-прежнему быстро. Для этого будем отбрасывать асинхронные запросы, которые выходят за лимит.
Почему не воспользовались стандартными методами
Мы сразу отмели простые способы решения проблемы:
Вертикально заскейлить (купить базу дороже и мощнее). Лишь на время скроем проблему: мы продолжим неоптимально использовать ресурсы и вскоре снова столкнемся с деградацией.
Сделать реплику базы и перекинуть на нее тяжелую нагрузку. Это дорого и усложняет работу — появляется еще одна база, которую нужно поддерживать, оплачивать, а за механизмом репликации нужно следить.
Кешировать данные. В нашем случае данные быстро обновляются: клиент зашел на страницу нового товара — рекомендации чуть изменились. На момент нового запроса кеш уже протухнет.
Есть еще два решения, которые происходят уже на уровне сервиса — ограничение по RPS (request per second) и статическое ограничение конкурентных запросов. От них также решили отказаться.
Ограничение по RPS
Суть метода: установить лимит — определенное число запросов, которые могут направляться в базу каждую секунду. Все запросы, не входящие в лимит, отклоняются или встают в очередь.
Это простой и распространенный способ, у большинства языков есть библиотеки для его реализации, а у некоторых он встроен в базовый фреймворк. Есть множество вариантов реализации, которые можно подбирать под задачу: token bucket, fixed window, sliding window etc.
У статического ограничения по RPS есть проблемы:
Отрезаем запросы, которые могли бы обработать. Например, если прилетает много «легких» запросов, база быстро их пережевывает и может взять еще, но лимит RPS израсходован и не пускает новые.
Отдаем много тяжелых запросов. Обратная ситуация: пришло много сложных запросов, мы пускаем их по лимиту, но база не справляется с таким количеством.
Сложности при скейлинге. Лимит задается разработчиком, поменять его можно только вручную. Настройки ограничения по RPS обычно лежат внутри сервиса, и их приходится подвязывать либо к количеству подов, либо к суммарной настройке RPS по всем инстансам. Оба варианта выглядят некрасиво и сложно, и обычно в них легко пропустить ошибку.
Статическое ограничение конкурентных запросов
Метод похож на ограничение по RPS, но учитывает не количество запросов в единицу времени, а количество одновременных запросов в базу. Ограничивать их просто — обычного семафора хватит.
Такой подход позволяет избежать ситуаций, когда мы отрезаем запросы, которые могли бы обработать, или отдаем много тяжелых запросов. Однако управлять количеством запросов по-прежнему нужно централизованно — писать сложный код, покрывающий непрозрачную логику, чтобы каждый инстанс приложения понимал, сколько ему положено держать запросов.
На практике это выражается в следующих сложностях:
Еще одна точка синхронизации. Что будет с сервисом, если эта точка не будет отвечать?
Запаздывание в системе. В идеальном случае ко времени ответа добавятся доли секунд, пока запрос летит от точки синхронизации до сервиса. Но чаще — период поллинга, измеряемый секундами.
Если нужно скейлить базу или сервисы приложения, настройку все равно придется подбирать на глаз.
Наш метод — динамическое ограничение конкурентных запросов
Для своей задачи мы решили менять количество конкурентных запросов, ноне отталкиваться от статичной цифры, а подстраиваться под время ответа базы. Ответы приходят быстро, мы увеличиваем лимит; база тормозит — уменьшаем. Такая система обратной связи позволяет выставить желаемое время отклика и поддерживать его.
Лимит в каждом инстансе независимо подстраивается под нагрузку. Благодаря этому нет необходимости в общей точке синхронизации при скейлинге сервиса или базы — система сама адаптируется под новые условия.
Для решения похожей проблемы уже есть специализированные алгоритмы. Они используются в TCP (Transmission Control Protocol) и называются congestion control algorithms. С их помощью предотвращают застой трафика в TCP-узле: разница здесь только в том, что вместо congestion window мы будем использовать количество одновременных запросов (concurrency). В разработку сервисов такие алгоритмы проникли не так давно, так как распределенные нагруженные системы — относительно новая вещь. Но у взрослых дядей уже можно увидеть подобное: например, стратегия ретраев s3 у Amazon, библиотека лимитера у Netflix, ограничение нагрузки на Active Directory у Microsoft.
Алгоритмов congestion control algorithms несколько. Самый распространенный — AIMD (Additive Increase Multiplicative Decrease). Он работает по следующему принципу: при каждом запросе проверяем, случилась ли перегрузка. Если нет, увеличиваем congestion window на a. Видим перегрузку — уменьшаем.
Формула ограничения конкурентных запросов:
![](https://habrastorage.org/getpro/habr/upload_files/2a6/71b/6aa/2a671b6aa396f62f1cc932d4dfd8316a.png)
a — на сколько увеличиваем этот размер в случае, если «не успели» с запросом.
b — множитель, уменьшающий общую нагрузку в случае слишком долгих запросов 0<b<1.
Несмотря на элегантность решения, оно редко используется в разработке сервисов, а готовых библиотек, реализующих алгоритм, очень мало. У нас сервис на .NET и библиотек для него я не нашел, поэтому собрал решение сам.
Для этого понадобилось:
Написать логику, по которой меняется лимит подключений. Решение подсмотрел в библиотеке Netflix. Она написана понятно, с комментариями и описаниями, а кода относительно немного, потому реализовать то же самое не составит труда.
Реализовать семафор с динамическим изменением емкости. С этим помог вклад некоего доброго человека в Polly.Contrib, который вкрутил динамический семафор прямо в BulkheadPolicy известной библиотеки Polly. Его код ещё можно причесывать, но он делает что нужно, и вполне исправно.
Всё это получилось завести в проде и решить проблему деградации. В статье опишу запуск на тестовом полигоне и результаты, которые удалось воспроизвести.
![Результат, к которому пришли: при асинхронном запросе RPS растет, но при этом время ответа базы не превышает установленного предела Результат, к которому пришли: при асинхронном запросе RPS растет, но при этом время ответа базы не превышает установленного предела](https://habrastorage.org/getpro/habr/upload_files/082/4eb/f50/0824ebf50b4cbe2abda9ba73e3fda3ba.png)
Кейс: запуск на тестовом полигоне
Дано — хранилище, которое будет искусственно увеличивать время отклика от количества RPS. Допустим, будет базовый доступный RPS, и если текущий увеличится вдвое, время отклика тоже.
Ситуация — в сервис летит 75 RPS. Сервис лезет в базу, после отдает данные клиенту. Задержка от базы при такой нагрузке — в районе 260 мс.
![Динамика RPS Динамика RPS](https://habrastorage.org/getpro/habr/upload_files/427/360/fb3/427360fb32fbaf782600a0e4b85183a9.png)
![Время ответа Время ответа](https://habrastorage.org/getpro/habr/upload_files/c6f/aa3/231/c6faa3231eed2c47af39e8a90505b33e.png)
Представим, что требование по времени отклика — 200 мс. То есть в текущей конфигурации мы выбиваемся.
Добавим механизм ограничения. Для этого используем следующие настройки:
MinConcurrency и MaxConcurrency — ограничение конкурентных запросов. Ставим ограничение снизу, потому что какое-то количество одновременных запросов сервис может прожевать (хотя бы 1). Ограничение сверху — сколько запросов мы согласны впускать в сервис.
_maxLatency — максимальная задержка, допустимая при ответах. В нашем случае это 200 мс, о которых договорились выше.
BackoffRatio — коэффициент, на который умножаем параллелизм при «пробитии» latency. В формуле ограничения конкурентных запросов это параметр b. Он определяет, во сколько раз сократится допустимое количество одновременных запросов. Значение все ставят по-разному: в TCP это 0,5 (т.е. уменьшение вдвое), в библиотеке Netflix — по умолчанию 0,9.
Формула ограничения конкурентных запросов
![](https://habrastorage.org/getpro/habr/upload_files/069/119/c9c/069119c9c95dd17360c7c7b8925a97ea.png)
a — на сколько увеличиваем этот размер в случае, если «не успели» с запросом.
b — множитель, уменьшающий общую нагрузку в случае слишком долгих запросов 0<b<1.
В коде это выглядит следующим образом:
public int Calculate(TimeSpan roundTimeTrip, int currentLimit, int inFlightRequests)
{
var newLimit = currentLimit;
if (roundTimeTrip > _maxLatency)
newLimit = (int)(1d * _settings.BackoffRatio * newLimit);
else
newLimit++;
var result = Math.Min(_settings.MaxConcurrency, Math.Max(newLimit, _settings.MinConcurrency));
return result;
}
В результате нагрузка на сервис по RPS та же, что и раньше, но до хранилища доходит от силы половина. Остальное возвращается клиенту в виде 429 ответа.
![Нагрузка на сервис и базу. Зеленым показана общая нагрузка на сервис, желтым — сколько запросов доходит до хранилища Нагрузка на сервис и базу. Зеленым показана общая нагрузка на сервис, желтым — сколько запросов доходит до хранилища](https://habrastorage.org/getpro/habr/upload_files/97f/0d0/631/97f0d063177bd8b9e7258258342c0866.png)
![Разделение ответов. Зеленым — ответы на обработанные запросы, желтым — TooManyRequests (429) Разделение ответов. Зеленым — ответы на обработанные запросы, желтым — TooManyRequests (429)](https://habrastorage.org/getpro/habr/upload_files/793/c61/e56/793c61e56653d84e180328ebaec1e74f.png)
![Задержка ответа от хранилища. Синим — средняя задержка, желтым и зеленым — 95 и 99 процентили соответственно Задержка ответа от хранилища. Синим — средняя задержка, желтым и зеленым — 95 и 99 процентили соответственно](https://habrastorage.org/getpro/habr/upload_files/e7f/503/69a/e7f50369af7f2842cc05f5dbba8cb717.png)
Улучшим результат — добавим процентиль для плавности изменений
Получившийся алгоритм работает, но в нем есть пара нюансов, которые хотелось бы поправить:
Лимит — цифра «с потолка». Лимит в 200 мс, который мы поставили, — ни к чему не привязанная цифра. В нашем случае есть более весомое ограничение — SLA или обещания клиентам. По SLA, 95% запросов должны обрабатываться не дольше, чем n мс — от этого и нужно отталкиваться.
Частая смена лимита. При каждом запросе мы проходим по алгоритму, чтобы понять, стоит ли менять лимит. При нагрузке в сотню RPS это становится неприятно.
Станет лучше, если поправим логику и при расчете нового лимита будем отталкиваться от процентиля с последних n запросов. Например, мы хотим, чтобы 95% запросов выполнялись быстрее 200 мс.
Добавив в расчет процентиль, мы получили мягкость изменений: это спасает от ситуаций, где, например, моргнувшая сеть привела к 10 подряд «просроченным» запросам, что опустит лимит до минимума. С учетом процентиля график ответа нашего сервиса будет выглядеть уже так:
![График с ограничением 200 мс на 95 процентиль График с ограничением 200 мс на 95 процентиль](https://habrastorage.org/getpro/habr/upload_files/87a/1c5/f68/87a1c5f68c75142bc85187671a4191e9.png)
Проверка нагрузки, чтобы избыточно не повысить лимит
Если долго нет нагрузки, база отвечает быстро, и изменяемый лимит улетает в потолок. Мы рискуем в какой-то момент впустить слишком много запросов, и база ляжет. Чтобы такого не было, при увеличении лимита стоит проверять, утилизируется ли количество запросов, разрешенное сейчас:
private static bool CorrectlyUtilized(int currentLimit, int inFlightRequests) =>
inFlightRequests * 2 + 1 >= currentLimit;
Результат
Конечный алгоритм расчета нового лимита будет выглядеть примерно так:
public int Calculate(TimeSpan roundTimeTrip, int currentLimit, int inFlightRequests)
{
_lastRequests.Enqueue(roundTimeTrip);
var requests = GetRequestTimes();
if (requests.Length == 0)
return currentLimit;
var percentileLatency = CalculatePercentileLatency(requests, _settings.TargetPercentile);
var newLimit = currentLimit;
if (percentileLatency > _maxLatency)
newLimit = (int)(_settings.BackoffRatio * newLimit);
else if (CorrectlyUtilized(currentLimit, inFlightRequests))
newLimit++;
var result = Math.Min(_settings.MaxConcurrency, Math.Max(newLimit, _settings.MinConcurrency));
return result;
}
➡️ Репозиторий, чтобы посмотреть, как все это собрано, и запустить лимитер в песочнице.
Какие проблемы всплыли при запуске
Динамическое ограничение конкурентных запросов — не универсальное решение. Например, стоит учитывать следующие негативные эффекты, которые могут возникнуть.
Нюансы при автоскейлинге
Подход с динамическим изменением лимита работает отлично при скейлинге, когда лимит запросов от конкретного инстанса зависит только от того, как быстро отвечает база. Вот, например, добавление +2 инстансов в целом не влияет на задержку в ответах и RPS на хранилище.
![Скелинг сервиса до трех инстансов Скелинг сервиса до трех инстансов](https://habrastorage.org/getpro/habr/upload_files/111/a0b/819/111a0b819498bc3de41328efa9dfaa2b.png)
![Изменение задержки при скейлинге сервисов (в 11:10 заскейлил до трех) Изменение задержки при скейлинге сервисов (в 11:10 заскейлил до трех)](https://habrastorage.org/getpro/habr/upload_files/c9e/d3e/a41/c9ed3ea41e92d07fdbf9d60315f28251.png)
Однако при этом надо быть аккуратным с настройкой MinConcurrency — она должна быть достаточно низкой для максимального количества инстансов. То есть если в вашем сервисе minConcurrency=40, а инстансов 20, то общий минимальный concurrency на систему уже 800. База может этого не ожидать.
Время ответа периодически превышает лимит
Недостаток процентиля в том, что лимит уменьшается уже постфактум — когда граница уже «пробита». Потому лимит стоит всегда ставить чуть ниже критичного.
![Реальное значение процентиля превышает на пару мс то, что мы установили как цель (200 мс) Реальное значение процентиля превышает на пару мс то, что мы установили как цель (200 мс)](https://habrastorage.org/getpro/habr/upload_files/647/5c3/bb1/6475c3bb1f1b67f0c31464da70a46d21.png)
Что помогло при внедрении
Запись конференции StrangeLoop, где рассказывают про метод динамического ограничения конкурентных запросов.
Библиотека Netflix. Из нее я частично позаимствовал реализацию. А еще в ней реализован не только AIMD, но и ряд других механизмов для ограничения concurrency, которых в этой статье нет.
Статья о congestion control и почему он работает.
Комментарии (5)
AdAbsurdum
29.03.2024 09:08+3Не совсем понятно что плохого в репликации, это ведь ещё и дефолтная вещь для отказоустойчивости. Данное решение кажется очень сложным.
networkswitch Автор
29.03.2024 09:08Абсолютно ничего плохого. Совершенно разумное решение.
В статье описал инструмент, позволивший не тратить деньги на реплику еще довольно долгое время. В целом его же можно прикрутить в разных задачах, где нужно кого-то троттлить - и в части мест он будет работать лучше, чем остальные.
Batalmv
Я не очень хочу критиковать решение, так как не в курсе всех нюансов и требований, но вот это место выглядит стремно для большинства задача
Простой пример. Где-то там выше над нами веб-клиент и серверное приложение. Допустим для получения "удовлетворения" нам надо отработать 10 запросов. К примеру развесистая start page. Допустим мы попали в ситуацию, когда конкуретных запросов стало больше чем надо, и некоторые пошли "в сад". Пусть это будет половина. Тогда понятно, что почти никто не будет "удовлетворен", так как кто-то получил 8 из 10, кто-то - 7, кто-то вообще 2. Плохо
Если нам это не важно - я бы просто ставил очередь. Все пришли, дале FIFO, но строго ограниченным числом потоков. Можно прикрутить autoscaling на основе метрик базы, если хочется применить формулы. Возможно будет медленнее, зато прозрачно и легко мониторить.
Если клиенты таки хотят получать ответы на много вопросов, вопрос можно решить агрегацией сообщений, чтобы в работу брался полный пакет "счастья". Тогда нет проблем с частичным "удовлетворением", да и сборка/разборка сообщений в пакет на "месседжинге" делается относительно несложно
Это все не критика, а просто рассуждения на схожую тему
networkswitch Автор
Тут ведь очень зависит от множества нюансов - куда эти 10 запросов идут (в одну ли базу), сколько из них можно просто из кеша возвращать и пр. Если мы все 10 перенаправляем в одну базу, без кешей - да, описанным тут такую проблему лечить не выйдет. В любом случае, троттлинг обычно - это когда стало плохо, и ты не хочешь, чтобы оно превратилось в "ужасно". Если мы уже пришли к тому, что база начинает сильно тормозить - пускай что-то отбрасывает, если ей это поможет не помереть, пока разбираемся с проблемой.
В принципе, это конкарренси лимит и есть, только не на уровне сервиса, а чуть выше. Автоскейлинг сервиса на основе метрик базы, в которую он ходит (если я правильно понял) - по мне звучит страшновато. Там и скорость скейлинга (можем пропустить слишком много и очень долго), и зависимость деплоя одной сущности от другой, и еще несколько нюансов не нравятся. По мне тут и с прозрачностью, и с мониторингом грустнее выходит.
Batalmv
Я исходил из вашего примера, насколкьо я его внимательно изучил :) Очередь иммет смысл перед уже "исполнителем", так как она по сути меняет "загрузку" по сессиям на время ожидания естественным образом. тут достаточно сходить в магаз :)
Отбрасывание кстати тоже может реализовываться в очереди, так как большинство реализаций имеют банальную фичу, как время жизни сообщения в очереди. Т.е. если сообщение уже живет больше 5 секунд - можно считать, что оно уже не нужно и его можно удалять
Это да, наша основная задача - обеспечить загрузку баз полезными запросами. Т.е. любой запрос должен быть не просто обработан, но и вовремя, чтобы пользователь его дождался.
Для меня всегда было сложнее выполнить вторую часть. Просто отрезать несложно, но вот сделать это консистентно и вовремя - это сложнее.
Поэтому хочется в сложных кейсах иметь запросы в виде некоего знания, которым можно управлять, чтобы обеспечить консистентность выполнения. Т.е. если я взял в работу запрос из 10 подзапросов, тогда надо все 10 пропихнуть, иначе если я пропихну только 8 - все будет зря и самое обидное - ресурсы то потрачены :(
Обычно можно в параметрах очереди указать, в сколько потоков из нее можно читать. Поэтому не надо ничего поднимать, достаточно менять параметр. К примеру, запустили сразу 100 обработчиков (цена вопроса - только операцтивная память), а на очереди - 20 потоков. Итого, в один момент времени 20 работают (если конечно есть работа), а 80 ждут.
Если на очередях все - то мониторя длину очереди и время жизни сообщений там вы сразу видите всю картину. Это просто условно очевидно, тот же пример супермаркета. Как только очереди достигли условно какой-то точки - надо открывать новую касу.