load-tester Все курсы

✻ Урок 11.5 · Тема 11: Поиск узких мест

Кэш, внешние зависимости, таймауты и ретраи

⏱ 2.5 ч

Зачем это нужно

Я твой наставник: опытный коллега, который сидит рядом и смотрит на тот же стенд. Вот история из нашей работы. Тест показал, что при тройной нагрузке всё встаёт, и мы прошли базу, процессор и память. Остались два подозреваемых. Первый: кэш, быстрое временное хранилище готовых ответов. Второй: внешняя зависимость, всё, что сервис вызывает по сети и чем не владеет (платёжный шлюз, почта, чужое API). Починить её ты не можешь, но обязан пережить её сбой.

Кэш способен ускорить сервис в разы и разгрузить базу, но только если не обманываться цифрами. Медленная зависимость способна положить весь сервис, а повторные попытки (ретраи), добавленные «для надёжности», превращают небольшой сбой в лавину. Оба явления видны только под нагрузкой, поэтому их находят нагрузочные инженеры.

Шаг проекта: ты включаешь кэш карточек товаров и измеряешь выигрыш (доля попаданий, нагрузка на базу, p95) и проверяешь ловушки кэша. Затем замедляешь заглушку оплаты и смотришь, как один чужой сервис ломает все маршруты. Исправляешь таймаутами и ретраями и записываешь итог всей темы в ~/perf-lab/11-bottlenecks/05-cache-dependencies.md.

Что нужно знать

  • Метод и скрипты bn.js, set-env.sh, run.sh: урок 11.1; пул соединений, N+1 и «узкое место переезжает»: урок 11.3.
  • Redis и ключи со сроком жизни: урок 2.3; как кэш подключён в «Магазине»: CACHE_ENABLED, CACHE_TTL.
  • Закон Литтла и очереди: урок 8.2; хоккейная клюшка: урок 8.1.
  • Метрики shop_cache_requests_total, shop_payment_requests_total и PromQL rate, histogram_quantile: урок 7.2.
  • Заглушка оплаты на порту 8001 и её /admin/config (задержка и доля ошибок меняются на лету): описание стенда.
  • Настройки .env: CACHE_ENABLED=0, CACHE_TTL=60, PAYMENT_TIMEOUT=10, PAYMENT_RETRIES=3.

Картина целиком

Пиццерия. Кухня это база: пицца печётся 10 минут. Пятьдесят человек заказывают «Маргариту», повар печёт пятьдесят пицц подряд, очередь растёт. Умный менеджер держит под лампой десяток готовых: покупатель берёт за секунду, кухня занята редкими заказами. Это кэш, и у него свои проблемы: пицца под лампой остывает (данные устаревают). Во второй половине пиццерия заказывает тесто у стороннего поставщика. Поставщик задерживает, повара стоят и ждут, касса закрыта для всех, даже для тех, кто хотел кофе. А если менеджер при каждой задержке звонит поставщику ещё три раза, тот получает вчетверо больше звонков.

flowchart TD
    A["Запрос<br>карточка товара"] --> B{"Есть в кэше?"}
    B -->|"да (hit)"| C["Ответ из Redis<br>быстро, БД свободна"]
    B -->|"нет (miss)"| D["Запрос в БД<br>и запись в кэш"]
    E["Заказ"] --> F["Транзакция держит<br>соединение пула"]
    F --> G["Вызов оплаты<br>внешний сервис"]
    G -->|"медленно"| H["Пул занят, все<br>маршруты ждут"]

Верхняя часть показывает выигрыш кэша, нижняя опасность: вызов внешнего сервиса внутри транзакции удерживает дефицитное соединение пула на время чужой работы.

Теория

Кэш: зачем он и как измерить пользу

Одну карточку популярного товара смотрят тысячи людей. Заново читать её из базы каждый раз значит тратить процессор базы и соединения пула на одинаковую работу.

Это шпаргалка: ты записываешь ответ на карточку и достаёшь её за секунду, а не листаешь учебник. Шпаргалка не знает, что учебник обновили, и тогда ты уверенно даёшь неверный ответ.

В «Магазине» кэш это Redis (хранилище «ключ, значение» в памяти). При CACHE_ENABLED=1 обработчик карточки смотрит ключ product:<id>. Нашёл: отдаёт готовый ответ (hit, попадание). Не нашёл (miss, промах): идёт в базу, кладёт результат в Redis на CACHE_TTL секунд (по умолчанию 60) и отдаёт. Когда время вышло, запись истекает (TTL, time to live, время жизни), и следующий запрос снова идёт в базу. Каждое обращение считает метрика shop_cache_requests_total{result="hit"|"miss"}.

Главная метрика кэша: hit ratio (доля попаданий), то есть hits / (hits + misses). При 90% база видит только 10% прежней нагрузки, при 50% половину, а при 10% кэш почти бесполезен.

Посчитаем для «Магазина». Допустим, 80% запросов приходятся на 200 «горячих» товаров из 10 000, остальные 20% рассыпаны по каталогу. Горячий товар запрашивают раз в секунду-две, значит, за минуту он промахнётся один раз, остальные раз пятьдесят попадёт: около 99%. Холодных запросов 50 в секунду на 10 000 товаров, каждый товар приходит в среднем раз в 200 секунд. Запись живёт 60 секунд, но запросы идут неровно: примерно четверть повторов случается в пределах минуты после предыдущего. Это и есть попадания, около 0,26. Итого 0,8 × 0,99 + 0,2 × 0,26 ≈ 0,84.

Прикинь сам: при 250 запросах в секунду и hit ratio 84% сколько запросов доходит до базы?

Промахи это 16%: 250 × 0,16 = 40 запросов в секунду вместо 250. Вот что это даёт (ориентировочные числа):

Показатель Без кэша С кэшем (TTL 60 с)
Запросов к базе 250/с около 40/с
p95 карточки 38 мс 11 мс
CPU shop 78% 58%
CPU postgres 55% 10%

База разгрузилась в шесть раз, p95 упал в три с половиной раза. Даже процессору приложения стало легче: прочитать готовый ответ из Redis дешевле, чем разобрать ответ базы.

Это график «нагрузка против задержки» для сервиса с кэшем: предел около 430 запросов в секунду, а на 250 мы далеко от колена. Без кэша предел был около 320 (оба числа ориентировочные, как и вся таблица). Кэш поднял потолок, но не убрал его.

Осторожно: если тест просит один и тот же товар, кэш прогреется за секунду, и ты увидишь результат, которого в жизни не будет. Распределение запросов в тесте должно быть похоже на настоящее (поэтому в bn.js, нашем скрипте k6 из урока 11.1, 80% запросов идут на горячие товары). Ещё есть холодный старт: после очистки кэша все запросы идут в базу, и она должна выдержать полную нагрузку.

Проверь понимание: hit ratio 90%, нагрузка 1000 запросов в секунду. Сколько запросов видит база? Что будет, если перезапустить Redis?

Ответ

База видит 10%, то есть 100 запросов в секунду. После перезапуска Redis кэш пуст, hit ratio падает почти до нуля, и база получает все 1000, а справиться может со своими ста: очередь, пул переполнен. База должна выдерживать нагрузку и без кэша, хотя бы на время прогрева.

Главное: польза кэша равна hit ratio, а база должна выдерживать нагрузку и без него хотя бы на время прогрева.

Кэш быстрый. Но всегда ли он говорит правду?

Ловушки кэша: устаревшие данные и инвалидация

Администратор поправил цену товара, а покупатель всё ещё видит старую. Кэш отдал неправду, и это не баг, а его свойство.

Как табло в аэропорту, которое обновляется раз в минуту: рейс задержали, а на табло «по расписанию». Данные в кэше устаревают, как только изменятся в базе. Есть два способа это лечить: ждать истечения TTL (просто, но с задержкой) и инвалидация (invalidation): явно удалить запись при изменении. В «Магазине» инвалидация одна: после успешного заказа код удаляет ключи product:<id> купленных товаров. Всё остальное держится на TTL.

Проверь руками. С включённым кэшем: curl localhost:8000/api/products/1 (цена, например, 137). Потом UPDATE products SET price = 1 WHERE id = 1 прямо в базе и снова curl: придёт 137, а не 1. Через минуту, когда ключ истечёт, придёт 1. Ты обменял свежесть данных на скорость.

Прикинь сам: для каких полей карточки минута неточности допустима, а для каких нет?

Описание товара подождёт минуту. А вот остаток (stock) и цену в корзине так показывать нельзя. В «Магазине» заказ проверяет остаток в базе с блокировкой строки (SELECT ... FOR UPDATE), поэтому купить отсутствующий товар нельзя, но устаревшее число показать можно.

Для любопытных: гонка и давка

Гонка чтения и инвалидации: запрос A прочитал старую цену, цена поменялась, кэш очистили, и A записал старую цену обратно. Она пролежит до конца TTL. Давка (cache stampede): популярный ключ истёк, и сотни параллельных запросов одновременно идут в базу за одним и тем же. Защита: блокировка на ключ, случайный разброс TTL (jitter), прогрев.

Осторожно: нагрузочный тест кэша должен проверять не только скорость, но и корректность. Например, заказ должен идти по актуальной цене: код читает её из базы, а не из кэша.

Главное: кэш меняет свежесть на скорость, и для каждого поля решай, допустима ли минута неточности.

Кэш мы приручили. Теперь вторая половина: что делать, когда тормозит чужой сервис.

Внешняя зависимость: чужое время становится твоим

Оплата тормозит, а каталог, который оплату вообще не вызывает, тоже лёг. Как так?

Магазин ждёт доставку от курьерской службы. Служба задерживается, и если ты не разрешаешь выдать клиенту заказ без доставки, весь магазин стоит в очереди ждущих заказов.

В create_order код начинает транзакцию, блокирует строки товаров (SELECT ... FOR UPDATE: «возьми и не давай другим трогать, пока не закончу»), вставляет заказ, обновляет остатки, вызывает оплату pay() и только потом завершает транзакцию. Это намеренная ошибка проектирования (антипаттерн: приём, который выглядит разумным, а вредит). Пока идёт сетевой вызов, удерживаются три вещи. Первая: соединение из пула (оно занято, хотя база ничего не делает). Вторая: блокировки строк (другие заказы на те же товары ждут) и поток, который обслуживает этот запрос.

Если оплата отвечает за 50 мс, это незаметно. Если за 2 секунды, соединение занято 2 секунды вместо 50 мс. По закону Литтла каждое соединение даёт 1 / время_удержания заказов в секунду: при пуле 15 получается 15 / 2 = 7,5 заказов в секунду. Заказов приходит больше, а соединения нужны всем маршрутам: список товаров, карточка и корзина берут их из того же пула.

Пул из 15 соединений, каждое держится две секунды, приходит (для примера) 12 заказов в секунду, а освобождается 7,5. Очередь растёт, и ждущие запросы получают отказ по таймауту пула (5 с).

Вот что покажет прогон mix при 10 визитах в секунду и задержке оплаты 2000 мс (её меняет POST /admin/config на порту 8001). Базовая линия: p95 0,11 с, ошибок 0.

Маршрут p95 до p95 при задержке оплаты 2 с Доля 503
/api/products (каталог) 0,05 с 5,1 с 35%
/api/products/[id] 0,03 с 5,0 с 35%
POST /api/orders 0,08 с 7,3 с 45%
GET /api/orders 0,10 с 5,2 с 36%

Тормозит всё: p95 около 5 секунд (таймаут пула, DB_POOL_TIMEOUT=5), а 35% запросов получают 503 database pool timeout. Для пользователя «сайт лёг», хотя причина в чужом сервисе. Это каскадный отказ (cascading failure): сбой в одном месте разрастается на соседние.

Прикинь сам: где в этой картине искать виновника, если процессор postgres почти пуст?

Транзакции ждут оплату, а не работают. По дереву из урока 11.1: CPU shop низкий, ждут соединения (shop_db_pool_waiting высок), процессор базы не упёрт. Значит, соединения удерживаются зря: подозрение на зависимость. Подтверждают shop_payment_duration_seconds и длительность POST /api/orders при здоровой базе.

Осторожно: не ищи причину в базе, она тут жертва. И не путай таймаут пула (сколько ждать свободного соединения) с таймаутом оплаты (сколько ждать ответа).

Главное: вызов чужого сервиса внутри транзакции превращает его медленность в нехватку соединений для всех маршрутов.

Значит, ждать ответа надо ограниченное время. Сколько?

Таймауты: сколько ждать внешнего сервиса

Без таймаута вызов к зависшему сервису ждёт вечно и навсегда занимает твои соединения и потоки. Таймаут это лимит ожидания: дальше ты сдаёшься и возвращаешь ошибку. Это как звонок в службу: полминуты гудков можно, дольше нет смысла.

Выбирай таймаут по тому, сколько зависимость отвечает в норме и сколько готов ждать клиент. Оплата в норме отвечает за 50 мс, значит, PAYMENT_TIMEOUT=10 (по умолчанию) слишком щедр: клиент уйдёт раньше. Правило: возьми p99 нормальной работы и умножь на 3-5, это запас на плохие минуты (у оплаты около 0,25 с). Сверху ограничивает терпение пользователя: 3-5 секунд для веб-запроса. Библиотека httpx, которой shop ходит в оплату, применяет одно значение и к установке связи, и к ожиданию ответа.

Сравним на задержке оплаты 2 секунды:

PAYMENT_TIMEOUT Что с заказом Соединение держится
10 с оплата успевает (2 с), заказ создан 2 с
3 с то же, запас 1 с 2 с
1 с таймаут, 504 «payment timeout» (после повторов), заказ не создан 1 с на попытку

Прикинь сам: при таймауте 1 с и задержке оплаты 2 с заказ создаётся?

Нет: оплата не успевает, и все заказы превращаются в ошибки. При этом заглушка всё равно доделает работу, и деньги могли бы списаться, хотя клиент получил ошибку (зомби-работа). Поэтому у настоящих платёжных вызовов есть идемпотентные ключи. Это метка запроса: повтор с той же меткой не считается новым платежом и не списывает дважды. В учебном стенде их нет.

Осторожно: «таймаут сработал» не значит «операция не выполнилась»: на стороне зависимости работа могла завершиться.

Главное: таймаут выбирают по норме зависимости и терпению пользователя: короткий превращает медленное в ошибки, длинный держит соединение.

Таймаут ограничивает одну попытку. А если попыток несколько?

Ретраи: когда повтор помогает и когда убивает

Сбои бывают случайными: потерялась посылка, миг перегрузки. Повторная попытка часто проходит. Но звонить занятому абоненту каждую секунду разумно, а десять раз в секунду значит мешать ему освободиться.

pay() делает до PAYMENT_RETRIES + 1 попыток (при 3 это четыре) без паузы: упала одна, сразу вторая. Каждая неудача занимает до таймаута, и соединение пула держится всё это время. Получается три беды. Усиление нагрузки: вместо одного запроса на зависимость идёт до четырёх, и именно когда ей плохо. Сбой на 10 секунд растягивается в минуты (шторм ретраев, retry storm). Удержание ресурсов: четыре попытки по таймауту 1 с держат соединение 4 секунды. Синхронность: сотни клиентов повторяют одновременно, и пики складываются (thundering herd, «стадо»: все бегут в одну дверь разом).

Посчитаем. Пусть заказов приходит 12 в секунду (это отдельный пример, не тот прогон), предел оплаты 30 попыток в секунду. Без сбоя это 12 попыток в секунду, всё нормально. Во время сбоя при PAYMENT_RETRIES=3 каждый заказ делает 4 попытки: 12 × 4 = 48 в секунду, выше предела 30. Оплата перегружена, ошибки растут, ждущих клиентов больше, и повторов тоже. С PAYMENT_RETRIES=0 остаются 12 попыток в секунду, и оплата восстанавливается сразу.

Верхняя панель показывает попытки к оплате против предела (линия), нижняя успешные заказы. Включи режим «без пауз» с тремя повторами: во время сбоя попытки взлетают выше предела и остаются там и после сбоя. Переключи на «с паузой» или «бюджет повторов»: система восстанавливается.

Прикинь сам: оплата упала, PAYMENT_RETRIES=3, заказов 8 в секунду, предел оплаты 25. Сколько запросов в секунду вы ей шлёте?

8 × 4 = 32, выше предела 25: оплата не оправится, пока вы продолжаете ретраить.

Для любопытных: правильные приёмы

Экспоненциальная пауза с джиттером (exponential backoff with jitter). Ждать 0,1 с, 0,2 с, 0,4 с и так далее плюс небольшой случайный разброс (джиттер, jitter), чтобы клиенты не повторяли в один и тот же момент. Бюджет ретраев: повторов не больше, скажем, 10% от общего числа запросов. Выключатель (circuit breaker): при высоком проценте ошибок на время перестать вызывать зависимость и быстро отдавать ошибку. Не ретраить ошибки клиента (4xx) и неидемпотентные операции без ключа. Главное исправление для нашего антипаттерна: вынести вызов из транзакции. Сначала зафиксировать заказ в статусе pending и освободить соединение, потом вызвать оплату и обновить статус. Тогда медленная оплата держит только поток, а не дефицитное соединение. В учебном стенде это потребовало бы правки кода, поэтому мы ограничиваемся настройками, а в отчёте пишем это главной рекомендацией.

Осторожно: «три ретрая это надёжность» неверно: при общем сбое это три лишних удара по лежащему сервису. Ретраи без таймаута накапливаются без ограничения.

Главное: ретраи спасают от случайных сбоев и убивают при общих: считай, во сколько раз они умножают нагрузку на зависимость.

Вернёмся к распродаже: тройная нагрузка укладывала магазин сразу по нескольким причинам, и теперь у тебя есть метод, чтобы найти каждую. Кэш снимает нагрузку с базы. Таймауты и вынесенный из транзакции вызов оплаты не дают чужой медленности лечь на всех. А ретраи без паузы надо сокращать или заменять паузой.

Практика

Стенд поднят, мониторинг включён. Исходное состояние для урока: после 11.3-11.4 в .env стоит BUG_N_PLUS_ONE=0, DB_POOL_MAX=15, LEAK_ENABLED=0; индекс orders_user_id_idx создан. Проверь: grep -E '^(CACHE_ENABLED|BUG_N_PLUS_ONE|DB_POOL_MAX|LEAK_ENABLED|PAYMENT_TIMEOUT|PAYMENT_RETRIES)=' ~/learning/load-tester/project/shop/.env и psqlshop -c "\d orders" (индекс на месте). Если что-то не так, верни настройки через set-env.sh.

Часть 1. Кэш

1.1. Базовая линия без кэша

Нагрузка на карточки товаров, 250 запросов в секунду, 60 секунд. Это сценарий product с распределением 80/20.

Предупреждение: запускай именно bn.js из этого курса, а не эталонный project/shop/examples/k6/shop.js. Тот выбирает товар равномерно из 10 000, и при TTL 60 секунд кэш почти не срабатывает: hit ratio выйдет в районе нескольких процентов, а не 0,84. Это не поломка кэша, а другое распределение запросов.

cd ~/perf-lab/11-bottlenecks
./set-env.sh CACHE_ENABLED=0
psqlshop -c "SELECT pg_stat_statements_reset();"
./run.sh cache-off SCENARIO=product RATE=250 DURATION=60s
psqlshop -c "SELECT calls, round(mean_exec_time::numeric,2) AS mean_ms FROM pg_stat_statements WHERE query LIKE 'SELECT id, name, price, category_id, stock FROM products WHERE id%'"

Ожидаемо (числа для ноутбука ориентировочные):

    http_req_duration{name:/api/products/[id]}
    ✓ 'p(50)>=0' p(50)=11.2ms
    ✓ 'p(95)>=0' p(95)=38.4ms
     iterations...............: 15000  249.9/s
 calls | mean_ms
-------+---------
 15000 |    0.55

Как читать вывод: p95 38 мс, и все 15 000 карточек пошли в базу (число вызовов равно числу запросов: calls = iterations). Средний запрос базы стоит 0,55 мс, но 250 раз в секунду это заметный процессор.

1.2. Включаем кэш

Гипотеза: «Если включить кэш с TTL 60 с, то при 80% запросов к 200 горячим товарам hit ratio будет около 84%, вызовов в базу станет примерно 40 в секунду, p95 упадёт с 38 до 11 мс. Если hit ratio окажется ниже 50% или p95 не упадёт ниже 25 мс, гипотеза неверна». Одно изменение:

./set-env.sh CACHE_ENABLED=1
psqlshop -c "SELECT pg_stat_statements_reset();"
./run.sh cache-on SCENARIO=product RATE=250 DURATION=60s
P=~/perf-lab/scripts/promq.sh
$P 'sum(increase(shop_cache_requests_total{result="hit"}[1m])) / sum(increase(shop_cache_requests_total[1m]))'
psqlshop -c "SELECT calls FROM pg_stat_statements WHERE query LIKE 'SELECT id, name, price, category_id, stock FROM products WHERE id%'"

Разбор: increase(...[1m]) показывает, насколько вырос счётчик за последнюю минуту (то есть за прогон); отношение попаданий к сумме даёт hit ratio. Результат:

  0.84
 calls
-------
  2380

Как читать вывод: hit ratio 0,84, как предсказано. В базу дошло 2 380 запросов за минуту (около 40 в секунду) вместо 15 000, то есть в шесть раз меньше. p95 из отчёта k6 около 11 мс.

По всем трём показателям кэш выигрывает. Заметнее всего разгрузка базы (шесть раз): именно это даёт запас на случай роста нагрузки.

Типичные ошибки: hit ratio около нуля значит, что CACHE_ENABLED не подхватился (docker compose exec shop env | grep CACHE); hit ratio около 100% в тесте значит, что тест просит всего несколько товаров (в bn.js должно быть 80/20); NaN значит, что нет запросов за окно, значит, прогон уже закончился: смотри increase сразу после него.

1.3. Ловушка: данные поменялись мимо приложения

Кэш включён и прогрет. Проверь свежесть:

curl -s localhost:8000/api/products/1 | jq '{id, price}'
psqlshop -c "UPDATE products SET price = 1 WHERE id = 1;"
curl -s localhost:8000/api/products/1 | jq '{id, price}'
sleep 61
curl -s localhost:8000/api/products/1 | jq '{id, price}'
{"id": 1, "price": "137.00"}
{"id": 1, "price": "137.00"}
{"id": 1, "price": "1.00"}

Как читать вывод: после правки в базе второй запрос вернул старую цену 137: ответ взят из кэша. Только после TTL (60 секунд) пришла новая цена. Запиши это как риск: «цена в карточке может быть неактуальна до 60 с после ручной правки в БД». Верни цену: psqlshop -c "UPDATE products SET price = 137 WHERE id = 1;" (исходное значение возьми из первого вывода).

Часть 2. Внешняя зависимость

Кэш оставь включённым (он снижает нагрузку на базу, но не защитит заказ). Подготовь заглушку оплаты: смотри текущие настройки.

curl -s localhost:8001/admin/config
{"delay_ms":50.0,"fail_rate":0.0}

Как читать вывод: заглушка отвечает за 50 мс в среднем (с разбросом ±20%), и ошибок ноль. Менять её настройки можно на лету командой curl -X POST localhost:8001/admin/config -H 'Content-Type: application/json' -d '{"delay_ms": 2000}': в теле можно указать delay_ms, fail_rate или оба.

2.1. Базовая линия оформления заказов

cd ~/perf-lab/11-bottlenecks
./run.sh pay-base SCENARIO=mix RATE=10 DURATION=60s

Ожидаемо: p95 около 0,11 с, ошибок нет, POST /api/orders p95 около 0,08 с.

2.2. Оплата замедлилась

Гипотеза: «Если оплата ответит за 2 секунды, то соединение пула будет удерживаться на 2 секунды при пуле 15 (максимум 7,5 заказа в секунду). При 10 визитах в секунду очередь за пулом вырастет, и все маршруты станут медленными и начнут получать 503, хотя процессор базы останется свободным. Если каталог останется быстрым, гипотеза неверна».

curl -s -X POST localhost:8001/admin/config -H 'Content-Type: application/json' -d '{"delay_ms": 2000}'
./run.sh pay-slow SCENARIO=mix RATE=10 DURATION=60s

Во время прогона снимай:

P=~/perf-lab/scripts/promq.sh; S='container_label_com_docker_compose_service'
$P "shop_db_pool_waiting"
$P "sum(rate(container_cpu_usage_seconds_total{$S=\"postgres\"}[30s]))"
$P 'histogram_quantile(0.95, sum by (le) (rate(shop_payment_duration_seconds_bucket[30s])))'
  14
  0.07
  2.4

Как читать вывод: в очереди за соединением 14 запросов (первая строка), при этом процессор postgres на 7% (вторая): база не занята, значит, соединения удерживаются не работой базы, p95 оплаты 2,4 с (третья) называет виновника: зависимость. По дереву из урока 11.1 это не лист «запросы к БД», а лист «зависимость». Отчёт k6 покажет p95 около 5 с у всех маршрутов и около 35-45% ошибок 503. Таким образом гипотеза подтверждена, и это пример каскадного отказа.

Типичные ошибки: каталог остался быстрым значит, что нагрузки не хватило, чтобы занять пул (подними RATE до 12-15) или оплата не получила настройку (curl -s localhost:8001/admin/config); ошибки 502 вместо 503 значит, что оплата возвращает ошибку, а не медленно отвечает (смотри fail_rate).

2.3. Шторм ретраев

Теперь превратим оплату в недоступную с таймаутом 1 секунда и стандартными тремя ретраями. Сначала гипотеза: «При таймауте 1 с и задержке 2 с каждая попытка кончается таймаутом. Каждый заказ делает 4 попытки, значит, число обращений к оплате вчетверо выше числа заказов; соединение удерживается 4 секунды. Заглушка продолжает считать оплаты, на которые мы уже не ждём». Нагрузку берём 3 заказа в секунду: по закону Литтла соединений занято 3 × 4 = 12 из 15, пул не переполняется. При 5 заказах в секунду нужно было бы 20, и часть заказов получила бы 503 от пула ещё до обращения к оплате, а число попыток вышло бы меньше расчётного.

cd ~/perf-lab/11-bottlenecks
./set-env.sh PAYMENT_TIMEOUT=1
./run.sh pay-storm SCENARIO=checkout RATE=3 DURATION=60s
P=~/perf-lab/scripts/promq.sh
$P 'sum by (result) (increase(shop_payment_requests_total[1m]))'
$P 'sum(increase(shop_orders_created_total[1m]))'
{result="timeout"}  718
  0

Как читать вывод: за минуту оплата получила 718 попыток (все с результатом timeout) при ноль созданных заказов: из 180 заказов (3/с × 60 с) каждый сделал 4 попытки (≈720). Это усиление ×4 в чистом виде. Параллельно в логе заглушки (docker compose logs payment) видны запросы, которые она всё равно обрабатывает: зомби-работа. Заказы отдают 504 «payment timeout».

Теперь проверь, что видит сама оплата. Заглушка тоже отдаёт метрики, и Prometheus их собирает (job="payment"): счётчик payment_requests_total{status} считает всё, что она получила и довела до конца. Сравни его с тем, что насчитал магазин:

$P 'sum by (status) (increase(payment_requests_total[1m]))'
$P 'sum by (result) (increase(shop_payment_requests_total[1m]))'
$P 'sum(increase(shop_orders_created_total[1m]))'
{status="200"}  714
{result="timeout"}  718
  0

Как читать вывод: магазин на своей стороне записал 718 таймаутов и ни одного заказа, а заглушка оплаты на своей стороне успешно провела около 714 платежей (небольшая разница из-за окон измерения и того, что часть запросов ещё не дошла до конца). Заглушка ждала свои 2 секунды и ответила «оплачено», только магазин уже не слушал. С точки зрения настоящей платёжной системы это четыре списания по каждому заказу, которого не существует. Это двойное списание (в нашем случае даже четверное), и оно возникает потому, что POST /pay не идемпотентен: повторный вызов с тем же order_id считается новой оплатой. Идемпотентность (idempotency) значит, что повтор с тем же ключом даёт тот же результат и не меняет ничего второй раз. Поэтому в боевой системе ретраи оплаты делают только с ключом идемпотентности: платёжный сервис по нему узнаёт повтор и не списывает второй раз. В нашем стенде ключа нет, и это ещё одна причина, почему ретраи без паузы и без ключа опасны не только нагрузкой, но и деньгами.

Таблица ориентировочных цифр для сравнения:

Настройки (3 заказа в секунду, 60 с) Попыток к оплате на заказ Заказов создано Результат клиента
задержка 50 мс, retries 3 (норма) 1 все (180) 201
задержка 2000 мс, timeout 10 с 1 все (180), p95 около 2,1 с 201
задержка 2000 мс, timeout 1 с, retries 3 4 (около 720 за минуту) 0 504

2.4. Исправление настройками

Одним изменением за раз. Сначала ретраи (убираем усиление):

./set-env.sh PAYMENT_RETRIES=0
./run.sh pay-noretry SCENARIO=checkout RATE=3 DURATION=60s
$P 'sum by (result) (increase(shop_payment_requests_total[1m]))'

Ожидаемо: попыток около 180 (по одной на заказ), timeout по-прежнему (оплата отвечает 2 секунды при таймауте 1 секунда), заказов 0, но нагрузка на оплату вчетверо ниже, соединение держится 1 секунду вместо 4. Теперь реалистичный таймаут: оплата в норме отвечает за 50 мс, а в деградации за 2 секунды, и мы готовы подождать 3 секунды:

./set-env.sh PAYMENT_TIMEOUT=3
./run.sh pay-fixed SCENARIO=checkout RATE=5 DURATION=60s

Ожидаемо: оплата успевает (2 с < 3 с), заказы создаются (около 5 в секунду), ошибок нет, p95 POST /api/orders около 2,1 с, потому что оплата честно медленная. Сам пул: 5 заказов в секунду × 2 с = 10 соединений занято из 15: запас есть. Но при 8 заказах в секунду он кончится (предел 7,5): именно поэтому правильное долгосрочное решение это вынести вызов оплаты из транзакции, а не крутить таймауты.

Верни оплату в норму: curl -s -X POST localhost:8001/admin/config -H 'Content-Type: application/json' -d '{"delay_ms": 50}' и ./set-env.sh PAYMENT_RETRIES=3 PAYMENT_TIMEOUT=10 (исходные значения), если хочешь сохранить стенд по умолчанию.

Столбик третьего сценария вчетверо выше остальных: это и есть шторм ретраев. Исправление возвращает число попыток к норме, и теперь не только оплата перестаёт задыхаться, но и соединение пула освобождается быстрее.

Часть 3. Вывод по всей теме

Запиши ~/perf-lab/11-bottlenecks/05-cache-dependencies.md:

# 11.5. Кэш и зависимости

## Кэш (product, 250/с, 60 с)
| | без кэша | с кэшем |
| p95, мс | 38 | 11 |
| запросов к БД, 1/с | 250 | 40 |
| hit ratio | - | 0,84 |
Ловушка: цена, правленная в БД, видна в карточке до 60 с. Остаток в кэше тоже устаревает.

## Оплата
Задержка 2 с, пул 15: макс 7,5 заказа/с. mix 10/с: все маршруты p95 ~5 с, 35-45% 503, CPU postgres 7%.
Таймаут 1 с + retries 3: 4 попытки на заказ (около 720 за минуту при 180 заказах, 3/с), заказов 0, заглушка работает впустую.
Исправление: PAYMENT_RETRIES=0, PAYMENT_TIMEOUT=3: заказы идут, одна попытка на заказ.

## Рекомендации
1. Вынести оплату из транзакции (pending → оплата → paid).
2. Ретраи с паузой и джиттером, бюджет, выключатель.
3. Таймаут по нормальному p99 зависимости, идемпотентный ключ.
4. Алерт на pool waiting и p95 оплаты.

Дополни отдельный файл ~/perf-lab/11-bottlenecks/summary.md: таблицу всей темы «проблема, как нашли, исправление, эффект» по пяти урокам. Это основа для портфолио в теме 13.

cd ~/perf-lab
git add 11-bottlenecks results/11-cache-*.txt results/11-pay-*.txt
git commit -m "11.5: кэш, оплата, таймауты и ретраи"
git push

Сначала сам объясни, почему после FLUSHALL вырос shop_db_pool_waiting, и предложи защиту. Потом спроси нейросеть про прогрев и jitter и проверь на стенде: повтори сброс с CACHE_ENABLED=1 и смотри, как быстро растёт hit ratio.

Сломай и почини

Поломка: проверь, как ведёт себя кэш при внезапной «очистке» Redis. Включён кэш, идёт нагрузка 250 запросов в секунду. Сбрось весь кэш и посмотри на базу:

cd ~/perf-lab/11-bottlenecks
./run.sh cache-flush SCENARIO=product RATE=250 DURATION=90s &
sleep 30
cd ~/learning/load-tester/project/shop && docker compose exec -T redis redis-cli FLUSHALL

Что произойдёт: hit ratio мгновенно падает до нуля и медленно (десятки секунд) восстанавливается, в базу в первые секунды летит почти полная нагрузка (до 250 запросов в секунду вместо 40), p95 на несколько секунд подпрыгивает. FLUSHALL стирает и корзины (они тоже в Redis), поэтому на боевой системе так делать нельзя; здесь мы лишь воспроизводим перезапуск Redis.

Диагностика: график rate(shop_cache_requests_total{result="miss"}[15s]) показывает всплеск промахов в момент очистки, и параллельно растёт shop_db_pool_waiting. Если база не рассчитана на полную нагрузку без кэша, пул переполнится. Если рассчитана, всплеск проглатывается.

Решение: (1) убедиться, что база держит нагрузку и без кэша хотя бы на время прогрева (мы показали это в уроке 11.3, предел около 60 визитов в секунду); (2) прогревать кэш при старте; (3) случайный разброс TTL (jitter), чтобы ключи не истекали одновременно; (4) для горячих ключей защита от stampede (блокировка на ключ). Для отчёта: «кэш это оптимизация, а не часть ёмкости: ёмкость считается без него».

ИИ в помощь

Нейросеть хорошо перечисляет защиты кэша и ретраев, но часто даёт общие советы без твоих цифр. Общие правила: ИИ-помощник.

Задача: посчитать эффект кэша и объяснить hit ratio.

Сервис «Магазин», карточка товара. Нагрузка 250 запросов в секунду, hit ratio кэша <вставь>%, запрос в базу
занимает <вставь> мс. Объясни простыми словами и с числами, сколько запросов реально доходит до базы с кэшем
и без него, и почему ёмкость нужно считать без кэша. Затем перечисли три риска кэша (stampede, одновременное
истечение TTL, холодный старт) и по одной защите от каждого.

Проверь ответ: сверь расчёт со своим прогоном: запросы в базу равны нагрузке, умноженной на долю промахов; метрики shop_cache_requests_total{result="miss"} и hit. Типичная ошибка: нейросеть советует увеличить TTL до бесконечности без инвалидации и забывает про устаревшие данные.

Задача: разобрать каскад ретраев при медленной оплате.

Сервис shop вызывает payment (заглушка оплаты, порт 8001) внутри транзакции базы. Настройки:
PAYMENT_TIMEOUT=<вставь>, PAYMENT_RETRIES=<вставь>, ретраи без паузы. Оплата замедлилась до <вставь> мс.
Покажи по шагам, как растёт число запросов к payment и время удержания соединения пула, почему падает весь
сервис, и как исправить: вынести вызов из транзакции, поставить паузу с экспоненциальным ростом и случайным разбросом,
таймаут, лимит ретраев.

Проверь ответ: воспроизведи через /admin/config у payment и сравни shop_payment_requests_total и shop_db_pool_waiting. Типичная ошибка: совет «просто увеличить пул», который не лечит причину, и ретраи для неидемпотентных операций без защиты от двойного заказа.

Словарик урока

Термин Простыми словами
Кэш Быстрое временное хранилище готовых ответов
Hit / miss Попадание в кэш (нашли) и промах (не нашли, пошли в базу)
Hit ratio Доля попаданий: hits / (hits + misses)
TTL Время жизни записи в кэше, после него она удаляется
Инвалидация Явное удаление записи из кэша при изменении данных
Stampede (давка) Много запросов одновременно промахиваются по истёкшему ключу и бьют по базе
Холодный старт Пустой кэш после перезапуска: всё идёт в базу
Внешняя зависимость Сервис, который ты вызываешь по сети и не контролируешь
Каскадный отказ Сбой в одном месте растёт на соседние части системы
Таймаут Предел ожидания ответа, после него вызов прерывают
Ретрай Повторная попытка вызова после неудачи
Шторм ретраев Повторы усиливают нагрузку на зависимость и мешают ей восстановиться
Backoff и jitter Растущая пауза между попытками со случайным разбросом
Retry budget Ограничение доли повторов от общего числа запросов
Circuit breaker Выключатель: при высокой доле ошибок вызовы временно прекращаются
Идемпотентность Повтор операции с тем же ключом не меняет результат второй раз
Зомби-работа Операция, на результат которой клиент уже не ждёт, но сервис её доделывает

Вопросы с собеседований

Раздел для повторения: ответь вслух, потом открой ответ.

1. [junior] [часто] Что такое hit ratio и от чего он зависит?

Ответ

Доля запросов, обслуженных из кэша. Зависит от распределения запросов (есть ли горячие ключи), TTL и размера кэша. При высоком hit ratio база разгружена, при низком кэш почти бесполезен. Ориентир по базе: видит долю (1 − hit ratio) исходной нагрузки.

Что хотят услышать: определение, зависимость от распределения и TTL, расчёт нагрузки на базу.

Красный флаг: «кэш всегда ускоряет».

2. [junior] [часто] Какие проблемы создаёт кэш?

Ответ

Устаревшие данные (до TTL или до инвалидации), гонки между чтением и обновлением, stampede при истечении горячего ключа, холодный старт после очистки, дополнительная сложность. Нагрузочный тест кэша проверяет не только скорость, но и свежесть данных.

Что хотят услышать: минимум три проблемы и инвалидацию.

Красный флаг: «кэш нельзя сломать».

3. [junior] [часто] Что такое таймаут и зачем он нужен?

Ответ

Предел ожидания ответа от внешнего сервиса. Без него зависший вызов блокирует потоки и соединения навсегда. Значение выбирают по нормальному времени ответа зависимости (примерно в 3-5 раз выше p99) и терпению пользователя.

Что хотят услышать: защита ресурсов, как выбрать значение.

Красный флаг: «таймаут не нужен, пусть ждёт».

4. [junior] Что такое ретрай и когда он вреден?

Ответ

Повторная попытка после неудачи. Помогает при случайных сбоях. Вреден, когда зависимость перегружена или лежит: повторы добавляют нагрузку, мешают ей восстановиться, удерживают ресурсы и бьют синхронной волной.

Что хотят услышать: усиление нагрузки, пример ×4.

Красный флаг: «ставим пять ретраев, чтобы надёжнее».

5. [middle] Почему медленная оплата делает медленным каталог?

Ответ

Вызов оплаты внутри транзакции удерживает соединение из пула. Когда оплата замедляется, все соединения оказываются заняты ожиданием, а каталог берёт соединение из того же пула и встаёт в очередь. Это каскадный отказ. Признак: процессор базы свободен, pool waiting высок. Исправление: вынести вызов из транзакции, разделить пулы, ввести таймауты и выключатель.

Что хотят услышать: общий дефицитный ресурс, признак по метрикам, три приёма лечения.

Красный флаг: «надо раздуть пул».

6. [middle] Как сделать ретраи безопасными?

Ответ

Экспоненциальная пауза с джиттером, ограничение числа попыток и бюджет повторов, выключатель при высокой доле ошибок, идемпотентные ключи для операций с побочными эффектами, ретраить только временные ошибки (таймауты, часть 5xx, 429 с ожиданием по Retry-After), не 4xx из-за ошибки клиента.

Что хотят услышать: backoff с jitter, budget, circuit breaker, идемпотентность.

Красный флаг: ретраи без паузы.

7. [middle] Как нагрузочным тестом проверить устойчивость к сбою зависимости?

Ответ

Держать обычную нагрузку, замедлить или сломать зависимость (заглушка, чаос-инструмент), снять метрики: пул, ошибки, задержки по маршрутам, число попыток к зависимости. Проверить, что сервис деградирует предсказуемо (быстрые ошибки, запасной ответ), не теряет соседние маршруты и восстанавливается после снятия сбоя.

Что хотят услышать: управляемый сбой, метрики, восстановление.

Красный флаг: тест только «зелёного» пути.

8. [middle] Почему нельзя полагаться на кэш при расчёте ёмкости?

Ответ

Кэш может оказаться пустым (перезапуск, деплой, сбой Redis), и тогда на базу придёт полная нагрузка. Ёмкость считают без кэша или хотя бы проверяют, что база выдерживает холодный старт; кэш считают запасом производительности.

Что хотят услышать: холодный старт, база выдерживает без кэша.

Красный флаг: «у нас hit ratio 95%, база нам не нужна».

9. [на скорость] Как посчитать, сколько запросов видит база при hit ratio 90% и нагрузке 1000/с?

Ответ

100 в секунду (10% промахов).

10. [на скорость] Во сколько раз ретраи (3 повтора) могут увеличить нагрузку на зависимость?

Ответ

В четыре раза: одна основная попытка и три повтора.

11. [на скорость] Что такое TTL?

Ответ

Время жизни записи в кэше: по его истечении запись удаляется.

12. [на скорость] Что такое stampede?

Ответ

Одновременный промах множества запросов по истёкшему популярному ключу: все идут в базу за одним и тем же.

Проверено на версиях

Redis 8.10, PostgreSQL 18.6, Python 3.14 (httpx в образе shop), Prometheus 3.15, k6 2.3. Заглушка оплаты управляется через /admin/config. Числа смоделированы по коду стенда (задержка оплаты, размер пула, распределение запросов) и на твоём железе будут отличаться; соотношения (hit ratio около 0,8, усиление ×4, каскад на все маршруты) должны совпасть. Октябрь 2026.

Итог урока: ты умеешь

  • Включать кэш и измерять пользу: hit ratio, запросы к базе, p95.
  • Знать и проверять ловушки кэша: устаревшие данные, инвалидация, stampede, холодный старт.
  • Определять по метрикам, что причина во внешней зависимости, а не в базе.
  • Объяснять, почему вызов зависимости внутри транзакции создаёт каскадный отказ.
  • Считать усиление нагрузки от ретраев и подбирать таймауты и число повторов.
  • Называть безопасные приёмы: backoff с jitter, бюджет, выключатель, идемпотентность, вынос вызова из транзакции.
  • Свести расследования всей темы в таблицу «проблема, причина, исправление, эффект».

Дальше: тема 12, урок 12.1: отчёт о нагрузочном тестировании, где расследования превращаются в документ для команды.

Проверь себя

Короткий тест по уроку: 5 вопросов из банка в 30. Засчитывается только полностью правильный ответ, порог 60%. Каждая новая попытка даёт другие вопросы, пока банк не закончится. Ответы видны после проверки.

Тест работает с включённым JavaScript.

тема 11 урок 11.5 2.5 ч курс 0/0 ← → уроки