Значительную часть карьеры я работал с системами, обрабатывающими много пользовательских запросов. Одни выдерживали длительный поток, многократно превышавший их возможности. Другие разваливались под нагрузкой, с которой должны были справляться. Разницу редко можно было объяснить числом подов или языком программирования.
Начну с двух систем.
Система A: полный коллапс
Система A была обычным сервисом в Kubernetes, обрабатывающим запросы клиентов. Она принимала запрос, выполняла часть работы, обращалась к другим сервисам и возвращала результат. Когда нагрузка росла, автоскейлинг добавлял поды. При обычных пиках этого хватало.
Затем входящий поток достиг примерно пятикратной максимальной пропускной способности сервиса. Kubernetes довёл число подов до настроенного предела. CPU работал на пределе, память была исчерпана, полезная пропускная способность упала почти до нуля, а новые поды вскоре после начала обработки трафика начинали перезапускаться. Добавление подов не помогло сразу: каждый получал ту же избыточную нагрузку раньше, чем успевал выполнить сколько-нибудь полезной работы.
Команда восстановила сервис, остановив трафик, существенно увеличив мощности и затем постепенно вернув трафик. Когда сервис снова начал завершать запросы, накопившаяся очередь начала сокращаться. Со временем нагрузка вернулась к обычному уровню.
В каждом таком инциденте нужно разбирать детали. Что запустило цепочку: всплеск трафика, короткий сбой или замедление зависимого сервиса? Где скапливались ожидающие запросы? Какая проверка состояния приводила к перезапуску? Но общий механизм знаком: полезной работы выполняется меньше, запросов в ожидании становится больше, а растущая очередь ещё сильнее затрудняет восстановление.
Система B: загружена, но работает
Система B тоже часами получала больше запросов, чем могла обработать. Мне удавалось поддерживать загрузку её CPU около 99%, сохраняя низкую задержку и практически максимальную для неё полезную пропускную способность. Верхние перцентили немного выросли, но оставались в пределах цели сервиса. Память не росла, новые поды могли подключаться и начинать работу. Лишние запросы отклонялись быстро. В этой системе для отказа использовался код 429.1
Обе системы работали под высокой нагрузкой, но результаты различались. У первой полезная пропускная способность упала почти до нуля, у второй оставалась близкой к максимуму.
Система B ограничивала объём работы, принимаемой в обработку: различала предложенную клиентами и допущенную работу. Система A принимала слишком много работы, в том числе запросы, у которых не было шанса завершиться вовремя. Мне особенно важно, что за ограничение нагрузки отвечает сам сервис. Я не должен заставлять каждого клиента гадать, сколько запросов мой сервис выдержит.
Если хочется начать с главного, прочитайте разделы «Очередь есть всегда», «Сервис должен оставаться полезным при избыточной нагрузке» и «Собираем всё вместе», а контрольный список возьмите на следующее обсуждение архитектуры. Промежуточные разделы показывают, откуда взялись ограничения и что может пойти не так на каждой границе.
Очередь есть всегда
Представим небольшой асинхронный сервис. Он получает запрос и запускает корутину. Объекта Queue в коде нет, поэтому может казаться, что сервис вообще не ставит запросы в очередь.
При слабой нагрузке такое впечатление иногда оправданно. С ростом нагрузки всё больше корутин одновременно готовы к выполнению. Они конкурируют за ядра CPU. Другие ждут сокета, места в пуле соединений, блокировки, слота GPU или ответа зависимого сервиса. Работа где-то ждёт, даже если никто не назвал это место очередью.
То же происходит с диском. Запрос, читающий много данных, занимает диск, пока другой запрос ждёт. Если трафик приходит всплесками, следующий запрос может появиться раньше, чем предыдущий освободит ресурс. Асинхронный API не добавил диску пропускной способности.
Увидеть ожидающую работу помогает закон Литтла:
Здесь — среднее число запросов в системе, — интенсивность потока через неё, а — среднее время пребывания запроса в системе. В устойчивой системе долгосрочные интенсивности входящего и выходящего потоков равны.
Допустим, устойчивый сервис завершает 100 запросов в секунду, а каждый запрос проводит в нём в среднем 200 миллисекунд. Тогда в системе в среднем находится 20 запросов. Не все они обязательно исполняются на CPU: часть может ждать API, блокировку или соединение. Закон Литтла описывает долгосрочное среднее устойчивого потока. Он не говорит, что сервис примет любую интенсивность поступления или что все 20 запросов выполняются параллельно.
Та же связь встречается в сети. При скорости 100 МБ/с и времени прохождения туда и обратно (RTT) 20 миллисекунд для заполнения канала в пути должно находиться около 2 МБ данных. Этот объём данных находится в пути; он не определяет размер буфера для будущего всплеска. Другой случай: если перед пакетом уже стоят в очереди 8 МБ на узком месте с пропускной способностью 100 МБ/с и дисциплиной FIFO, пакет подождёт перед передачей примерно 80 миллисекунд. Объём данных в пути и объём данных в очереди нужно учитывать отдельно, как запросы в работе и запросы, ожидающие начала обработки.
Невидимая очередь не перестаёт быть очередью.
Что именно достигло предела?
Мы часто говорим, что сервис выдерживает «100 запросов в секунду». Это полезное сокращение, но оно скрывает причину ограничения. Одному запросу нужно 2 миллисекунды CPU, другому — 200. Один занимает немного памяти, другой держит соединение с базой данных, пока ждёт медленного клиента.
Полезно разделить несколько понятий:
- Пропускная способность — количество завершённой полезной работы за единицу времени.
- Число одновременных запросов — количество незавершённых запросов, включая ожидающие.
- Параллелизм — возможность доступных ресурсов выполнять работу одновременно.
- Мощность системы — устойчивый темп, с которым эти ресурсы могут завершать полезную работу в пределах целевой задержки.
- Предложенная нагрузка — вся работа, которую пытаются отправить клиенты. Допущенная нагрузка — её часть, которую мы приняли в обработку.
Для этапа, производительность которого ограничена CPU, можно начать с процессорного времени на запрос, . Если этап допускает запросов в секунду, ему требуется примерно CPU-секунд за секунду. При одинаковых доступных ядрах оценка загрузки такова:
Здесь — загрузка CPU: доля суммарной мощности этих ядер, которую требует допущенная работа.
Как оценить процессорное время на запрос? Допустим, за 60 секунд при стабильной и характерной для сервиса нагрузке четыре ядра израсходовали 120 CPU-секунд именно на 6000 успешно завершённых запросов. Тогда:
Перед расчётом вычтите постороннюю фоновую работу. Если отклонённые или неудачные попытки тратят заметное время CPU, измеряйте и учитывайте его отдельно; иначе полученное отношение не покажет затрат CPU на успешный запрос. Измеряйте на составе запросов, близком к рабочему: за общим средним могут скрываться отдельные классы дорогих запросов.
При 100 запросах в секунду и 20 миллисекундах CPU на запрос требуется две CPU-секунды каждую секунду. На четырёх ядрах это около 50% загрузки. Потолок, рассчитанный только по CPU, без накладных расходов и цели по задержке, составил бы запросов в секунду. Если полное время запроса равно 200 миллисекундам, закон Литтла по-прежнему даёт около 20 запросов в системе. В среднем процессорная работа занимает мощность, эквивалентную двум ядрам. Остальное время уходит на что-то другое.
Это намеренное упрощение. Реальные запросы требуют разного количества ресурсов, ядра не всегда взаимозаменяемы, а у GPU или пула соединений с базой данных есть собственные ограничения. Важно измерить расход того ресурса, который ограничивает производительность данного этапа. Дополнительные корутины не создают новые ядра. Дополнительные поды не помогают, если все они ждут одну базу данных. Дополнительные GPU помогают, только если полезная работа доходит до них и завершается до своего дедлайна.
Почему всё разваливается вблизи насыщения
Процент загрузки сам по себе не говорит, как быстро сервис оправится после всплеска. Для этого важнее запас мощности: разница между темпом завершения работы и темпом её допуска :
Если сервис завершает 100 запросов в секунду, а получает 90, после всплеска он может сокращать очередь на 10 запросов каждую секунду. При 99 поступающих запросах этот запас равен всего одному. Разница между 90% и 99% загрузки кажется небольшой, но после образования очереди она определяет, сколько продлится ожидание.
Если в ожидании находится запросов, а скорость поступления остаётся ниже пропускной способности, время ликвидации накопившейся очереди можно грубо оценить так:
Здесь — накопившаяся очередь, а — разница между скоростью обработки и поступления, за счёт которой очередь сокращается.
Если накопилось 1000 запросов, сервис завершает 100 запросов в секунду, а поступает 90, очередь опустеет примерно за 100 секунд. При 99 поступающих запросах — примерно за 1000 секунд. Если скорость поступления превышает пропускную способность, очередь вообще не сокращается.
Ожидание может расти намного быстрее загрузки. В простейшей модели с одним обслуживающим устройством, случайными интервалами между поступлениями и случайным временем обслуживания среднее ожидание в очереди пропорционально , где — загрузка, то есть используемая доля мощности устройства. Этот множитель равен 9 при загрузке 90%, 19 при 95% и 99 при 99%. Эти числа описывают модель, а не ваш сервис: подставлять их в рабочий дашборд без проверки нельзя.
Поэтому правило «держать CPU ниже 70%» для одного сервиса может быть разумным, а для другого — расточительным. Цель зависит от размера всплесков, стоимости запросов, требования к задержке и скорости добавления мощности. Это не закон природы. Предположим, нагрузочные тесты нашего сервиса с четырьмя ядрами показали: при данном составе запросов он укладывается в целевую задержку вплоть до загрузки CPU 85%. Начальная оценка безопасной пропускной способности тогда равна полезных запросов в секунду, ниже потолка 200/с, рассчитанного только по CPU. Используем эту оценку для расчёта очереди и масштабирования, а затем проверим на практике. Она пока не гарантирует, что очередь будет разбираться с такой скоростью.
Как разброс увеличивает ожидание
Одной загрузки недостаточно, чтобы описать ожидание: важно, насколько неравномерно приходит работа и насколько различается её стоимость. Следующий расчёт объясняет, почему при одной и той же средней загрузке время ожидания в двух сервисах может сильно различаться. Его можно пропустить и перейти к примеру со всплеском, используя проверенную оценку 170/с.
Для более общей очереди с одним обслуживающим устройством приближение Кингмана для высокой загрузки учитывает разброс интервалов между поступлениями и времени обслуживания:
Здесь — среднее время ожидания в очереди, — среднее время обслуживания, а — загрузка единственного обслуживающего устройства. и описывают неравномерность интервалов между поступлениями и времени обслуживания: каждый коэффициент равен стандартному отклонению, делённому на среднее.
Чтобы увидеть влияние этого разброса, возьмём другой пример с одним обслуживающим устройством, а не наш сервис с четырьмя ядрами. При среднем времени обслуживания 10 миллисекунд и загрузке 90% значения дают около 90 миллисекунд среднего ожидания. Оставим поступления прежними, но поднимем до 2 — оценка станет 225 миллисекунд. Короткое выражение неявно предполагает, что множитель, учитывающий разброс, равен 1. Это не означает, что разброса нет. Для настоящего сервиса с несколькими исполнителями я бы измерял распределение задержек под нагрузкой, а не переносил на него результат для одного исполнителя.
Превратить всплеск в требование
Средняя интенсивность поступления скрывает форму всплеска. Наш сервис с четырьмя ядрами уложился в целевую задержку при 170 запросах в секунду, а обычно получает 130. Чтобы оценить размер очереди ожидания, нужно ещё одно допущение: в этом упрощённом примере исполнители продолжают брать из неё примерно 170 запросов в секунду во время всплеска. Сам по себе нагрузочный тест не доказывает, что под перегрузкой очередь будет освобождаться именно с такой скоростью. Сервис может начинать больше работы, которая закончится слишком поздно, или из-за конкуренции за ресурсы начинать меньше.
Теперь предположим, что поток поднимается до 200 запросов в секунду на десять секунд. Если состав запросов не меняется, а очередь вначале пуста, приём 30 лишних запросов каждую секунду оставит после всплеска 300 ожидающих. Одной пиковой интенсивности для такого вывода недостаточно: важна и длительность. В общем виде, если пиковая интенсивность равна , предполагаемый темп выхода из очереди — , а длительность всплеска — , то:
Здесь — очередь, которая возникла бы, если принять весь всплеск при указанном допущении о скорости разбора очереди. Когда трафик вернётся к обычным запросам в секунду, останется запас запросов в секунду. На разбор этой очереди из 300 запросов уйдёт примерно секунды. Запрос, вставший в конец очереди сразу после всплеска, может ждать начала обработки около секунды — очень далеко от бюджета ожидания в 100 миллисекунд.
Теперь можно спросить, какую часть всплеска мы можем позволить себе оставить в ожидании. Если исполнители берут из очереди около 170 запросов в секунду, бюджет ожидания 100 миллисекунд допускает примерно 17 мест. Требование восстановиться за одну секунду допускает очередь из 40 запросов: исполнители берут на 40 запросов в секунду больше, чем обычно поступает. Ограничение по задержке строже. Если — физический размер очереди, — бюджет ожидания, а — срок восстановления, начальную оценку можно записать так:
Вот требование, которое я бы сформулировал: при обычном потоке 130 запросов в секунду выдерживать десятисекундный всплеск до 200/с, удерживая p99 задержки принятых запросов в пределах цели и возвращая очередь к норме за одну секунду. Арифметика показывает, почему по такому контракту нельзя принять все запросы. Нужно добавить мощность, отложить работу где-то ещё или быстро отклонить лишние запросы.
Но расчёт очереди ещё не гарантирует соблюдения цели по p99. Очередь может сокращаться, потому что исполнители начинают обработку запросов, даже если те позже завершатся после дедлайна. Если — цель по полной задержке, нам ещё нужно:
Здесь — ожидание в очереди, — время обслуживания, а покрывает накладные расходы на ответ. Всплеск с более дорогими запросами может снизить безопасный темп завершения работы, поэтому надо тестировать настоящую форму всплеска и распределение стоимости запросов. Нужно также считать, какая доля уникальной предложенной работы завершилась вовремя: хорошая задержка после отклонения большинства запросов — ещё не вся картина.
Управление допуском: сделать ожидание явным
Если принять весь всплеск, через десять секунд внутри сервиса останутся около 300 ожидающих запросов. Представим, что HTTP-обработчик создаёт корутину для каждого входящего запроса. Одни запросы готовы занять CPU, другие застряли перед пулом соединений или ждут разрешения на запуск. Все они ещё не закончены, даже если ни один не виден в метрике с названием «длина очереди». Запросы всё равно ждут; мы лишь оставили среде выполнения выбор места ожидания.
Решение нужно принимать до дорогой работы и до бесконтрольного ожидания. Новый запрос может получить место среди уже запущенной работы, встать в ограниченную очередь ожидания или быстро получить отказ, если нет ни свободного активного слота, ни места в очереди:
запрос → проверка допуска
├─ есть активный слот → начать работу
├─ есть место в очереди → ограниченное ожидание → активный слот
└─ мест нет → отказ
Ограничение очереди сдерживает число запросов, ожидающих начала обработки. Ограничение активной работы сдерживает число запросов, которые уже начались, но ещё не закончились: одни занимают CPU, другие держат соединение или ждут I/O. Четыре ядра сами по себе не говорят, сколько таких запросов безопасно выполнять одновременно. Оценка пропускной способности 170 запросов в секунду — тоже. Я бы проверил разные ограничения под нагрузкой и выбрал то, которое удерживает задержку допущенных запросов в пределах цели, не оставляя полезную мощность простаивать.
Чтобы увидеть разницу, представим условное ограничение в шесть начатых запросов и два места ожидания. Это условный пример, а не рекомендация по настройке: каждая буква ниже обозначает запрос, а не поток исполнителя.
начатая работа (предел: 6)
на четырёх ядрах CPU: [A] [B] [C] [D]
ожидание I/O: E F
ожидают начала: [G] [H] (предел очереди: 2)
следующий запрос: I → отказ
Для очереди я мог бы начать с 16 мест вместо оценочных 17, а затем измерить распределение задержки во время всплесков. Запрос близко к дедлайну иногда нужно отклонить даже при свободном месте.
Ограниченного канала недостаточно, если запросы могут скапливаться, пытаясь войти в него. Например, в Tokio вызов send(...).await ждёт свободного места в канале. Если я создаю неограниченное число задач для новых запросов, перед каналом ёмкостью 16 мест всё равно может скопиться неограниченное число ожидающих задач. try_send немедленно вернёт ошибку при заполненном канале; ждать send уместно, только когда число ожидающих производителей ограничено или обратное давление может безопасно дойти до них.2 Считайте все места ожидания, а не только то, в типе которого написано «очередь».
Эти ограничения меняют и математическую модель. Объём работы внутри системы с конечной ёмкостью может оставаться ограниченным даже при входящем потоке выше 170/с, потому что часть запросов она отклоняет. Тогда закон Литтла применим к допущенной работе и времени до её выхода из системы. При этом нужно согласованно учитывать успешные завершения и остальные исходы. Подставлять в расчёт весь предложенный поток нельзя. Лишняя работа не исчезла: её отклонили, от неё отказались или она ждёт за пределами сервиса.
Очередь на 10 000 элементов не даёт большей пропускной способности. Она позволяет накопить до 10 000 необработанных элементов. Иногда это именно то, что нужно: в пакетной обработке накопление работы может быть допустимым способом сгладить всплеск. Иногда такая очередь превращает избыток спроса в медленный отказ, который замечают лишь после того, как клиенты уже перестали ждать.
Очереди поглощают неравномерность. Они не создают мощность.
Подробнее о передаче работы между пулами
Отдельные пулы для CPU- и I/O-работы позволяют управлять использованием этих ресурсов независимо, но так легко незаметно создать ещё одну очередь. Запрос A покидает CPU-исполнителя ради I/O, а тот берёт из входной очереди запрос B. Допустим, в очереди CPU два места, запрос D приходит, пока A ждёт, и B всё ещё использует CPU к моменту завершения I/O у A:
A на CPU A ждёт I/O A снова нужен CPU
исполнитель CPU [A] [B] [B]
ожидание I/O - [A] -
очередь CPU (2) [B] [C] [C] [D] [C] [D]
вне очереди - - [A: продолжить]
Покинув CPU, запрос A не завершился, поэтому он должен по-прежнему учитываться в ограничении начатой работы. После I/O ему снова нужно время CPU для обработки результата, но оба места в очереди заняты. Продолжение A теперь ждёт вне ограниченной входной очереди. Если оно присоединится к той же FIFO-очереди, когда место освободится, C и D уже окажутся впереди.
Я видел три способа устроить это. В строго последовательном конвейере у каждого этапа могут быть свой пул и ограниченная очередь — CPU, I/O, CPU для последующей обработки, возможно, ещё один I/O-этап. Размеры и обратное давление нужно настраивать согласованно.
Общий пул CPU может повышать приоритет запроса после каждого перехода. Когда завершивший I/O запрос и новый запрос одновременно ждут CPU, сначала выполняется продолжение. Это позволяет завершать уже начатую работу, готовую к продолжению, прежде чем пул начнёт новую. Но пул всё ещё может взять новый запрос, пока старые ждут I/O. Отказ принимать что-либо новое до завершения всей начатой работы был бы отдельным правилом.
Для асинхронного сервиса часто проще оставить один пул CPU с неблокирующим I/O и отдельным ограничением на все начатые запросы, включая ожидающие зависимый сервис.
Последнее ограничение может быть выше числа ядер и не требует дополнительных CPU-потоков. Если запрос тратит 10 миллисекунд CPU и ждёт I/O 100 миллисекунд, в идеальном устойчивом режиме на одно ядро может приходиться примерно 11 запросов в работе. Это предполагает, что зависимый сервис выдерживает соответствующую пропускную способность. Если он замедлится, увеличение числа запросов в работе приведёт лишь к увеличению числа ожидающих. Я бы отдельно ограничивал обращения к этому сервису и использовал circuit breaker, когда таймауты показывают, что дальнейшие вызовы, скорее всего, лишь потратят ресурсы зря. Число ядер, время CPU, задержка I/O и полезная пропускная способность дают отправную точку; адаптивные ограничители параллелизма могут подстраивать предел по наблюдаемой задержке. Им всё равно нужны безопасные границы и нагрузочные тесты.
Такая передача работы добавляет ещё одно место ожидания, которое надо ограничить, но не отменяет границу допуска. Даже ограничив все места ожидания, мы должны спросить, сможет ли запрос внутри них дать полезный ответ. От свободного слота мало толка, если помещённая туда работа пропустит дедлайн или провалится ниже по цепочке.
Когда работа вряд ли завершится успешно
Представим клиента с дедлайном 15 секунд, который вызывает сервер с таймаутом 30 секунд. Клиент сдаётся через 15 секунд, а сервер может ещё 15 секунд выполнять работу для того, кто уже не слушает. При слабой нагрузке это просто расточительно. При высокой эти 15 секунд занимают ресурс, нужный другим запросам.
Дедлайн должен передаваться вместе с запросом. Если у клиента было 15 секунд, к моменту вызова сервиса B у сервиса A может остаться только 12. Когда B вызывает C, возможно, останется семь. Каждый уровень должен использовать оставшееся время, оставить запас на возврат ответа и отменять работу, у которой больше нет ожидающего клиента. Отмена должна доходить до запросов в очереди и дорогих вызовов зависимых сервисов, а не останавливаться на HTTP-обработчике.
При допуске можно начать с простого вопроса:
Здесь — ожидание в очереди, — время обслуживания, означает среднее, оставляет время на возврат ответа, а — время до дедлайна клиента.
Если даже среднее не укладывается, лучше отказать сейчас. Но прохождение этой проверки не обещает соблюдения p99. Если допустимая доля пропущенных дедлайнов равна , настоящий вопрос ближе к такому:
То есть вероятность завершиться до дедлайна должна достигать выбранной цели .
Реализовать это можно через измеренные квантили или откалиброванную эвристику, а не обязательно через явное распределение. В любом случае измеряйте, как часто допущенные запросы пропускают дедлайн, и подстраивайте политику. Запрос может пропустить дедлайн и во время ожидания. Очередь должна уметь выбросить его до занятия дефицитного ресурса.
FIFO — простой вариант по умолчанию, но запрос с почти исчерпанным временем может оказаться позади долгой работы. Обслуживание по ближайшему дедлайну (earliest deadline first, EDF) иногда помогает, когда дедлайны различаются. Оно также может надолго отложить запросы с дальним дедлайном и не спасёт запрос, которому уже не хватает времени. Планирование и допуск решают разные части задачи.
Circuit breaker должен защищать ресурсы вызывающего сервиса
Обычно circuit breaker представляют как способ перестать бомбардировать запросами неисправный зависимый сервис. Для управления перегрузкой это лишь половина его работы. Если зависимый сервис необходим, состояние его breaker должно участвовать в проверке допуска ещё до начала дорогой работы. Иначе он может защитить зависимый сервис, но не наш.
Допустим, перед вызовом такого сервиса запросу нужны десять секунд подготовки на CPU. Если breaker уже открыт, а мы проверяем его только перед самим вызовом, десять секунд уйдут на запрос, который не сможет завершиться. Последующий отказ может заставить клиента повторить запрос и заново потратить процессорное время. Ожидание зависимого сервиса или повтор внутри нашего сервиса удерживает активный слот, пока очередь растёт, а время до дедлайна сокращается.
Перед началом обработки проверка допуска должна учитывать состояние того же breaker или сигнал перегрузки. Тогда новый запрос можно отклонить до этапа CPU либо, если контракт позволяет, обработать с меньшими затратами, вернув упрощённый результат. Проверка непосредственно перед вызовом тоже нужна: зависимый сервис может сломаться уже после допуска, а выполняющимся запросам по-прежнему нужны дедлайны и отмена. Breaker также должен пропускать небольшое число пробных вызовов, чтобы обнаружить восстановление; breaker, который никогда не проверяет восстановление, сам мешает сервису вернуться к работе.3
Дедлайн и breaker дают разную информацию на одной и той же границе допуска. Первый отвечает, осталось ли достаточно времени; второй по результатам недавних вызовов оценивает, насколько вероятен успех необходимой операции. Ни один не даёт гарантии, но вместе они помогают тратить дефицитную мощность на запросы, которые ещё могут дать полезный ответ.
Сервис должен оставаться полезным при избыточной нагрузке
От сервиса я хочу следующего:
Компонент должен оставаться устойчивым, даже когда клиенты предлагают больше работы, чем он может выполнить.
Это не значит, что он сможет завершить любой предложенный объём. У сети и входного тракта всегда есть физический предел числа пакетов, которые они способны принять или отклонить. В диапазоне нагрузок, для которого мы проектируем и тестируем сервис, он должен ограничивать принятую работу своими возможностями и дёшево отклонять остальное.
Квоты — не ограничения мощности
Отказывать приходится не только ради защиты сервиса. Иногда нужно решить, сколько ресурсов пользователь, клиент-арендатор или API-ключ вправе потреблять за определённое время. Это квота. Ограничитель частоты запросов с корзиной токенов может пропустить короткий всплеск, соблюдая долгосрочную норму и не позволяя одному клиенту забрать чужую долю.
Квоты и ограничения частоты могут снизить перегрузку, но не заменяют проверку допуска на основе ресурсов, доступных прямо сейчас. Квота в 100 запросов в минуту мало говорит о стоимости этих запросов или о том, свободен ли слот GPU в данный момент.
Представим многоклиентский сервис, мощности которого рассчитаны на сумму выделенных квот при обычной стоимости запроса. Один клиент остаётся в пределах своей квоты, но начинает отправлять гораздо более дорогие запросы. По числу запросов квота соблюдается, а потребность в CPU растёт. Или замедляется зависимый сервис: при прежнем темпе допуска запросы дольше остаются в работе, занимают больше памяти и, возможно, держат соединения. Когда завершения начинают отставать от поступлений, растут очереди. Все клиенты могут соблюдать квоты, пока сервис уже перегружен.
Сервису всё равно нужны ограничения активной работы и ожидания, а решение о допуске должно учитывать действительно дефицитный ресурс. Эти ограничения защищают его при изменении стоимости запросов или задержки зависимого сервиса. И наоборот, ограничение числа одновременных запросов может сохранить сервис устойчивым, хотя один клиент займёт все доступные слоты. Квоты распределяют доли; ограничения мощности определяют, сколько работы сервис сейчас может безопасно взять.
Разницу должен отражать и API. 429 Too Many Requests обозначает превышение клиентом ограничения частоты; 503 Service Unavailable может обозначать временную нехватку мощности и сопровождаться заголовком Retry-After.1 Система B использовала 429 для быстрого отказа при перегрузке. Каким бы ни был код ответа, клиент и дашборды должны ясно видеть причину: стоит ли подождать, попробовать другой сервер или прекратить попытки.
Когда сервис сам контролирует приём запросов и понятно сообщает об отказах, клиенту проще с ним работать. Иначе каждому клиенту приходится заводить семафор с приблизительно подобранным пределом, вручную выбирать задержки или задавать особые правила с учётом предполагаемой мощности зависимого сервиса. Такие догадки устаревают при каждом изменении сервиса или характера нагрузки.
Если зависимый сервис защищает себя и ясно об этом сообщает, большая часть таких ограничений на стороне клиента становится не нужна. Клиент по-прежнему отвечает за свой дедлайн, бюджет повторов, идемпотентность и решение, стоит ли вообще делать работу. Но ему больше не приходится управлять перегрузкой чужого сервиса. В системе со множеством клиентов это настоящее упрощение.
Когда предложенная нагрузка растёт, я хотел бы видеть такую картину:
предложенные запросы растут
полезная пропускная способность
растёт, затем держится около проверенного предела
задержка успешных ответов остаётся в известных границах
память и запросы в работе остаются ограниченными
отказы растут
Сравните с системой A, где полезная пропускная способность падала, пока расход ресурсов рос. Один график высокой загрузки CPU не отличит эти случаи. CPU системы B мог держаться около 99%, пока она продолжала выполнять работу, потому что объём допущенной работы оставался под контролем. Это не совет всем сервисам целиться в 99% CPU. Это повод оценивать состояние сервиса по полезной пропускной способности, задержке, памяти и способности восстановиться наряду с загрузкой.
Я бы вывел на один график число уникальных предложенных запросов в секунду, число допущенных, число полезных завершений до дедлайна и быстрых отказов. Считайте каждый логический запрос один раз, не раздувая спрос повторными попытками. Если при росте предложенной нагрузки полезная пропускная способность растёт, а затем остаётся стабильной, тогда как задержка успешных запросов и число запросов в работе остаются ограниченными, сервис защищает свою мощность. Если он отклоняет половину спроса, у пользователей всё равно есть проблема нехватки мощности, даже если сам сервис не рухнул. Оба факта должны быть видны одновременно.
Вместо «Сервис выдерживает около X запросов в секунду, а потом падает» я бы сказал: «При таком составе нагрузки один экземпляр может завершать около X полезных запросов в секунду в пределах цели по задержке; лишняя нагрузка отклоняется, а не разрушает эту пропускную способность». Ограниченная очередь может поглотить короткий всплеск до начала отказов. Ответ также должен сообщать клиенту, уместна ли новая попытка; если да, API нужна безопасная схема повторов.
Состав нагрузки — часть этой фразы. Иначе рост доли дорогих запросов выглядит загадочной потерей мощности, хотя сервер выполняет ровно столько работы, сколько мог всегда.
Быстрый отказ защищает сервис, но избыток спроса может остаться. Работа может вернуться повторной попыткой, остаться в брокере или ждать, пока автоскейлер поднимет новые поды. Если её по-прежнему нужно выполнить, она просто ждёт в другом месте. Посмотрим, что происходит на каждой из этих границ.
Стоимость повторов
«Повторы вызывают шторм повторов» — слишком упрощённое объяснение. Повтор — это ещё одна попытка выполнить тот же логический запрос. Важно, сколько она стоит и остаётся ли у работы шанс на успех. Вызывающая и принимающая стороны платят по-разному.
Для вызывающей стороны задержка неудачной попытки и ожидание перед следующей съедают дедлайн. Новая попытка добавляет трафик и может повторить операцию, исход которой ещё неизвестен. Таймаут не доказывает, что первая попытка не удалась; поэтому важен контракт идемпотентности операции.4
Вызывающей стороной может быть сервис, у которого тоже ждёт клиент. Представим поток 100 запросов в секунду по цепочке A → B → C. Если время ответа C вырастет со 100 миллисекунд до одной секунды, а темп завершений останется прежним, закон Литтла даст примерно на 90 запросов больше в работе. Если A и B держат контекст запроса до ответа C, оба теперь удерживают в памяти эти дополнительные запросы, хотя входящий поток не изменился.
Повторный вызов B → C может стоить C совсем дёшево, если тот немедленно откажет, но ожидание перед повтором удерживает в B контексты запросов от вышестоящих сервисов. Если темп завершений C тоже упадёт, растущая очередь добавится к проблеме. Стоимость повтора — не только работа принимающего сервиса над одной попыткой.
Отказаться от работы иногда дороже, чем повторить нужный шаг один раз. Допустим, B выглядит здоровым, когда A начинает работу, но вызов B проваливается после того, как A потратил десять секунд CPU на подготовку входных данных. Если A повторит вызов B, пока его клиенту ещё нужен ответ, подготовку можно использовать снова. Если A вернёт ошибку и клиент повторит всю операцию, эти десять секунд процессорного времени придётся потратить заново, уменьшив полезную пропускную способность. Это аргумент в пользу локального повтора, только пока дедлайн оставляет время, а у B есть реальный шанс восстановиться. Если B перегружен, дальнейшие вызовы могут лишь держать слот A и усиливать затор; уже потраченные десять секунд не оправдывают бесконечные попытки.
Чтобы не повторять подготовку, её результат можно сохранить. Ограниченный кэш по идентификатору логического запроса сохранит данные для следующей попытки. К общему кэшу вроде Redis могут обращаться разные поды; локальный кэш с закреплением клиента за подом избавляет от удалённого чтения, но перенаправление запроса или перезапуск пода может заставить пересчитать результат.
В асинхронном конвейере дорогие вычисления можно вынести в отдельный этап: опубликовать их результат или ссылку на него в Kafka либо SQS, чтобы при повторе следующий этап использовал сохранённый результат. Задача Flink может восстановить подготовленное состояние из контрольной точки, хотя обработка после последней завершённой точки может повториться. Ни один из этих переходов сам по себе не устраняет дубликаты. Если повторные побочные эффекты важны, выход этапа и следующий этап должны работать идемпотентно или транзакционно.5
Вопрос в том, с какого шага начинается следующая попытка и что придётся делать заново:
Сохранение уже сделанной работы не значит, что каждый уровень должен повторять вызовы. Если A повторяет B, а B повторяет C, одно действие пользователя может породить множество вызовов C. Выбирая, какой компонент отвечает за повторы, учитывайте и размножение попыток, и цену отказа. Если сервис знает, что новая попытка не поможет, ему лучше сказать об этом, а не приглашать к ещё большему объёму работы.
Для принимающего сервиса вопрос в том, сколько дефицитной мощности расходует неудачная попытка. Её стоимость можно приблизительно представить так:
Каждое — стоимость названного шага, измеренная в единицах одного и того же дефицитного ресурса. В частности, — работа, выполненная неудачной попыткой после допуска.
Если первые три шага дёшевы, а частичной работы почти нет, принимающая сторона может сразу отказать, когда достигнут предел принятой работы. Клиент может подождать и повторить запрос в пределах дедлайна: у другого пода, возможно, есть место или мощность появится чуть позже. Google описывает похожее поведение в материале об управлении перегрузкой: быстрый отказ позволяет повтору найти свободный сервер.
Здесь полезно различать «у меня нет места для этого запроса» и «у моего зависимого сервиса сейчас нет полезной мощности». Первое — локальное решение одного принимающего сервиса; второе — вывод, который клиент делает по результатам нескольких попыток. Один отказ не доказывает, что все поды заполнены, поэтому клиент с оставшимся временем и бюджетом повторов может найти место в другом. Если отказы становятся частыми на разных подах, дальнейшие поиски свободного почти без результата расходуют ресурсы, нужные сервису для приёма запросов. Клиенту стоит замедлиться или остановиться и сообщить об этом своему клиенту. Зависимый сервис не обязательно буквально «лежит»; возможно, он уже делает всю полезную работу, на которую способен.
Для этого не требуется контроллер, знающий состояние каждого пода. Каждый принимающий сервис защищает свои ограниченные ресурсы и быстро сообщает об отказе; каждый клиент корректирует поведение по результатам попыток в пределах дедлайна и бюджета попыток. Эти границы важны: рой клиентов, каждый из которых по отдельности ведёт себя разумно, всё равно может перегрузить входной тракт, если все одновременно продолжат пробовать.
Поздний отказ меняет временную картину. Допустим, запрос провёл 900 миллисекунд в скрытой очереди сервера, прежде чем получил ответ о перегрузке. Если клиент затем ждёт 200 миллисекунд перед повтором, 1,1 секунды прошли без продвижения, а сервер в первую часть этого времени мог удерживать память или слот. Ожидание перед повтором одинаково в обоих вариантах; дополнительные затраты создаёт скрытая очередь:
Работу, которая возвращается с повторными попытками, я рассматриваю как очередь за пределами сервиса: логические запросы ещё не завершены и продолжают ждать допуска. Каждая новая попытка спрашивает, освободился ли слот. Серверная метрика длины очереди может не видеть этих запросов, но работа всё равно ждёт. Считайте отдельно логические запросы, повторные попытки и расход ресурсов на эти попытки. Перенос ожидания за пределы сервера полезен, только если ожидание и повторы на новом месте тоже ограничены.
Если каждая неудачная попытка уже выполнила половину вычислений инференса на GPU, держала блокировку базы данных или вызвала три зависимых сервиса, то такое же поведение повторов может съесть мощность, необходимую для восстановления. Полезная рабочая метрика:
Эта доля показывает, не сжигают ли повторы ресурс, который мы пытаемся защитить. Но при большом числе попыток даже дешёвые отказы требуют значительных ресурсов. Каждая отклонённая попытка должна дойти до сервиса и пройти проверку допуска. Если много клиентов повторяют одновременно, попытки могут вытеснить полезную работу, несмотря на малую стоимость каждой.
После отказа клиент должен учитывать Retry-After, если тот указан, и распределять попытки во времени со случайным разбросом. Цикл повторов должен завершаться по дедлайну или бюджету попыток. Эти ограничения позволяют быстрому отказу перенести ожидание к клиентам, не превращая его в бесконечный поток новых попыток.
Брокер сообщений не устраняет нехватку мощности
Теперь допустим, что работа приходит через Kafka или SQS, а не по HTTP. Очередь здесь явная, а сообщения часто сохраняются надёжно. Её проще увидеть, но арифметика от этого не меняется.
Если не учитывать дубликаты и повторные доставки, число записей, ожидающих обработки, меняется при поступлении новых записей, взятии их исполнителями и отбрасывании:
Здесь — число ожидающих записей, — темп поступления новых записей, — темп, с которым исполнители начинают их обработку, а — темп отбрасывания до обработки. Истечение срока выполнения логической задачи само по себе не уменьшает : её запись уйдёт из этой очереди, только когда мы начнём обработку или выбросим её. Речь не об общем числе записей, хранимых в журнале Kafka.
Если приходит 1000 сообщений в секунду, исполнители начинают 800, а остальные не отбрасываются, очередь растёт на 200 каждую секунду. За час добавится примерно 720 000 сообщений. Этот размер очереди — накопленный дефицит мощности. Дополнительные партиции или более долгий срок хранения Kafka сами по себе его не устранят.
Число записей в брокере не обязательно соответствует числу пользовательских задач. Повтор одной нерешённой задачи создаёт ещё одну попытку, а не новую логическую задачу. Но если повтор публикует новое сообщение, появляется ещё одна физическая запись. Дашборд может показывать растущий «спрос», хотя часть роста — повторные попытки выполнить уже известную работу.
Ключ идемпотентности особенно полезен, когда мы проверяем его до повторения дорогой работы. Если задача уже завершилась, исполнитель может подтвердить и выбросить дубликат либо вернуть сохранённый результат, если он ещё нужен следующему этапу. Такой дешёвый путь позволяет активно разбирать очередь дубликатов, а не прогонять каждый через полное вычисление. Запись о завершении нужно хранить достаточно долго, чтобы распознавать повторы. Если копии приходят одновременно, обычной проверки кэша мало: исполнителям нужен атомарный захват задачи, чтобы не вычислять её дважды; внешние побочные эффекты всё равно требуют идемпотентной или транзакционной обработки.
Подробнее об учёте логических задач
Уравнение для очереди записей показывает, сколько сообщений ждёт в брокере, а не сколько отдельных задач остаётся нерешёнными. Если провести границу вокруг всех нерешённых логических задач, включая ожидающие у клиентов, баланс будет другим:
Здесь считает нерешённые логические задачи, — новые задачи в секунду, а каждый — темп ухода задач с указанным исходом. Эти категории исходов не должны пересекаться; если мы так определим «отказ от задачи», в него входит и окончательная ошибка. Это тождество для учёта, а не утверждение, будто истечение срока не зависит от прежних поступлений. Если каждая задача истекает через секунд после появления, задачи, истекающие в момент , пришли в момент , и истечь могут только те, которые с тех пор не завершились и всё ещё нужны. Если все задачи из этой группы всё ещё ожидают результата, ; иначе темп истечения меньше.
Повторы одной задачи меняют число попыток или записей, а не . Поэтому я бы выводил эти числа на графики отдельно.
Решить, остаётся ли очередь полезной
Для очереди ожидания, если дефицит сохраняется секунд, грубая оценка её размера до отбрасывания записей — , где — начальная очередь, а — скорость роста. Дедлайн задачи определяет, когда она перестаёт быть полезной. Брокер может удалить запись по TTL, но сам факт хранения не означает, что сообщение ещё полезно. Без подходящей политики удаления устаревшие сообщения могут оставаться доступными долгое время. Исполнители должны проверять дедлайн и выбрасывать истёкшую работу до дорогой обработки. Если исполнители способны начинать и завершать обработку записей с темпом , превышающим поступление , при неизменных темпах и без новых отбрасываний оценка времени восстановления снова равна .
Часто важнее не сама длина очереди, а возраст самого старого полезного элемента. Десять тысяч сообщений могут означать десять секунд работы или целый день. Приемлем ли любой из вариантов, зависит от дедлайна этой работы.
Здесь настройка очереди становится бизнес-решением. Предупреждение о вторжении может быть чрезвычайно ценным ближайшие секунды и почти бесполезным завтра. Обычное уведомление может подождать несколько минут. Обогащение исторических данных — несколько часов. Сначала нужно решить, какая работа теряет смысл после дедлайна, какую можно отложить, а какую следует отбросить вместо бесконечных повторных доставок. Лишь затем выбирать число исполнителей, срок хранения и политику повторов.
Кому достанется следующий слот?
Сначала рассмотрим задачи одного вида. FIFO берёт самую старую первой. Допустим, каждая задача полезна 30 секунд, а первая в очереди ждёт уже 40. Её стоит выбросить до обработки. Если перегрузка продолжается, выбор самой новой доступной задачи (LIFO) может дать части задач шанс завершиться, пока они ещё полезны.
При этом старые задачи ждут ещё дольше. LIFO не добавляет мощности, и старые задачи могут ждать бесконечно, если не удалять их после истечения срока. Такой порядок не годится, когда задачи должны обрабатываться последовательно или каждый принятый элемент непременно должен завершиться.
Если работа различается по виду, FIFO может поставить срочное предупреждение за пачкой малозначимых задач. Строгий приоритет защитит предупреждение, но может оставить всё остальное без обслуживания. Нужно решить, чему отдать следующий слот: ближайшему дедлайну, ценности на единицу дефицитной работы или гарантированной доле каждого класса. Эти цели могут противоречить друг другу.
Планирование выбирает из задач, уже стоящих в очереди. Оно может не дать срочному предупреждению застрять за малозначимой работой, но не освободит слот, уже занятый долгой задачей. Проверка допуска должна отдельно определять, какую работу вообще принимать в обработку.
Сохранять запас, отклоняя часть нагрузки вероятностно
Ранее обозначало запас, остающийся после допущенной работы. Я хочу сохранить часть этого запаса для всплесков важного трафика, а не заранее занимать каждый слот обычной работой. В упрощённом примере, где задачи стоят одинаково, сервис способен завершать 100 задач в секунду. Обычно он допускает 60 задач обогащения истории и 20 предупреждений в секунду, оставляя запас 20/с. Если поток задач обогащения вырастет до 100/с и мы примем всё, суммарные 120 задач в секунду превысят мощность на 20/с. Очередь начнёт расти ещё до нового всплеска предупреждений.
Вместо этого можно отклонять каждую задачу обогащения с вероятностью, которая увеличивается по мере роста предложенной нагрузки и давления на ресурсы. При 100 таких поступлениях в секунду вероятность отказа 40% оставит в среднем около 60 допущенных. Вместе с обычными 20 предупреждениями это сохранит примерно 20/с запаса. Его хватит на краткий рост предупреждений с 20 до 40/с без устойчивого дефицита мощности. Это вероятностное отсечение нагрузки: доля отказов растёт вместе с затором, вместо резкого переключения от приёма всех задач обогащения к отказу всем сразу.
Сервис запросов в реальном времени может принимать такое решение на входе и дёшево отказывать. Конвейер может отклонять менее ценную работу до публикации либо выбрасывать её перед дорогим этапом потребителя, если контракт допускает потерю этой работы. Если каждая задача обязательно должна выполниться, оставление её в брокере — отсрочка, а не отсечение; дефицит мощности остаётся.
Вероятность выражает предпочтение, а не границу безопасности. Случайный разброс и резкие скачки нагрузки всё ещё могут пропустить слишком много работы за короткое время, поэтому жёсткие ограничения активной работы и ожидания внутри сервиса остаются необходимыми. Если предупреждениям нужна гарантированная доля ресурсов, следует зарезервировать её или обеспечить правилами распределения. Я бы оценивал политику по полезным завершениям и пропущенным дедлайнам каждого класса во время всплеска, а не только по проценту отказов.
Когда автоскейлинг даёт сбой
Граница допуска должна работать и при изменении мощности. Вернёмся к системе A. Допустим, под перегрузился настолько, что не проходит проверку состояния. Если не проходит проверка готовности (readiness), Kubernetes перестаёт направлять ему обычный трафик; если не проходит проверка живости (liveness), Kubernetes перезапускает под. Оставшиеся поды получают больше трафика, замедляются и тоже могут не пройти проверки. Эффективная мощность падает именно тогда, когда она нужнее всего.6
под перестаёт продвигать работу
→ эффективных подов становится меньше
→ каждый оставшийся под получает больше трафика
→ растут ожидание и ошибки
→ эффективных подов становится ещё меньше
Поэтому занятый под всё же должен уметь отвечать на проверку состояния, не требующую больших затрат. Если он завершает допущенную работу, но не может принять новую, перезапуск лишь убирает полезную мощность. Kubernetes предупреждает, что плохо спроектированные проверки живости могут вызвать каскадные сбои.6
Масштабирование поможет лишь после того, как новые поды запустятся, станут готовыми и начнут выполнять полезную работу. Если каждый новый под принимает запросы, не ограничивая собственное ожидание и не проверяя шансы закончить вовремя, дополнительные экземпляры могут создать новые места для накопления работы, почти не увеличив число полезных завершений. Автоскейлер — управляющий контур с задержкой, поэтому рассчитывать на мгновенное появление дополнительных ресурсов нельзя.6
У сервиса с контролируемым допуском потеря пода выглядит иначе. Успешных ответов может стать меньше, пока не запустится новый под. Отказов станет больше. Уцелевшие поды продолжат завершать принятую работу, и у сервиса останется устойчивая основа для восстановления. Перегрузка сама по себе не означает неисправность. Сервис может не иметь места для следующего запроса и при этом оставаться работоспособным и обслуживать уже принятые.
Вывести целевую загрузку для масштабирования
Целевая загрузка для масштабирования — та, с которой мы хотим работать до всплеска, а не максимальная, которую под пережил в нагрузочном тесте. Для нашего сервиса с четырьмя ядрами проверенный предел составлял 85% CPU при соблюдении цели по задержке. Работа на 85% в обычное время не оставляет места для новых поступлений, пока запускаются дополнительные поды.
Предположим, до готовности следующего пода трафик может вырасти на 30% при примерно том же составе запросов. Если начать с 65% CPU, этот рост приведёт существующие поды примерно к 85%: 0.65 × 1.3 ≈ 0.85. Если начать с 85%, они выйдут за проверенный предел. В общем виде первая оценка такова:
Здесь — загрузка до всплеска, — наибольшая загрузка, при которой сервис в тестах ещё укладывался в целевую задержку, а — ожидаемый множитель трафика за время масштабирования: 1,3 при росте на 30%. И безопасный предел, и оценку всплеска нужно брать из измерений, а не из общего правила для CPU.
При 20 миллисекундах CPU на запрос 65% четырёх ядер хватает примерно на 130 полезных запросов в секунду на под. Десять подов могли бы обслуживать 1300/с до всплеска. Рост на 30% даст спрос 1690/с — чуть ниже их суммарного проверенного предела в 1700/с. Но здесь нет запаса на потерю пода: девять смогут завершать при этом пределе лишь около 1530/с. Если всплеск может совпасть с потерей пода, нужен больший резерв или более низкая целевая загрузка. Допуск должен защищать уцелевшие поды, пока запускаются новые.
Если я использую Kubernetes Horizontal Pod Autoscaler (HPA), то прежде чем копировать 65% в averageUtilization, проверю знаменатель: HPA измеряет CPU относительно запрошенных подом CPU-ресурсов, а не обязательно относительно четырёх доступных ядер нашего примера.6
Допуск меняет и сигнал для масштабирования. Допустим, отсечение удерживает каждый под около этой целевой загрузки, хотя запросы, которые ещё можно было бы выполнить с пользой, получают отказ. Автоскейлер, смотрящий только на CPU, не видит причины добавлять поды, хотя спрос остаётся неудовлетворённым. Я бы также отслеживал уникальные задачи, которые были отклонены, хотя ещё сохраняли ценность, либо возраст полезной очереди в конвейере и использовал соответствующий сигнал спроса для масштабирования, если дополнительные экземпляры действительно увеличивают дефицитный ресурс. Общее число повторных попыток и число отказов по квоте сами по себе таким сигналом не являются.
Затем повторите расчёт для потери узла или зоны доступности. Сколько мощности останется и смогут ли уцелевшие поды отклонять лишнюю работу, не развалившись? Политика масштабирования и самозащита каждого пода отвечают на разные части этого вопроса. Нагрузочный тест при двукратном или десятикратном предложенном трафике часто рассказывает больше, чем красивый график автоскейлинга в обычный день.
Собираем всё вместе
Перед выбором механизма я бы задал три вопроса:
Сколько полезной работы при таком составе нагрузки успеет завершиться вовремя? Куда уходит избыток? Во сколько обходится каждая попытка на всём пути?
Один график CPU или число записей в брокере на них не ответит.
На каждом дефицитном этапе управление допуском решает, начать ли работу, разрешить ей ограниченное ожидание, отказать или отложить. Квоты, часто реализуемые ограничителями частоты запросов, распределяют доли, а не создают мощность. Вероятностное отсечение может отклонять растущую долю менее ценной работы при усилении затора, сохраняя запас для важного трафика. Ни то ни другое не заменяет жёстких ограничений начатой работы и ожидания внутри сервиса. Дедлайны и circuit breakers помогают избежать работы, которая вряд ли завершится успешно; breaker должен отклонять запросы ещё до того, как мы потратим много ресурсов на подготовку вызова необходимого, но уже неработающего зависимого сервиса. Планировщик выбирает, какая ожидающая задача получит следующий слот.
Отказ одного пода означает, что он защищает себя, а не что зависимый сервис целиком «лежит». Клиент может попробовать другой под, пока у него остаются время до дедлайна и бюджет попыток. Повторные отказы на разных подах показывают, что у всего сервиса, вероятно, нет места и новые попытки мало помогут. Каждый компонент может судить по собственным ограничениям и недавним исходам, но пределы повторов не дают этим локальным решениям превратиться в шторм во всём кластере.
Если запрос отклонён, клиент может повторить его; если потребитель приостановился, брокер сохранит запись. Ни то ни другое не уничтожает избыток работы. Даже автоскейлер добавляет мощность лишь после задержки и только если растёт именно ресурс, ограничивающий пропускную способность.
Сервис запросов в реальном времени
Представим сервис с четырьмя ядрами, который тратит на запрос 20 миллисекунд CPU. Нагрузочный тест показывает, что при обычном составе запросов он завершает около 170 полезных запросов в секунду в пределах цели по задержке. Обычно он получает 130/с, но на десять секунд поток поднимается до 200/с. Для этого расчёта также предположим, что исполнители во время всплеска продолжают брать из очереди ожидания около 170 запросов в секунду. Если принять весь всплеск, останутся примерно 300 ожидающих запросов: 30 лишних каждую секунду в течение десяти секунд.
Сравним очередь с бюджетом задержки. Бюджет ожидания в 100 миллисекунд допускает примерно 17 мест (170/с × 0,1 с); для начала можно попробовать 16 и проверить в тесте. Такая очередь сгладит короткий всплеск, но все 300 запросов в неё не поместятся. Нужен и отдельный, проверенный нагрузкой предел начатых запросов, включая ожидающие I/O.
При входе HTTP-запроса сначала принимайте дешёвые решения. У клиента-арендатора может закончиться квота, хотя у пода есть место. Breaker необходимого зависимого сервиса может быть открыт, хотя квота ещё есть. Дедлайн мог уже пройти. Менее ценную работу можно отклонять вероятностно по мере роста затора, оставляя запас для важного трафика. Если запрос всё ещё может дать полезный результат, попробуйте дать ему активный слот или место в ограниченной очереди. Если нет ни того ни другого, быстро откажите. Разделяйте причины — квота, невозможность получить полезный результат, дедлайн или нехватка мощности, — чтобы клиент понимал, поможет ли повтор.
Очереди также нужно правило выбора следующего запроса. Для одного класса FIFO может быть достаточно; разные дедлайны или гарантии клиентам могут потребовать другого порядка. Удаляйте запрос, пропустивший дедлайн во время ожидания, и перед стартом заново проверяйте его дедлайн и состояние необходимого зависимого сервиса. Свободный слот — не причина начинать работу, которая уже не даст полезного ответа.
Активный слот остаётся занятым во время CPU-работы и I/O и освобождается, когда работа, которую он учитывает, действительно остановилась. Если последний зависимый сервис замедлится, вышестоящие сервисы тоже могут держать контексты запросов в памяти, пока его ждут. Если CPU и I/O обслуживаются разными пулами, нужно также ограничивать очереди между ними и число запросов, возвращающихся после I/O для продолжения обработки. Вызовам дефицитного зависимого сервиса нужны собственное ограничение и проверка breaker непосредственно перед вызовом: после допуска его состояние могло измениться. Отмена должна доходить до ожидающей и уже выполняемой работы, но таймаут запроса не доказывает, что вычисление остановилось; преждевременно освободив разрешение, можно запустить новую работу рядом со старой и превысить предел.7 Неограниченное число корутин, ожидающих входа в очередь, сделало бы это ограничение бессмысленным.
Если клиент повторяет запрос после быстрого отказа, ожидание переходит к нему. Во время паузы он всё ещё может держать в памяти контекст исходного запроса, поэтому дешёвый отказ для одного пода не обязательно дёшев для всего пути. Ограничивайте цикл повторов дедлайном и числом попыток, добавляйте случайный разброс к паузам и учитывайте Retry-After, если он есть. Если поздний отказ заставит клиента повторить дорогую подготовку, подумайте о кэшировании её результата или повторе только неудавшегося шага; неизвестный исход операции также требует правила идемпотентности.
При появлении или исчезновении реплик под сохраняет прежние границы допуска. Я бы вывел вместе уникальные предложенные запросы, повторные попытки, допуски, ожидание в очереди, отказы по причинам и полезные завершения до дедлайна. Дополнительные поды помогают только после готовности; ожидание их появления не оправдывает бесконечную очередь.
Конвейер обработки через брокер
Публикация в Kafka или SQS ещё не означает допуск к этапу обработки, ресурсы которого ограничены. Производитель должен знать, есть ли у задачи дедлайн, можно ли её отбросить или она обязательно должна быть выполнена. Дайте ей логический идентификатор, чтобы отличать повторы от нового спроса. Квоты могут распределять доли производителей; менее ценную работу можно отсекать до публикации только при контракте, допускающем её потерю. Если каждая задача обязательна, производителю нужны обратное давление или цель по времени ликвидации очереди и мощность, достаточная для её достижения.
Потребитель ограничивает число забираемых записей и запускаемых задач. До дорогой обработки отбрасывайте задачи с истёкшим дедлайном и уже завершённые дубликаты. Затем важен порядок: FIFO может быть обязательным для упорядоченной работы, а независимым задачам с истекающими дедлайнами иногда лучше подходит выбор по сроку или ценности. LIFO спасёт часть свежих задач, только если менять порядок разрешено, а старые активно удаляются. Открытый breaker или заполненный этап CPU, базы данных либо GPU — повод отложить или отбросить работу по её контракту, а не строить ещё одну бесконечную очередь внутри потребителя.
Если разрешения на работу нет, оставьте её в брокере или в небольшом ограниченном наборе уже полученных записей. Когда дорогой этап завершится, надёжно сохраните его результат, прежде чем отмечать входную запись как обработанную. Обеспечьте идемпотентность на случай сбоя между этими действиями. Тогда следующий этап сможет повториться без всей подготовительной работы; той же цели могут служить кэш, сохранённый промежуточный результат или контрольная точка. Точные правила подтверждения сообщений и смещений зависят от брокера и гарантий порядка.8
Это защищает исполнителей, но само по себе не контролирует возраст очереди. Отдельно следите за новыми логическими задачами, записями повторов, возрастом самой старой полезной задачи, впустую потраченным дефицитным ресурсом и завершениями вовремя. Если ожидаемое время превышает срок полезности задачи, на стороне производителя нужны обратное давление или отсечение там, где контракт это допускает. Добавление потребителей не поможет, если не выросла мощность ограничивающего CPU, базы данных или GPU.
В обоих вариантах должно быть понятно, что происходит с каждым запросом или задачей: обработка начинается сразу, задача ждёт в очереди с ограничением по числу или возрасту, возвращается отправителю для ограниченного числа повторов либо отбрасывается после утраты ценности. При избыточной нагрузке полезные завершения должны держаться около проверенной мощности, а задержка успешных ответов и использование ресурсов сервиса — оставаться ограниченными. Избыток должен быть виден в быстрых отказах или очереди известного возраста, а не в обвале полезной пропускной способности. Если мы не можем объяснить, куда уходит работа, вероятно, мы просто спрятали ещё одну очередь вместо управления перегрузкой.
Вернёмся к двум системам
Вернёмся к системам из начала статьи. Разница была не между занятым и простаивающим сервисами: обоим предлагали больше работы, чем они могли завершить. Для каждой я бы вывел на один график предложенные запросы, попытки, допуски, полезные завершения и ожидающую работу.
Мы знаем, что у системы A во время пика пропускная способность рухнула, пока поды перезапускались. Если число допущенных запросов или скрытое ожидание продолжало расти при падении полезных завершений, это объяснило бы, почему одни только новые поды не восстановили обработку запросов. Прежде чем утверждать конкретную причину, я бы проверил, росло ли число попыток из-за повторов на нескольких уровнях, расходовала ли ресурсы работа с истёкшим дедлайном и уменьшалась ли доступная мощность из-за неудачных проверок живости или готовности. Остановка трафика и его постепенный возврат согласуются с тем, что допущенная работа вновь опустилась ниже темпа, с которым сервис мог её завершать, и очередь наконец получила возможность сокращаться.
У системы B картина иная: допущенная работа и ожидание оставались ограниченными, а лишний поток получал быстрый отказ. Задержка успешных ответов оставалась в пределах цели, новые поды подключались, не нарушая работу уже запущенных. CPU около 99% сам по себе не был мерой успеха; полезные завершения, ограниченный объём работы внутри сервиса и доля уникальной предложенной работы, выполненной вовремя, говорят намного больше.
Контрольный список для защиты сервиса
До следующего пика я бы хотел коротко ответить на четыре вопроса:
- Какой ресурс ограничивает полезные завершения для каждого важного вида запросов и сколько стоит там одна попытка?
- Где работа может ждать — на допуске, при передаче между исполнителями, у зависимого сервиса, в брокере или у клиента — и что ограничивает её число и возраст?
- Можем ли мы отказаться от работы с исчерпанной квотой, пропущенным дедлайном или малыми шансами на успех до расходования этого ресурса? Если работа отменена, когда ресурс действительно освободится?
- При избыточном трафике или медленном зависимом сервисе сохраняются ли полезные завершения, остаются ли ожидание и память ограниченными и видны ли отказы запросам, которые ещё можно было бы выполнить с пользой?
Во время инцидента я бы сравнил уникальную предложенную работу, повторные попытки, допуски, полезные завершения вовремя и возраст самого старого полезного элемента очереди. Затем пересмотрел бы ограничение, которое не сработало, и посмотрел, помогло ли это:
- Если допущенная работа и число запросов в исполнении растут, а полезные завершения падают, ограничьте новые запуски на дефицитном этапе. Проверьте, восстанавливается ли полезная пропускная способность, пока ожидание и память стабилизируются.
- Если этот этап занят работой с истёкшим дедлайном или дорогими повторами, отбрасывайте устаревшее и отклоняйте обречённые попытки раньше. Должен уменьшаться расход ресурса впустую, а не только число попыток.
- Если завершения и задержка стабильны, а число отказов ещё полезной работе растёт, самозащита работает, но спрос остаётся неудовлетворённым. Если его нужно обслужить, добавляйте мощность в настоящем узком месте и проверяйте, что полезных завершений стало больше, а отказов меньше.
Очереди, повторы, circuit breakers и автоскейлеры сами по себе не являются ответом. Они перемещают ожидание, останавливают попытки или добавляют мощность после задержки. Я хочу проследить запрос на всём пути и посчитать стоимость каждого шага: CPU, память, активные слоты, соединения, работу зависимых сервисов и цену отказа. Когда замедляется последний зависимый сервис, какие запросы выше по цепочке остаются в памяти? Если мы повторяем попытку, какая работа повторится и что останется занятым во время паузы? Только после этого можно решить, какую границу добавить и где.
На обсуждении архитектуры я бы задал один вопрос: если завтра клиенты пришлют в десять раз больше запросов, чем сервис способен обработать, или необходимый зависимый сервис замедлится в десять раз, что продолжит завершаться вовремя и куда денется остальная работа? Я не хочу, чтобы каждый клиент знал, как поддерживать жизнь моего сервиса. Я хочу, чтобы сервис сам ограничивал нагрузку в соответствии со своей мощностью и ясно сообщал, когда больше принять не может, оставляя клиентам решение, стоит ли им платить за новую попытку.
-
Сервис из начального инцидента отвечал
429при перегрузке. RFC 6585 определяет429для клиента, отправившего слишком много запросов за определённое время. RFC 9110 определяет503для временной перегрузки сервера или обслуживания и допускаетRetry-After. Выбор влияет на поведение клиентов и мониторинг, поэтому его нужно явно закрепить в контракте API. ↩︎ ↩︎ -
В документации Tokio сказано, что
Sender::sendждёт места в канале, аtry_sendпри заполненном буфере возвращает ответ сразу. ↩︎ -
Стандартный шаблон circuit breaker использует результаты недавних неудачных вызовов, чтобы не выполнять операции с малой вероятностью успеха, а затем проверяет, восстановилась ли работа зависимого сервиса. Такое же раннее решение защищает собственные потоки, соединения, память и CPU вызывающего компонента. ↩︎
-
См. материал Google об управлении перегрузкой — о бюджетах повторов и поведении при широкой перегрузке, материал AWS об ожидании со случайным разбросом и статью AWS об идемпотентных API. ↩︎
-
Стандартные очереди SQS могут доставлять сообщение несколько раз. Транзакции Kafka могут согласовывать выходные записи с прочитанными смещениями. Контрольные точки Flink восстанавливают управляемое состояние, но после сбоя входные данные с последней контрольной точки проигрываются снова; для сквозных эффектов «ровно один раз» также нужен транзакционный или идемпотентный приёмник. ↩︎
-
Kubernetes описывает сроки работы горизонтального автоскейлинга подов и разные последствия проверок живости и готовности. ↩︎ ↩︎ ↩︎ ↩︎
-
В документации Tokio указано, что уже выполняющуюся задачу
spawn_blockingнельзя прервать. Разрешение на такую работу должно оставаться занятым до её фактического завершения, даже если исходный запрос уже истёк. ↩︎ -
Потребители Kafka могут приостанавливать получение и фиксировать смещения обработанных записей. Таймаут видимости SQS временно скрывает полученные сообщения; после обработки потребитель удаляет их, а если не удалит до таймаута, они снова станут видимыми. ↩︎