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

На стенде 200 параллельных запросов к одному редко меняющемуся отчёту могут одновременно промахнуться по кэшу и запустить 200 одинаковых запросов в PostgreSQL. Если каждый из них занимает соединение на несколько секунд, пул быстро заканчивается, приложение начинает отдавать таймауты, а кэш уже не спасает. Ниже — разбор воспроизводимого инцидента для стека Django 5.2, Redis 7 и PostgreSQL 16: как безобидное кэширование превращается в отказ системы и что измерить перед исправлением.
Оптимизация, которая создала общий узкий участок
Исходная проблема выглядела знакомо: endpoint формировал отчёт из нескольких таблиц, выполнял агрегации и отвечал дольше допустимого. Команда добавила кэш Redis с TTL в несколько минут. Первый запрос строил отчёт и сохранял результат, следующие получали готовые данные.
На обычном трафике схема работала. Ошибка проявилась после истечения TTL. Все запросы к одному ключу увидели состояние «значения нет» и начали строить отчёт параллельно. Это называют cache stampede — лавиной промахов кэша, когда множество клиентов одновременно обращается к источнику за одним и тем же значением*.
Кэш ускоряет чтение только после успешного заполнения. В момент промаха он способен умножить нагрузку на источник во столько раз, сколько запросов одновременно ждут один ключ.
Как выглядит цепочка отказа
- TTL заканчивается. Запись отчёта исчезает из Redis почти одновременно для всех клиентов.
- Запросы проходят проверку. Каждый worker получает cache miss и считает, что именно он должен построить результат.
- База получает дубликаты работы. Одинаковая агрегация запускается десятки или сотни раз.
- Заканчиваются соединения. Новые запросы ждут свободное соединение из пула, а затем получают таймаут.
- Сбой распространяется дальше. Таймауты занимают workers приложения, растёт очередь, а повторные запросы клиентов усиливают нагрузку.
Проблема не в самом Redis и не в том, что SQL обязательно написан плохо. У системы не было правила, кто имеет право заполнять конкретный ключ, пока остальные ждут или используют старое значение.
Сноска. Cache stampede, или «лавина кэша», — одновременный промах большого числа запросов по одному кэш-ключу. В литературе также встречаются термины thundering herd и dogpile effect; детали реализации у них могут отличаться.
Что происходило внутри системы
Рассмотрим временную шкалу с одним ключом report:42. В момент T0 его TTL истекает. В интервале между T0 и завершением нового SQL-запроса Redis пуст. Все входящие запросы видят одинаковое состояние.
| Участок | Нормальный запрос | После одновременного истечения TTL |
|---|---|---|
| Redis | Один промах, затем попадания | Много промахов по одному ключу |
| Приложение | Один worker считает отчёт | Несколько workers выполняют одинаковую работу |
| PostgreSQL | Один тяжёлый запрос | Очередь одинаковых запросов |
| Пул соединений | Короткое занятие | Ожидание, таймауты и исчерпание доступных соединений |
| Пользователь | Быстрый ответ из кэша | 502, 504 или ошибка приложения |
Особенно опасен ключ с высокой популярностью: отчёт, главная страница, список остатков или карточка крупного клиента. Уникальные ключи распределяют нагрузку, а один популярный ключ создаёт синхронный удар по одному месту.
Почему увеличение пула не лечит причину
Первый инстинкт при таймаутах — увеличить число соединений к PostgreSQL. Это может временно убрать очередь на стороне приложения, но не уменьшает количество одинаковых запросов. Наоборот, база получает больше одновременно работающих операций, начинает конкурировать за CPU, память, дисковые чтения и блокировки.
У PostgreSQL есть собственный предел подключений, а у приложения — свой пул. Эти значения нельзя рассматривать отдельно. Если приложение открывает больше соединений, чем база и её хост способны обслужить, отказ просто перемещается на другой уровень.
Как подтвердить именно этот сценарий
До изменения кода соберите временной ряд хотя бы за один полный цикл TTL. Ищите не только среднее время ответа: среднее легко скрывает короткий пик, во время которого система уже теряет запросы.
- число обращений к каждому ключу Redis и долю hit/miss;
- время заполнения кэша отдельно от времени выдачи готового значения;
- число одновременно выполняющихся одинаковых SQL-запросов;
- время ожидания соединения из пула приложения;
- активные и ожидающие подключения PostgreSQL;
- частоту ответов 5xx и таймаутов в тот же момент, когда истекает TTL.
Ключевой признак — всплеск одинаковых запросов сразу после истечения одной записи. Если в этот момент растут только медленные SQL, а Redis показывает серию промахов по одному ключу, гипотеза подтверждается.
Для проверки не нужно сразу повторять эксперимент на продакшене. Возьмите обезличенную копию данных, ограничьте доступное число соединений и создайте нагрузку на один ключ. Фиксируйте время первого промаха, количество вычислений и время восстановления. Не переносите результаты теста на другую машину без оговорки: важны объём данных, план запроса, CPU, диск, сеть и настройки пула.
Как исправлять: от быстрого патча к устойчивой схеме
1. Развести время истечения ключей
Случайный небольшой разброс TTL уменьшает вероятность синхронного удаления большого набора ключей. Это полезно для массового кэширования, но не решает проблему одного горячего ключа: его значение всё равно может закончиться одновременно для множества запросов.
2. Оставлять старое значение на время пересчёта
Для отчётов и каталогов, где допустима небольшая устарелость, можно разделить срок свежести и срок хранения. Пока новый результат строится, пользователи получают предыдущую версию. После успешного расчёта она заменяется новой.
Такой подход защищает источник, но требует явно определить допустимый возраст данных. Для баланса на складе или доступного лимита устаревший результат может быть опаснее медленного ответа.
3. Выбрать одного владельца заполнения
Для каждого ключа нужен механизм блокировки с коротким временем жизни. Первый запрос получает право строить значение, остальные не запускают тот же SQL. Они могут подождать небольшой интервал, отдать старое значение или получить контролируемый ответ.
Блокировка должна иметь срок истечения. Если worker упадёт после захвата блокировки, ключ не должен остаться заблокированным навсегда. Освобождать блокировку нужно только владельцу: без уникального идентификатора процесса один worker может удалить блокировку, которую уже успел перенять другой.
4. Перенести расчёт из пользовательского запроса
Если отчёт тяжёлый и меняется редко, надёжнее считать его по расписанию или через очередь фоновых задач, а HTTP-запросу отдавать последний успешно собранный результат. Цена решения — дополнительный компонент и задержка обновления. Выигрыш — предсказуемая нагрузка на базу.
| Подход | Что выигрываете | Чем платите | Когда применять |
|---|---|---|---|
| Случайный TTL | Простая защита от массового совпадения сроков | Не защищает один горячий ключ | Много независимых ключей |
| Stale-while-revalidate | Быстрый ответ и отсутствие лавины | Пользователь может увидеть старые данные | Каталоги, отчёты, публичные страницы |
| Блокировка заполнения | Один расчёт на ключ | Нужно обработать падение владельца и ожидание | Ценный, но дорогой результат |
| Фоновый расчёт | Предсказуемая нагрузка и контроль расписания | Очередь, мониторинг и задержка обновления | Большие отчёты и пакетные данные |
Где подход ломается
Даже правильная блокировка не делает расчёт бесплатным. Она лишь не даёт нескольким запросам выполнять его одновременно. Если один расчёт длится дольше таймаута блокировки, второй worker может решить, что первый исчез, и начать дублировать работу. Время жизни блокировки должно превышать ожидаемую длительность операции с запасом, а необычно долгие расчёты нужно отдельно измерять.
Есть и другие граничные случаи:
- Падение Redis. Если кэш используется как обязательное хранилище, отказ Redis превращается в отказ приложения. Для кэша обычно нужен деградированный путь, который не создаёт лавину запросов к базе.
- Слишком короткий TTL. Запросы постоянно пересчитывают значение, даже если лавина не успевает проявиться явно.
- Слишком длинный TTL. Отчёт становится быстрым, но данные устаревают дольше допустимого бизнесом.
- Ключ без версии данных. После изменения формата результата старое значение может быть несовместимо с новым кодом. Версию схемы стоит включать в имя ключа.
- Разные процессы считают по-разному. Если часть параметров не входит в ключ, пользователи получают чужой или неверный результат.
Инвалидация кэша после изменения исходных данных — отдельная тема. Здесь важен только её предел: даже идеальная инвалидация не предотвращает одновременный промах, если много клиентов пришли после удаления записи.
Что делать дальше
Начните с одного самого дорогого ключа и добавьте наблюдаемость: hit/miss, возраст значения, время генерации, число владельцев заполнения и время ожидания блокировки. Затем проведите нагрузочный тест на границе TTL, а не только на уже прогретом кэше.
В продакшене контролируйте не только среднее время ответа, но и p95 или p99, число одинаковых запросов к PostgreSQL, ожидание соединений и долю stale-ответов. После внедрения защиты сравните два сценария: обычное попадание в кэш и одновременный промах. Цель оптимизации — не самый быстрый удачный запрос, а предсказуемое поведение в момент, когда кэш пуст.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: Django 5.2: кэширование, PostgreSQL 16: параметры подключения
Частые вопросы
Можно ли просто увеличить TTL, чтобы система больше не падала?
Как понять, что проблема именно в cache stampede?
Что делать, если результат нельзя отдавать устаревшим?
Опасно ли увеличивать max_connections в PostgreSQL?
Что будет, если worker упадёт, удерживая блокировку?
Работает ли эта схема с Django 5.2 и Redis 7?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.