Кеш Symfony под нагрузкой: что ломается первым
Прогрев контейнера на деплое, тегированная инвалидация и dogpile под пиковым трафиком — где кеш Symfony 6.x/7.x подводит первым и как это ловить.

Симптом: деплой, и первые сотни запросов идут в 3–5 раз дольше
Типичная картина на проекте Symfony 7.x: выкатили релиз, контейнер пересобрался, и первые 200–400 запросов после старта отдаются заметно медленнее обычного. В APM видно всплеск времени ответа на 2–5 секунд, потом всё выравнивается. Причина не в приложении, а в том, что кеш пуст, а несколько воркеров одновременно бросились его наполнять — тем же тяжёлым запросом к базе или внешнему API.
Разберём три места, где кеш Symfony ломается под нагрузкой раньше всего: холодный контейнер после деплоя, гонка при истечении ключа (dogpile) и инвалидация по тегам, которая под пулом соединений ведёт себя не так, как на локалке. Настройку самих кеш-пулов и выбор бэкенда (Redis против файлового) здесь не разбираем — это отдельная тема.
Слой первый: кеш контейнера и что происходит на первом запросе
Symfony кеширует не только ваши данные, но и собственный DI-контейнер, маршруты, аннотации/атрибуты, шаблоны Twig. В APP_ENV=prod при пустой директории var/cache/prod первый запрос запускает компиляцию контейнера — это дорого и происходит синхронно внутри обработки запроса.
Отсюда правило: кеш системного слоя нужно прогревать на этапе деплоя, до того как балансировщик пустит трафик на инстанс. Делает это команда:
php bin/console cache:clear --env=prod --no-debug
php bin/console cache:warmup --env=prod --no-debugcache:clear начиная с Symfony 4.4 сам вызывает прогреватели, но явный cache:warmup оставляют в пайплайне, когда сборка идёт в один каталог, а разворачивается в другой. Проверка: после warmup в var/cache/prod появляются скомпилированные PHP-файлы контейнера (класс вида *ProdContainer.php) и файлы маршрутов. Если каталог пуст — первый живой запрос будет платить за компиляцию.
Холодный контейнер бьёт не по одному запросу, а по всем, что пришли за время его компиляции. Под 300 rps «первый медленный запрос» превращается в сотни таймаутов.
OPcache и preload
Скомпилированный контейнер — это PHP-код, который читает OPcache. После деплоя OPcache надо сбрасывать (или перезапускать PHP-FPM), иначе воркеры продолжат исполнять старые файлы. Symfony умеет генерировать preload-файл (config/preload.php в стандартной структуре), который через opcache.preload загружает классы в память мастер-процесса FPM. Точный список того, что попадает в preload, и путь к файлу сверяйте с документацией вашей версии — механизм заметно менялся между 5.x и 6.x.
Слой второй: dogpile — когда ключ истёк, а запросов много
Прикладной кеш через Symfony\Contracts\Cache\CacheInterface использует callback-модель. Наивная реализация «проверил — если пусто, посчитал, положил» под нагрузкой даёт классический промах: ключ истёк, десять воркеров одновременно увидели пустоту, десять раз выполнили тяжёлый расчёт и десять раз записали результат. База в этот момент получает всплеск идентичных запросов.
Контракт CacheInterface::get() закрывает это встроенно — через probabilistic early expiration. Пятый аргумент callback, ItemInterface $item, и параметр $beta у get() управляют тем, что один воркер пересчитает значение чуть раньше срока, пока остальные ещё отдают старое:
<?php
// Symfony 6.x / 7.x
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
class PriceService
{
public function __construct(private CacheInterface $cache) {}
public function getPriceTable(int $categoryId): array
{
return $this->cache->get(
'price_table_' . $categoryId,
function (ItemInterface $item): array {
// держим 300 секунд
$item->expiresAfter(300);
// тяжёлый расчёт: запрос к базе + агрегация
return $this->buildPriceTable();
},
// beta > 0 включает early expiration;
// 1.0 — значение по умолчанию, INF — пересчитать немедленно
1.0
);
}
}Когда beta = 1.0, вероятность досрочного пересчёта растёт по мере приближения к сроку истечения. Значение INF заставляет пересчитать сейчас (полезно для ручного прогрева), 0 — отключает механизм. Это не блокировка: под очень высоким rps досрочный пересчёт всё равно могут запустить два воркера одновременно. Для жёсткой гарантии единственного пересчёта используйте LockRegistry — компонент Cache подключает его к адаптерам автоматически, но поведение зависит от настроенного лок-стора.
Проверка, что dogpile действительно устранён
Смотрите не среднее, а хвост. Возьмите ключ с TTL 300 секунд и нагрузите эндпоинт постоянным rps. На графике запросов к базе всплески должны стать редкими одиночными пиками вместо частокола каждые пять минут. Если пики остались синхронными с TTL — early expiration не включён (проверьте $beta) либо разные воркеры пишут в разные бэкенды (несогласованный пул).
Слой третий: инвалидация по тегам и её цена
Тегируемый кеш (TagAwareAdapter) позволяет сбросить группу ключей одним вызовом invalidateTags(['product_42']), не зная поимённо все производные ключи. Удобно, но у этого есть цена, которая проявляется под нагрузкой.
- Тег — это отдельная запись в бэкенде. Инвалидация не удаляет ключи физически, а помечает версию тега как устаревшую. При чтении адаптер сверяет версию тега — это дополнительное обращение к хранилищу на каждый тегированный ключ.
- Массовая инвалидация бьёт по латентности чтений. Сбросили популярный тег во время пика — и следующие чтения всех связанных ключей идут в промах одновременно. Получаете тот же dogpile, только вызванный вручную.
- С Redis-бэкендом инвалидация тегов делает лишние round-trip. На локальном Redis это микросекунды, на удалённом инстансе с сетевой задержкой 1–2 мс и сотнями ключей — заметный оверхед.
| Стратегия | Что выигрываете | Чем платите |
|---|---|---|
| TTL без тегов | Минимум обращений к бэкенду, предсказуемо | Данные живут до истечения TTL, точечно не сбросить |
| Теги (TagAwareAdapter) | Точечная инвалидация группы ключей | Лишние чтения версий тегов, риск массового промаха |
| Версионирование ключа в имени | Старая версия просто перестаёт запрашиваться, без сброса | Нужно менять версию во всех местах генерации ключа |
Практичный компромисс под нагрузкой: тегами инвалидируйте редко меняющиеся тяжёлые агрегаты, а горячие часто меняющиеся данные держите на коротком TTL без тегов. Массовый сброс тега планируйте на низкий трафик или прогревайте связанные ключи сразу после инвалидации.
Где всё это ломается первым — по порядку
- Холодный контейнер после деплоя — если warmup не в пайплайне или OPcache не сброшен. Ловится всплеском латентности сразу после релиза.
- Dogpile на истечении популярного ключа — если
get()вызван без$betaили через прямойgetItem/saveбез early expiration. Ловится синхронными пиками к базе с периодом TTL. - Инвалидация тега в пик — массовый ручной сброс роняет чтения. Ловится всплеском промахов сразу после админского действия «сбросить кеш».
Что мерить и что настроить дальше
Наблюдаемость важнее любого из этих приёмов. Что смотреть в проде:
- Hit ratio по пулам. Компонент Cache отдаёт метрики в профайлер; в проде выгружайте их в свой мониторинг. Падение hit ratio после деплоя — сигнал, что прогрев не сработал.
- Латентность самого бэкенда кеша. Redis под нагрузкой сам становится узким местом — следите за временем команд и сетевыми round-trip.
- Корреляция всплесков к базе с TTL ключей. Периодичность, совпадающая с TTL, почти всегда означает нерешённый dogpile.
Следующий шаг: вынесите прогрев критичных прикладных ключей в отдельную консольную команду и запускайте её в деплой-пайплайне после cache:warmup, но до включения инстанса в балансировку. Тогда первый живой запрос попадёт в уже тёплый кеш обоих слоёв — и системного, и прикладного.
Источники: Symfony Cache Component — официальная документация, Cache Invalidation (Tags) — Symfony Docs
Частые вопросы
У меня Symfony 5.4, а не 7.x — приёмы применимы?
Early expiration настроил, а пики к базе всё равно совпадают с TTL. Что не так?
Обязательно ли делать cache:warmup, если cache:clear и так прогревает?
Грозит ли ручной сброс кеша через админку падением в проде?
TagAwareAdapter или версионирование ключа в имени — что выбрать?
Нужно ли сбрасывать OPcache после деплоя вручную?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.