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

Типичная ситуация: страница каталога рендерится 400 мс, потому что на каждый запрос тянется тяжёлый JOIN с подсчётом остатков. Оборачиваете вызов в кеш — время падает до 30 мс. А через неделю выясняется, что после смены цены пользователи два часа видят старую цифру, а под пиком запросов база всё равно ложится, потому что кеш протух у всех одновременно. Разберём, как класть данные в кеш в Symfony осознанно: чем платит каждый подход и где он ломается. Статья про Symfony 6.4 / 7.x и компонент symfony/cache.
Инвалидация по событиям (сброс кеша при изменении сущности через Doctrine-листенеры) — отдельная большая тема, здесь её не разворачиваем. Фокус — на самом чтении и записи в кеш и на защите от типичных промахов.
Базовый инструмент: CacheInterface и метод get
В Symfony есть два уровня API. Низкий — PSR-6 (CacheItemPoolInterface) с ручным получением элемента, проверкой isHit() и сохранением. Высокий — контрактный Symfony\Contracts\Cache\CacheInterface с методом get(), который берёт на себя проверку попадания и вычисление значения через колбэк. Для прикладного кода почти всегда нужен второй.
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
class CatalogService
{
public function __construct(private CacheInterface $cache) {}
public function getFeaturedProducts(): array
{
// Ключ должен быть уникальным для набора данных
return $this->cache->get('catalog.featured', function (ItemInterface $item) {
$item->expiresAfter(300); // 5 минут
// Тяжёлый запрос выполнится только при промахе
return $this->productRepository->findFeaturedWithStock();
});
}
}
Колбэк вызывается только при промахе. Пока значение живо, метод возвращает его без обращения к базе. Обратите внимание: аргумент типа CacheInterface автоматически внедряется в сервис через autowiring и указывает на пул cache.app.
Почему нельзя просто вернуть из колбэка что попало
В кеш попадает всё, что возвращает колбэк, — включая null и пустой массив. Если запрос вернул пусто из-за временного сбоя, вы закешируете пустоту на пять минут. Для критичных данных проверяйте результат до записи и в крайнем случае бросайте исключение внутри колбэка — тогда значение не сохранится.
Раздельные пулы вместо одной свалки
Складывать в один пул горячий каталог и редко меняющиеся справочники — ошибка. У них разное время жизни и разная цена промаха. Symfony позволяет объявить несколько пулов с разными адаптерами и настройками TTL по умолчанию.
# config/packages/cache.yaml
framework:
cache:
app: cache.adapter.redis
default_redis_provider: '%env(REDIS_URL)%'
pools:
catalog.cache:
adapter: cache.adapter.redis
default_lifetime: 300
reference.cache:
adapter: cache.adapter.redis
default_lifetime: 86400
После этого в контейнере появляются сервисы catalog.cache и reference.cache. Внедрять их удобно через именованный аргумент autowiring — по имени пула. Разделение даёт две вещи: осмысленный TTL по умолчанию для каждого типа данных и возможность чистить пул целиком, не задевая соседний.
Один общий пул на всё приложение — это как одна папка «Разное» на весь рабочий стол. Работает, пока не понадобится что-то найти или удалить выборочно.
Три подхода к сбросу устаревших данных
Вернёмся к цене, которая висит два часа. TTL сам по себе не решает проблему: слишком короткий — теряете смысл кеша, слишком длинный — показываете устаревшее. Есть варианты точечного сброса.
| Подход | Что выигрываете | Чем платите |
|---|---|---|
| Только TTL | Ноль кода на инвалидацию | Данные устаревают на время жизни; сброс невозможен |
Удаление по ключу (delete) | Точечный сброс конкретной записи | Нужно знать все ключи, где участвует сущность |
Теги (TagAwareCacheInterface) | Один вызов сбрасывает все связанные ключи | Оверхед на хранение тегов; не все адаптеры поддерживают |
Теги: сброс группы одним движением
Теги решают проблему «цена товара участвует в десяти разных кешах». Помечаете каждый элемент тегами, а при изменении сущности сбрасываете все элементы по тегу — не перечисляя ключи.
use Symfony\Contracts\Cache\TagAwareCacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
class ProductCache
{
public function __construct(private TagAwareCacheInterface $cache) {}
public function getProduct(int $id): Product
{
return $this->cache->get('product.'.$id, function (ItemInterface $item) use ($id) {
$item->expiresAfter(3600);
// Метим элемент тегами: по товару и по его категории
$item->tag(['product.'.$id, 'category.'.$this->getCategoryId($id)]);
return $this->productRepository->find($id);
});
}
// Вызывать при изменении товара
public function invalidateProduct(int $id): void
{
$this->cache->invalidateTags(['product.'.$id]);
}
}
Для работы тегов пул должен быть обёрнут в tag-aware адаптер. В cache.yaml это включается опцией tags: true у пула. Учтите: теги хранятся отдельно и добавляют обращения к бэкенду — на очень горячих ключах это заметный оверхед.
Стемпид: когда кеш протух у всех сразу
Это та самая ситуация, когда база ложится под пиком. Популярный ключ протухает — и сотня одновременных запросов разом промахивается, все идут в базу, все пересчитывают одно и то же. Явление называют cache stampede.
Контрактный get() в Symfony умеет защищаться от этого через механизм вероятностного раннего пересчёта. Передаёте параметр beta: с некоторой вероятностью значение пересчитывается до формального истечения TTL, пока старое ещё отдаётся остальным. Так пересчёт делает один запрос, а не толпа.
$value = $this->cache->get('catalog.featured', function (ItemInterface $item) {
$item->expiresAfter(300);
return $this->productRepository->findFeaturedWithStock();
}, $beta = 1.0); // 1.0 — рекомендованное значение по умолчанию
Значение beta регулирует агрессивность раннего пересчёта: 0 отключает механизм, INF вызывает немедленный пересчёт. Единица — разумная отправная точка. Точное поведение и границы параметра сверяйте с разделом Cache в документации Symfony для вашей версии.
Когда что выбирать
- Данные меняются редко и их не критично держать чуть устаревшими — хватит TTL. Справочники, настройки, списки регионов.
- Одна сущность фигурирует в нескольких кешах — теги. Товар в каталоге, на карточке, в блоке «похожие».
- Один горячий ключ, который дорого пересчитывать — обязательно beta против стемпида, даже если инвалидация вам не нужна.
- Точечный сброс одной записи по известному ключу —
delete, без накладных расходов на теги.
Что ломается на практике
- Ключ с недопустимыми символами. PSR-6 запрещает в ключе символы
{}()/\@:. Если формируете ключ из пользовательского ввода или URL — прогоняйте через хеш, иначе получитеInvalidArgumentException. - Кеширование Doctrine-сущностей целиком. Сериализованная сущность после чтения из кеша отвязана от EntityManager. Ленивые связи взорвутся при обращении. Кешируйте DTO или массивы, а не managed-сущности.
- Тот же кеш в dev и prod. Если Redis один на оба окружения, dev-запросы отравляют prod-кеш. Разводите по префиксу или по номеру базы Redis.
- Забыли, что
cache:clearчистит не всё. Команда очищает системный кеш Symfony (контейнер, роутинг), но не обязательно ваши прикладные пулы. Для приложенческого кеша есть отдельная командаcache:pool:clear.
Что делать дальше
Начните с одного самого дорогого запроса: оберните его в get() с разумным TTL и параметром beta. Замерьте долю попаданий — Redis отдаёт keyspace_hits и keyspace_misses в INFO stats, отношение покажет, работает ли кеш вообще. Если попаданий мало при живом TTL — скорее всего, ключи слишком гранулярны и почти всегда уникальны. Следующий шаг после чтения — продуманная инвалидация через Doctrine-события, но это уже отдельный разбор.
Инфобез и 152-ФЗ: Аудит политик конфиденциальности, правила сбора cookie.
Спросить за 20 ₽Источники: Symfony Cache Component — официальная документация, Cache Invalidation with Tags — Symfony docs
Частые вопросы
У меня Symfony 5.4, а не 6.x — примеры сработают?
Кеш вернул устаревшие данные после деплоя — что проверить?
Нужен ли Redis или можно обойтись файловым кешем?
Теги замедляют кеш — это нормально?
Что будет, если колбэк в get() бросит исключение?
Как понять, что кеш реально работает, а не только замедляет?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.