Как Масштабировать Длительные Запросы API
В каждой системе рано или поздно возникает конечная точка, выполнение которой занимает минуты (или даже больше). В итоге пользователи несколько минут ждут ответа, а API удерживает запрос открытым всё это время, расходуя поток, соединение и прочие ресурсы. Небольшой всплеск трафика на этой одной конечной точке потенциально может привести к сбою всего API. Рассмотрим последовательность действий для решения этой проблемы.
0. Наивная версия
Оставим всё, как есть. В этом подходе нет ничего плохого — просто за корректность приходится платить доступностью. Пользовательский опыт плох, а радиус поражения велик: каждый принятый длительный запрос — это заблокированные ресурсы, которые могли бы быть потрачены на обработку других запросов.
1. Принимаем работу, но не выполняем её
Добавим таблицу заданий для работы, которую нужно выполнить. Теперь API:
- Проверяет запрос.
- Вставляет строку в таблицу заданий со статусом «Ожидание».
- Возвращает
202 Accepted с ID задания.Фоновый обработчик внутри того же API, читает строки со статусом «Ожидание» и обрабатывает их. Клиент либо опрашивает конечную точку
GET /jobs/{id}, либо — что лучше — сервер отправляет обновления через SignalR, Server-Sent Events или email, когда задание выполнено.Это уже даёт много преимуществ. Конечная точка быстро возвращает результат, а всплеск входящих запросов просто превращается во всплеск строк в таблице. Запись в таблицу обходится дёшево.
2. Отделение обработчика от API
Фоновый обработчик из шага 1 по-прежнему конкурирует с API за процессор, память и пул соединений. Если обработка становится ресурсоёмкой или медленной, API начинает это ощущать. Решение в том, чтобы вынести фоновый обработчик в отдельно развёртываемый компонент и разместить между ними очередь.
API публикует сообщение в очередь. Пул фоновых обработчиков получает данные из очереди и выполняет фактическую работу.
- Очередь поглощает пиковые нагрузки. API может продолжать принимать задачи с постоянной скоростью, в то время как рабочие процессы выполняют задания в своём темпе.
- Вы масштабируете рабочие процессы отдельно от API. Рост числа фоновых задач не требует увеличения количества экземпляров API.
- Сбой рабочего процесса — просто сообщение, которое возвращается в очередь, а не ошибка 500 для пользователя.
Вы также получаете возможность повторных попыток, приостановку/возобновление, структурированную обработку ошибок и очередь недоставленных сообщений — фактически бесплатно, поскольку инфраструктура очередей уже предоставляет их.
Чего это стоит
Это дополнительные компоненты, которые нужно развёртывать и отслеживать. Теперь клиент должен спрашивать, готов ли ответ, или вы должны его оповещать. И каждая задача должна быть идемпотентной, потому что доставка «хотя-бы-раз» означает, что рабочие процессы будут видеть дубликаты.
Если у вас только одна медленная конечная точка и умеренный трафик, это избыточно. Простое «запустить и забыть» с опросом статуса внутри того же процесса вполне подойдет.
Облачный сервис
Не обязательно делать всё самому. Вот несколько альтернатив:
- AWS SQS + Lambda или Azure Service Bus + Azure Functions, когда нужно масштабировать пул рабочих процессов до нуля и не охота управлять хостами.
- Azure Durable Functions или AWS Step Functions - для многоэтапных рабочих процессов с таймерами, повторными попытками и подтверждением человека. Оркестрация - их сильная сторона.
- Temporal — когда рабочий процесс длится долго (часы, дни), и необходимо первоклассное стабильное выполнение, версионирование и прозрачность между запусками.
Компромисс, как обычно, заключается в меньшем объёме операционной работы, но большей зависимости от поставщика и ценообразовании, которое необходимо тщательно оценить при росте нагрузки.
Источник: https://www.milanjovanovic.tech/blog/how-to-scale-long-running-api-requests