Нагрузка и отказоустойчивость: когда добавлять экземпляры и как пережить отказ зависимости
Приложение заказов нормально отвечает при обычном трафике. Во время акции количество запросов растёт, страницы открываются медленнее, а некоторые пользователи получают ошибку. Первая идея: запустить ещё три экземпляра API.
Это поможет, если предел достигло само приложение. Но если все новые экземпляры одновременно обратятся к перегруженной базе, ситуация может ухудшиться. А если причиной стала недоступность Redis, дополнительные экземпляры не вернут кэш к работе.
Прежде чем масштабировать систему, нужно ответить на три вопроса:
- Где находится предел: в API, базе данных, очереди или внешней зависимости?
- Сколько работы система способна выполнять с допустимой задержкой, в том числе после отказа одного экземпляра?
- Что получит пользователь, если зависимость не ответит вовремя или перестанет отвечать вовсе?
Эта глава соединяет расчёт нагрузки с поведением при сбоях. Мы не будем считать отказоустойчивостью одно только наличие нескольких контейнеров: важно, какой результат система сохранит и какие данные при этом не потеряет.
Сквозной пример и границы системы
В нашем учебном цикле клиент обращается к api.example.com. Caddy принимает HTTPS-запрос и передаёт его приложению orders-api на FastAPI. Заказы хранятся в PostgreSQL, часть чтений обслуживает Redis. После создания заказа приложение передаёт фоновую задачу через RabbitMQ обработчику orders-worker. Компоненты запущены в контейнерах Docker.
Путь чтения выглядит так:
Клиент → Caddy → orders-api → Redis; при промахе кэша → PostgreSQL → ответ клиенту.
Путь создания заказа длиннее:
Клиент → Caddy → orders-api → PostgreSQL → публикация задачи в RabbitMQ → ответ клиенту; позже orders-worker → выполнение задачи.
Для GET /orders пользователь ждёт сам HTTP-ответ. Для POST /orders нужно дополнительно определить обещание API: заказ уже сохранён или лишь принят к обработке? Можно ли считать операцию успешной, если сообщение для фоновой задачи ещё не принято брокером? Ответы влияют и на таймауты, и на действия при отказе RabbitMQ.
В предыдущей главе о логах, метриках и трассировке мы собирали данные для поиска причины задержки. Здесь используем их для решения: увеличить мощность, ограничить входящую работу, изменить таймауты или предусмотреть особый режим работы при отказе зависимости.
Выводы раздела:
- Масштабировать нужно конкретное звено, предел которого измерен.
- Отказоустойчивость описывают через обещанный пользователю результат, а не через количество контейнеров.
Что считать нагрузкой и где возникает предел
Фраза «у нас 100 запросов в секунду» недостаточна для расчёта. Сто запросов к простому /health и сто запросов, каждый из которых выполняет SQL, создают разную нагрузку. Нужны как минимум:
- интенсивность запросов по важным маршрутам;
- соотношение чтений, записей и фоновых задач;
- размер запросов и ответов;
- распределение времени ответа, включая медленные запросы;
- число одновременно выполняющихся операций;
- использование CPU, памяти, соединений и других ограниченных ресурсов;
- скорость поступления и завершения задач в очереди.
Пропускная способность показывает, сколько полезной работы система завершает за единицу времени. Задержка показывает, сколько ждёт отдельный запрос. Это разные показатели: система может принимать больше запросов, но завершать прежнее число, накапливая очередь ожидания.
Почему рост задержки быстро увеличивает число ожидающих запросов
Для устойчивого потока среднее число запросов, находящихся в системе, связано с интенсивностью и временем пребывания соотношением Литтла:
L = λ × W
Здесь L означает среднее число запросов в системе, λ означает среднее число завершённых запросов в секунду в устойчивом режиме, W означает среднее время одного запроса в секундах. Соотношение следует из подсчёта накопленного времени: если за интервал каждый из завершённых запросов провёл в системе W секунд, суммарно они заняли примерно число запросов × W запросо-секунд. Деление на длину интервала даёт среднее число запросов, находившихся в системе одновременно. Строгие условия закона приведены в исходной работе Джона Литтла.
Предположим, GET /orders получает 90 запросов в секунду, а среднее время ответа составляет 0,25 секунды:
L = 90 × 0,25 = 22,5 запроса.
В среднем около 23 запросов одновременно находятся в системе. Если время вырастет до 2 секунд при той же интенсивности завершения:
L = 90 × 2 = 180 запросов.
Теперь нужны ресурсы для гораздо большего числа незавершённых операций: память, соединения, места в пулах и задачи выполнения. В период резкой перегрузки система может уже перестать быть устойчивой, поэтому формулу нельзя использовать как точный прогноз бесконечно растущей очереди. Она объясняет механизм: задержка удерживает ресурсы, нехватка ресурсов увеличивает задержку ещё сильнее.
Почему нельзя оценивать предел только по CPU
Высокий CPU одного экземпляра API при нормальной работе PostgreSQL может указывать на предел приложения. Низкий CPU API при долгом ожидании соединения с базой говорит о другом: добавление экземпляров не устранит ожидание.
Особенно полезно разделять:
| Наблюдение | Вероятная область проверки | Почему новые API-экземпляры могут не помочь |
|---|---|---|
| CPU API высок, задержки базы стабильны | Код API, число процессов, CPU | Здесь масштабирование может помочь, если хватает ресурсов сервера |
| Растёт ожидание соединения из пула API | Пул, длительность запросов, число соединений | Каждый новый процесс создаст ещё один пул |
| Время SQL растёт у всех экземпляров | Планы запросов, блокировки, ресурсы PostgreSQL | Все экземпляры обращаются к той же базе |
| Растут готовые сообщения RabbitMQ | Скорость orders-worker, его зависимости |
Новые HTTP-экземпляры могут публиковать задачи ещё быстрее |
| Redis недоступен, обращения к PostgreSQL резко выросли | Отказ кэша и резервный путь | Новые экземпляры усилят поток к базе |
Это гипотезы, а не автоматические диагнозы. Причину подтверждают измерениями конкретных операций и испытанием предполагаемого изменения.
Выводы раздела:
- Сравнивайте входящий поток с числом успешно завершённых операций.
- Если задержка растёт, сначала выясните, какой ограниченный ресурс удерживают ожидающие запросы.
Когда действительно добавлять экземпляры приложения
Рассмотрим отдельный случай: база и Redis работают устойчиво, но экземпляры orders-api достигают предела по CPU. Нагрузочное испытание показало, что один экземпляр с текущей смесью запросов надёжно обслуживает до 35 запросов в секунду, сохраняя согласованные пороги задержки и ошибок.
Назовём это значение q_safe. Оно меньше максимума, который удалось получить перед полным срывом работы: запас нужен для всплесков, изменения состава запросов и временных колебаний. Пусть ожидаемый пик λ_peak равен 90 запросам в секунду.
Если требуется выдерживать пик даже после потери одного экземпляра, количество n должно удовлетворять условию:
(n − 1) × q_safe ≥ λ_peak.
С одним отказавшим экземпляром остаётся n − 1 работающих. Подставим числа:
- при
n = 3остаются два экземпляра:2 × 35 = 70, этого меньше 90; - при
n = 4остаются три экземпляра:3 × 35 = 105, этого достаточно для измеренного пика.
Таким образом, четыре экземпляра соответствуют выбранному требованию. Без требования пережить отказ расчёт дал бы три, но запас на потерю одного исчез бы. Число 35 нельзя переносить в другую систему: его нужно получить испытанием на реальной смеси операций и с действующими зависимостями.
Процессы, контейнеры и машины решают разные задачи
Несколько рабочих процессов Uvicorn помогают задействовать несколько ядер одного сервера. Несколько контейнеров приложения позволяют отдельно запускать и заменять экземпляры. Экземпляры на разных физических или виртуальных машинах позволяют пережить отказ одной машины. Документация FastAPI отдельно описывает рабочие процессы и репликацию контейнеров.
Четыре контейнера на одном сервере способны пережить остановку одного процесса, но не выключение самого сервера. Если единственный Caddy работает на той же машине, он тоже остаётся единой точкой отказа. Для устойчивости к отказу сервера понадобятся размещение на разных узлах и доступный при таком отказе входной балансировщик. Это уже другое требование к инфраструктуре.
Приложение должно быть готово принимать любой запрос на любом экземпляре. Если корзина, сессия или состояние заказа существуют только в памяти одного процесса, переключение запроса на соседний экземпляр их потеряет. Состояние, необходимое после переключения, хранят в общей системе с подходящими гарантиями, а не маскируют проблему «приклеиванием» пользователя к одному процессу.
Балансировка и проверка готовности
Для двух явно именованных экземпляров пример Caddyfile может выглядеть так:
api.example.com {
reverse_proxy orders-api-1:8000 orders-api-2:8000 {
lb_policy round_robin
health_uri /ready
health_interval 5s
health_timeout 1s
}
}
orders-api-1:8000 и orders-api-2:8000 должны быть достижимы Caddy по внутренней сети. lb_policy round_robin распределяет новые запросы между доступными upstream-серверами. health_uri /ready задаёт путь активной проверки. health_interval 5s задаёт период проверок, а health_timeout 1s ограничивает ожидание ответа проверки. Возможности и точный смысл параметров описаны в документации Caddy.
Проверка готовности должна отвечать на вопрос: может ли этот экземпляр сейчас принимать предназначенную ему работу? При запуске она становится успешной после необходимой инициализации. При штатной остановке экземпляр сначала перестаёт принимать новые запросы, затем получает время закончить уже принятые. Отдельная проверка живости отвечает более узко: работает ли процесс настолько, что его не нужно перезапускать.
Проверять PostgreSQL в каждом запросе /ready без продуманной цели опасно. Если общая база временно недоступна, все API-экземпляры одновременно станут «неготовыми». Балансировщик потеряет весь список upstream-серверов, хотя приложение могло бы ещё обслуживать некоторые чтения или возвращать осмысленную ошибку. Состояние общей зависимости лучше измерять отдельно и учитывать в поведении маршрутов.
Проверки тоже имеют интервал. Между отказом процесса и следующей проверкой часть запросов может попасть в него. Несколько экземпляров сокращают влияние отказа, но сами по себе не обещают ноль ошибочных запросов.
Выводы раздела:
- Расчёт экземпляров опирается на измеренную безопасную ёмкость и требование пережить конкретный отказ.
- Несколько контейнеров на одной машине не означают устойчивость к отказу машины.
- Балансировщик должен знать, какие экземпляры готовы, а приложение должно корректно завершать принятые запросы.
Новый риск после масштабирования: общие зависимости
Вернёмся к четырём экземплярам. Пусть каждый запускает один процесс API с пулом PostgreSQL, который сохраняет до восьми соединений и при нагрузке может временно открыть ещё два. Для SQLAlchemy эти настройки соответствуют pool_size = 8 и max_overflow = 2; pool_timeout ограничивает ожидание свободного соединения. Значение параметров и поведение пула описаны в документации SQLAlchemy.
Предельное число соединений от API:
4 экземпляра × 1 процесс × (8 постоянных + 2 дополнительных) = 40 соединений.
Допустим, два процесса orders-worker могут открыть по шесть соединений, а для обслуживания и других клиентов мы резервируем ещё 15. Тогда планируемый верхний предел составляет:
40 + 2 × 6 + 15 = 67 соединений.
Если у PostgreSQL настроен предел 100 соединений, остаётся запас. Но после бездумного увеличения API до восьми экземпляров расчёт изменится:
8 × 10 + 12 + 15 = 107 соединений.
Это уже больше принятого предела. При этом увеличение max_connections тоже расходует ресурсы PostgreSQL и не заменяет управление нагрузкой, что указано в документации PostgreSQL.
Расчёт представляет верхнюю границу конфигурации, а не утверждение, что все соединения постоянно открыты. Его задача: обнаружить опасное изменение до выпуска. Кроме числа соединений нужно измерять время их получения, длительность транзакций и нагрузку отдельных запросов.
Та же логика действует для RabbitMQ и Redis. Четыре экземпляра API способны создать больше одновременных обращений и сообщений, чем два. Предел системы задаёт не сумма красивых цифр на API-серверах, а звено, через которое должна пройти нужная пользовательская операция.
Выводы раздела:
- Перед добавлением экземпляров пересчитайте общий предел соединений и обращений к каждой общей зависимости.
- Горизонтальное масштабирование входного слоя не увеличивает автоматически ёмкость базы и обработчиков задач.
Таймаут: какое ожидание он ограничивает
Если зависимость замедлилась, запрос не должен ждать бесконечно и удерживать ресурсы. Но одна цифра timeout=2 мало что объясняет. Нужно знать, с какого момента начинается отсчёт и какого события ждут.
У HTTP-клиента встречаются разные ограничения:
- connect timeout: сколько ждать установления соединения;
- pool timeout: сколько ждать свободное соединение из локального пула;
- write timeout: сколько ждать отправки очередной части данных;
- read timeout: сколько ждать очередную часть ответа;
- общий срок операции: когда работа должна завершиться целиком.
В HTTPX эти четыре сетевых ограничения настраиваются отдельно. read timeout ограничивает ожидание очередной порции данных, поэтому его нельзя автоматически считать пределом полной длительности запроса. Это подробно показано в документации HTTPX.
Например, для вызова внутреннего HTTP-сервиса:
import httpx
timeout = httpx.Timeout(
0.6,
connect=0.15,
pool=0.05,
)
client = httpx.AsyncClient(timeout=timeout)
Здесь 0.6 секунды служит значением по умолчанию для неуточнённых этапов, connect=0.15 ограничивает установление соединения, pool=0.05 ограничивает ожидание соединения из пула. Клиент обычно создают на жизненный цикл приложения, а не заново для каждого запроса. Эти настройки не доказывают, что весь пользовательский запрос уложится в 0,6 секунды: он может включать несколько последовательных вызовов и собственную работу.
Для общей границы асинхронной операции Python предоставляет asyncio.timeout():
import asyncio
async def list_orders_with_deadline(service):
try:
async with asyncio.timeout(1.8):
return await service.list_orders()
except TimeoutError:
raise OrdersTemporarilyUnavailable()
Контекст пытается отменить ожидаемую асинхронную работу после 1,8 секунды. Это не магическое прерывание любого синхронного или блокирующего кода: приложение и используемые библиотеки должны корректно обрабатывать отмену и освобождать ресурсы. Особенности описаны в документации Python.
Как связать ограничения в один бюджет
Предположим, пользователю нужен ответ GET /orders не позднее 2,5 секунды. Для конкретного маршрута проектируем такие учебные пределы:
| Граница | Примерный предел | Зачем она нужна |
|---|---|---|
| Клиентское ожидание полного ответа | 2,5 с | После него результат уже может быть не нужен пользователю |
| Ожидание заголовков ответа API в Caddy | 2,2 с | Прокси не держит бесконечно молчащий upstream |
| Общий срок работы маршрута в API | 1,8 с | Остаётся время сформировать ошибку и доставить её через прокси |
| Обращение к Redis | 0,08 с | Кэш не задерживает чтение дольше допустимого |
| SQL-операция | 0,6 с | База не удерживает запрос без ограничения |
Это начальные значения для испытания, а не универсальные настройки. Время выполнения собственного кода, передачи ответа и сетевые колебания тоже занимают часть бюджета. Срок на каждом вложенном шаге должен учитывать оставшееся время запроса. Если до общего предела осталось 100 мс, нельзя начинать новую попытку обращения к базе с таймаутом 600 мс.
Для последовательных операций грубая проверка выглядит так:
Бюджет ≳ время Redis + время PostgreSQL + время кода API + время передачи + резерв.
Если Redis и база вызываются условно, считать нужно наиболее долгий допустимый путь. Если операции идут параллельно, их задержки не складываются полностью: важен наиболее долгий путь, от которого зависит ответ.
У Caddy response_header_timeout ограничивает ожидание заголовков ответа от upstream, а не гарантирует предельную длительность любого ответа целиком. Называть его общим дедлайном запроса было бы ошибкой. Значение и другие транспортные ограничения приведены в документации Caddy.
Для PostgreSQL отдельно существуют statement_timeout и lock_timeout. Первый ограничивает длительность SQL-команды, второй касается только ожидания блокировки. Если оба равны, ограничение блокировки теряет смысл, поскольку общий срок команды сработает не позже него. Эти параметры и их область действия описаны в документации PostgreSQL.
Почему «поставим таймаут побольше» часто ухудшает отказ
Пусть зависимость перестала отвечать, а каждый API-запрос ждёт её 30 секунд. При 90 запросах в секунду система быстро накопит много ожидающих операций, соединений и памяти. Если установить разумный срок и вернуть понятный отказ, ресурсы освобождаются раньше.
Слишком короткий срок тоже опасен. Он может прерывать нормальные запросы во время всплеска и запускать дополнительные повторы. Поэтому сроки выбирают по измеренным задержкам зависимостей и требуемому времени пользовательского ответа, а затем проверяют под нагрузкой. Подробный разбор этих компромиссов есть в материале Amazon о таймаутах и повторах.
Выводы раздела:
- Называйте конкретную границу каждого таймаута: соединение, пул, ответ, SQL или вся операция.
- Внутренние вызовы должны укладываться в оставшийся срок пользовательского запроса.
- Таймаут ограничивает ожидание; сам по себе он не восстанавливает зависимость.
Повторы: когда они помогают и когда создают новый отказ
После таймаута возникает соблазн немедленно повторить запрос. Иногда это правильно: кратковременная сетевая ошибка прошла, следующая попытка успешна. Но повтор расходует ресурсы зависимости, которая, возможно, уже перегружена.
Предположим, приложение получает 100 пользовательских запросов в секунду и допускает до трёх попыток обращения к одной зависимости. Обозначим через p вероятность неудачи каждой попытки и для простого расчёта предположим, что исходы попыток независимы. Тогда ожидаемое число обращений к зависимости на один пользовательский запрос:
A = 1 + p + p².
Первая попытка выполняется всегда. Вторая нужна с вероятностью p. Третья нужна, если не удались первые две, с вероятностью p².
Если p = 0,3, получаем:
A = 1 + 0,3 + 0,09 = 1,39.
При 100 пользовательских запросах в секунду зависимость получает в среднем около 139 обращений в секунду. Если отказ постоянный, p близко к единице и три попытки превращают 100 пользовательских запросов в 300 обращений. В реальном перегруженном сервисе неудачи часто зависимы друг от друга, поэтому простой вероятностный расчёт может недооценивать худший сценарий.
Если повторы встроены сразу в несколько уровней, их число может перемножаться. Клиент повторяет запрос к API, API повторяет запрос к сервису, а тот повторяет обращение к базе. Поэтому у операции должен быть понятный владелец повтора, ограничение числа попыток и общий срок. Между попытками применяют увеличивающуюся паузу и небольшое случайное смещение, чтобы множество клиентов не вернулось одновременно. Этот механизм называется backoff с jitter; его последствия для перегруженной зависимости разобраны в Amazon Builders' Library.
После таймаута нельзя считать, что операция не произошла
Это критично для POST /orders. Представим:
- API отправил запись заказа в PostgreSQL.
- Транзакция завершилась.
- Ответ потерялся по сети или клиент перестал ждать.
- Клиент повторил
POST /orders.
Если повтор создаёт второй заказ, устойчивость к сетевому сбою превратилась в ошибку данных. Поэтому для повторяемой операции создания нужен ключ идемпотентности: клиент повторяет запрос с тем же ключом, а сервер хранит связь ключа с уже созданным результатом. Повтор возвращает результат исходной операции вместо нового побочного эффекта. Ключ, область его уникальности, срок хранения и проверку неизменности содержимого запроса нужно определить в контракте API.
Повторять безопасный GET обычно проще, но и здесь нельзя начинать попытку, которая заведомо не помещается в оставшийся срок. Для записей в базу и публикации сообщений необходимо отдельно учитывать неопределённость результата после обрыва связи.
Выводы раздела:
- Повтор подходит для ограниченных временных сбоев, когда он не нарушает смысл операции.
- Повтор при перегрузке может увеличить входящий поток и задержать восстановление.
- Таймаут записи означает «результат неизвестен», а не «запись точно не произошла».
Как переживать отказ каждой зависимости
Единого действия на все случаи нет. Нужно определить, без каких данных маршрут не может выполнить обещание, а какие возможности допускают временное ухудшение.
| Отказ | Что возможно сохранить | Что нельзя обещать без дополнительных механизмов |
|---|---|---|
Один экземпляр orders-api |
Новые запросы могут перейти к другим готовым экземплярам | Выполнение запроса, уже оборванного на отказавшем экземпляре |
| Redis | Часть чтений можно направить в PostgreSQL или использовать допустимые устаревшие данные | Безграничный обход кэша без риска перегрузить базу |
| PostgreSQL | Некоторые публичные или заранее подготовленные данные могут оставаться доступными | Надёжное создание нового заказа без основного хранилища |
| RabbitMQ | Можно сохранить намерение отправить задачу в устойчивом хранилище и опубликовать позже, если такой механизм предусмотрен | Гарантированную доставку задачи от одного только HTTP-ответа |
| Внешний сервис уведомлений | Заказ можно сохранить, а уведомление повторить позже | Мгновенное уведомление при недоступном внешнем сервисе |
| Сервер, на котором запущены все контейнеры | При наличии других серверов и переключения работа может продолжиться | Устойчивость от нескольких контейнеров на одном сервере |
Redis: обойти кэш, но защитить базу
Допустим, в обычном режиме из 90 запросов GET /orders в секунду 70 % обслуживаются из кэша. Приблизительный поток к PostgreSQL составляет:
90 × (1 − 0,70) = 27 запросов в секунду.
Пусть база по результатам испытания сохраняет нужную задержку до 50 таких запросов в секунду. Если Redis недоступен и приложение без ограничений направит в базу все чтения, поток станет 90 запросов в секунду. Он превысит измеренную безопасную ёмкость базы. Четыре исправно работающих API-экземпляра только быстрее создадут этот поток.
Поэтому резервный путь требует ограничения одновременных запросов к базе и решения, что делать с избытком. Для некоторых чтений допустимы подготовленный ответ или явно обозначенные устаревшие данные. Для списка заказов конкретного пользователя устаревший ответ может быть недопустим: решение зависит от требований продукта и защиты данных. Если корректный ответ сейчас получить нельзя, лучше быстро вернуть временную ошибку, чем держать запрос до общего таймаута.
При временной перегрузке сервер может отвечать 503 Service Unavailable и, если известна разумная оценка, указывать Retry-After. Этот заголовок не обязывает клиента слепо повторять операцию; смысл статуса и заголовка описан в HTTP-стандарте.
PostgreSQL: не превращать кэш в «источник истины»
Redis может содержать последнюю копию части данных, но в нашем примере PostgreSQL остаётся основным хранилищем заказов. При его недоступности POST /orders нельзя честно объявлять успешно завершённым только потому, что данные временно записаны в кэш. Для такого обещания пришлось бы отдельно проектировать устойчивую запись, порядок синхронизации, обработку конфликтов и восстановление после отказа кэша. Это уже другая архитектура.
Если требуется пережить отказ самого сервера PostgreSQL, нужны репликация, обнаружение отказа, переключение на резервный узел и проверка согласованности после переключения. Резервная копия помогает восстановить данные, но не переключает пользователей автоматически. У асинхронной потоковой репликации PostgreSQL есть окно, в котором подтверждённая запись ещё не попала на резервный узел; при переключении она может быть потеряна. Это прямо указано в документации PostgreSQL о резервных серверах.
Здесь полезны две величины. RTO обозначает допустимое время восстановления работы, RPO обозначает допустимую потерю последних данных, выраженную временем. Требования «восстановиться за десять минут» и «не потерять подтверждённые заказы» ведут к разным решениям и стоимости. Нельзя получить гарантию по ним только установкой короткого таймаута API.
RabbitMQ: очередь сама тоже может отказать
Если orders-api сохранил заказ, а затем потерял связь с RabbitMQ, нужен способ не забыть задачу. Один вариант: записывать заказ и запись о необходимом событии в одной транзакции PostgreSQL, а отдельный процесс публикует события позже. Это схема transactional outbox. Она помогает пережить временную недоступность брокера, но не отменяет необходимость отслеживать публикацию, повторять её и делать обработчик устойчивым к дублям.
На стороне RabbitMQ сохранность зависит от конфигурации очереди и сообщений, подтверждения публикации и подтверждения обработки. Несколько API-экземпляров не делают одиночный брокер отказоустойчивым. Для требования пережить отказ узла брокера нужно выбирать соответствующую реплицируемую очередь и проверять её гарантии. Руководство RabbitMQ по надёжности отдельно описывает обязанности издателя, брокера и потребителя.
Если обработчики медленнее поступления задач, очередь растёт. Для короткого интервала изменение её длины можно оценить так:
ΔQ ≈ (λ_in − μ_out) × t, если λ_in > μ_out.
λ_in означает скорость поступления задач, μ_out скорость завершения задач, t длительность перегрузки. При поступлении 60 задач в секунду и завершении 40 за пять минут накопится примерно:
(60 − 40) × 300 = 6000 задач.
Это оценка без учёта повторных доставок и изменения скоростей. Она показывает, почему после восстановления зависимости очередь может ещё долго разбираться. Наблюдать нужно не только число сообщений, но и возраст самой старой ожидающей задачи.
Выводы раздела:
- Резервный путь должен иметь собственный предел нагрузки и понятное качество ответа.
- Для записи заказа при отказе основной базы нельзя обещать успех без другой устойчивой модели хранения.
- Быстрый HTTP-ответ не подтверждает завершение фоновой работы.
Ограничение нагрузки, изоляция и «предохранитель»
Когда система перегружена, бесконечное ожидание в очереди запросов редко помогает. Старые запросы уже могут быть не нужны клиентам, но продолжают занимать память и соединения. Ограничение нагрузки означает, что система принимает столько работы, сколько способна завершить с полезным результатом, а избыток отклоняет достаточно рано. Такой подход и его связь с защитой от каскадных отказов подробно разбираются в книге Google SRE.
Для приложения заказов ограничения можно поставить на нескольких уровнях:
- на входе: число одновременных HTTP-запросов к дорогому маршруту;
- перед PostgreSQL: число параллельных операций чтения;
- у
orders-worker: число одновременно исполняемых задач; - перед внешним API: число одновременных вызовов к сервису уведомлений.
Локальное ограничение в каждом процессе тоже умножается на число процессов. Если каждый из четырёх экземпляров допускает восемь одновременных обращений к зависимости, общий предел может достигать 32. После увеличения числа экземпляров прежнее локальное значение, возможно, придётся уменьшить.
Изоляция ресурсов означает, что необязательная операция не должна занять все места, необходимые критичной. Например, массовая выгрузка списка заказов не должна исчерпать пул, через который создаются новые заказы. Это можно обеспечить отдельными пределами, пулами или выделенными обработчиками. Точное разделение зависит от нагрузки: слишком много маленьких пулов тоже приводит к простаивающей ёмкости.
Предохранитель, или circuit breaker, прекращает обращения к зависимости после устойчивой серии неудач, а через некоторое время пропускает ограниченное число пробных запросов. Это защищает вызывающий сервис от повторяющегося долгого ожидания. Но у механизма появляется собственное состояние: когда он открывается, когда пробует восстановиться и не приводит ли он к лишним отказам после выздоровления зависимости. Начинать обычно разумно с конечных таймаутов, ограниченной конкуренции и контролируемых повторов. Предохранитель добавляют, когда измерения показывают, какую проблему он решит.
Отдельный вопрос: кому отвечать 429, а кому 503. Если клиент превысил назначенную ему квоту, подходит 429 Too Many Requests. Если сам сервис временно не располагает мощностью для общей нагрузки, обычно уместнее 503. В обоих случаях клиентская политика повтора должна ограничивать частоту и учитывать оставшийся срок операции.
Выводы раздела:
- Ранний управляемый отказ может сохранить больше успешно выполненных запросов, чем длинная общая очередь.
- Локальные пределы необходимо пересчитывать при изменении числа экземпляров.
- Сложный предохранитель не заменяет базовые таймауты, ограничения и измерения.
Как проверить расчёт, а не поверить ему
Нагрузочное испытание должно воспроизводить не только среднее число запросов, но и состав работы. Для orders-api подготовьте данные с реалистичным числом пользователей и заказов, обычное соотношение чтений и записей, долю попаданий в Redis и фоновую нагрузку. Пустая база и постоянно тёплый кэш дадут завышенную оценку ёмкости.
Проверяйте ступени нагрузки: обычный поток, прогнозный пик, краткий всплеск выше пика и восстановление после него. На каждой ступени измеряйте:
- успешно завершённые запросы в секунду;
- p95 и p99 времени ответа, а также долю ошибок;
- CPU и память каждого API-экземпляра;
- ожидание соединений в пулах;
- время SQL-операций и число активных соединений PostgreSQL;
- попадания в кэш;
- скорость публикации и обработки задач, длину и возраст очереди.
Важен способ подачи нагрузки. Если 50 тестовых клиентов отправляют следующий запрос только после ответа на предыдущий, замедление системы автоматически уменьшит число поступающих запросов. При среднем ответе 0,25 секунды такие клиенты теоретически могут создавать около 50 / 0,25 = 200 запросов в секунду. Когда ответ растянется до секунды, они создадут уже около 50 / 1 = 50 запросов в секунду. Тест начнёт щадить перегруженную систему. Поэтому полезно дополнить испытание режимом с заданной скоростью поступления запросов и отдельно учитывать неудавшиеся и не начатые вовремя запросы.
После измерения нормального режима проведите испытания отказов:
| Испытание | Ожидаемое поведение | Что проверить |
|---|---|---|
Остановить один orders-api |
Остальные принимают новые запросы | Задержку переключения, ошибки во время обнаружения отказа, запас ёмкости |
| Отключить Redis | Допустимые чтения переходят на ограниченный резервный путь | Поток к PostgreSQL, p95, управляемые отказы |
| Замедлить PostgreSQL | Операции завершаются в пределах бюджета или быстро отказывают | Ожидание пула, число занятых соединений, прекращение работы после таймаута |
| Отключить RabbitMQ | Сохранённые заказы не теряют намерение выполнить фоновую задачу, если предусмотрен outbox | Накопление записей, публикацию после восстановления, дубли |
| Замедлить сервис уведомлений | Создание заказа остаётся корректным, фоновые задачи не занимают все обработчики | Повторы, возраст задач, итоговые состояния |
| Остановить общий сервер | Работа прекращается, если резервного узла нет | Соответствие реальной архитектуры заявленному уровню отказоустойчивости |
Последняя строка нужна намеренно. Испытание должно выявлять границу защиты, а не только подтверждать удачные сценарии.
Решение для системы заказов целиком
Предположим, измерения дали следующие результаты:
- пиковый поток
GET /orders: 90 запросов в секунду; - один экземпляр API безопасно выдерживает 35 таких запросов в секунду при текущей смеси операций;
- PostgreSQL безопасно обслуживает 50 соответствующих чтений в секунду;
- нормальная доля попаданий в Redis: 70 %.
При нормальном кэше база получает около 27 чтений в секунду. Четыре API-экземпляра позволяют выдержать пик после остановки одного экземпляра: три оставшихся дают измеренную ёмкость 105 запросов в секунду. Caddy исключает неготовый экземпляр после проверки. Пулы соединений рассчитаны на все четыре экземпляра и фоновых работников.
Но после отказа Redis поток к PostgreSQL может подняться до 90 чтений в секунду, превысив её безопасные 50. Следовательно, увеличение числа API-экземпляров решает отказ одного API-процесса, но не отказ Redis. Для второго случая нужен другой план: короткий таймаут кэша, ограничение обращений к базе, допустимое ухудшение отдельных чтений или быстрый временный отказ. Для записей заказов нужен отдельный план на случай недоступности PostgreSQL; для фоновых задач, на случай недоступности RabbitMQ.
Так выглядит практическая отказоустойчивость: для каждого отказа заранее указаны сохраняемая функция, допустимое ухудшение, предел ресурсов и способ восстановления. Система не обязана выполнять всё во время любой аварии, но должна выполнять именно то, что обещано её контрактом.
Итог
Добавляйте экземпляры приложения, когда измерения показывают предел самого приложения и общие зависимости выдержат дополнительный поток. Рассчитывайте количество по безопасной ёмкости, пиковому трафику и числу экземпляров, потерю которых требуется пережить.
Задавайте таймауты как согласованный бюджет пользовательской операции. Различайте ожидание соединения, пула, отдельного ответа и полный срок запроса. Повторяйте только те действия, для которых определены условия безопасности, число попыток и оставшееся время.
При отказе зависимости выбирайте поведение по обещанию API: где можно использовать ограниченный резервный путь, где допустим устаревший ответ, а где нужно честно вернуть временную ошибку. Затем проверяйте выбранные решения испытаниями нагрузки и отказов. Только испытание покажет, выдерживает ли система не схему на бумаге, а нужный пользовательский результат.