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

Вы завернули тяжёлый запрос в Cache::remember(), страница стала открываться за 40 мс вместо 400 — и через час поддержка пишет, что покупатель видит товар, которого уже нет на складе. Классика: кеш работает, но отдаёт данные, которые уже устарели. Проблема почти никогда не в самом кешировании, а в том, как выбран ключ, срок жизни и момент сброса.
Статья про Laravel 11.x (приёмы одинаково работают и в 10.x). Разбираем, как кешировать результаты запросов так, чтобы не ловить рассинхрон. Про полностраничный кеш и кеш ответов HTTP здесь не говорим — это отдельная тема.
Почему очевидное решение течёт
Очевидное решение выглядит так:
use Illuminate\Support\Facades\Cache;
// Кешируем список активных товаров на час
$products = Cache::remember('products.active', 3600, function () {
return Product::where('is_active', true)->get();
});Работает ровно до первого изменения данных. Товар отредактировали, отключили, добавили новый — а кеш держит старую версию ещё до часа. Пользователь получает несуществующие позиции, вы получаете тикет в поддержку.
Дальше начинаются попытки чинить это Cache::forget('products.active') в обработчике сохранения модели. Пока ключ один — терпимо. Как только ключей становится десяток (список, счётчики, выборки по категориям), ручной сброс превращается в места, где легко забыть один вызов. Забытый forget — это баг, который не воспроизводится на тестовом стенде.
Четыре подхода к инвалидации
1. Короткий TTL без ручного сброса
Самый простой рабочий вариант: не сбрасывать кеш вручную вообще, а ставить такой срок жизни, при котором устаревание терпимо. Каталог, который меняется раз в день, спокойно живёт с TTL в пару минут.
// Данные могут отставать максимум на 120 секунд
$categories = Cache::remember('categories.tree', 120, function () {
return Category::with('children')->get();
});Выигрыш: нулевая связанность, ничего не забудешь сбросить. Цена: пользователь видит устаревшие данные до истечения TTL, а после истечения первый запрос снова бьёт в базу.
2. Ручной сброс по ключу
Подходит, когда ключей мало и данные обязаны обновляться мгновенно. Сброс вешают на события модели.
class Product extends Model
{
protected static function booted(): void
{
// Сбрасываем кеш при любом изменении и удалении
static::saved(fn () => Cache::forget('products.active'));
static::deleted(fn () => Cache::forget('products.active'));
}
}Выигрыш: данные свежие сразу после изменения. Цена: каждый новый кешируемый ключ нужно не забыть добавить в сброс. При десятке ключей это уже источник ошибок.
3. Теги кеша
Теги позволяют сбросить группу ключей одним вызовом. Работают только на драйверах Redis и Memcached — на file и database теги недоступны, вы получите исключение.
// Кешируем с тегом
$products = Cache::tags(['products'])->remember('products.active', 3600, function () {
return Product::where('is_active', true)->get();
});
$byCategory = Cache::tags(['products'])->remember('products.cat.7', 3600, function () {
return Product::where('category_id', 7)->get();
});
// Один вызов сбрасывает оба ключа
Cache::tags(['products'])->flush();Выигрыш: не нужно перечислять ключи руками, добавили новый ключ с тем же тегом — он сбросится вместе с остальными. Цена: жёсткая привязка к Redis/Memcached и то, что flush() по тегу под нагрузкой сам по себе не бесплатен.
4. Версионирование ключа
Вместо сброса меняем сам ключ. В ключ подставляют версию или метку времени последнего изменения, и старые записи просто перестают запрашиваться (а потом вытесняются по памяти).
// Версия хранится отдельным ключом и инкрементится при изменениях
$version = Cache::get('products.version', 1);
$products = Cache::remember("products.active.v{$version}", 3600, function () {
return Product::where('is_active', true)->get();
});
// При изменении данных — увеличиваем версию, старые ключи осиротеют
Cache::increment('products.version');Выигрыш: работает на любом драйвере, сброс атомарный и мгновенный. Цена: старые ключи занимают память до вытеснения — на драйвере без вытеснения (например, file) они копятся.
Кеш — это не «ускорить запрос», а сознательный размен свежести данных на скорость. Пока вы не назвали, сколько секунд устаревания допустимо для конкретной выборки, вы кешируете вслепую.
Сравнение подходов
| Подход | Свежесть данных | Драйверы | Риск ошибки |
|---|---|---|---|
| Короткий TTL | Отставание до TTL | Любой | Низкий |
| Ручной forget | Мгновенно | Любой | Высокий (забыть ключ) |
| Теги | Мгновенно | Redis, Memcached | Средний |
| Версия ключа | Мгновенно | Любой | Низкий |
Когда что выбирать
- Данные меняются редко, отставание терпимо (меню, справочники) — короткий TTL. Проще всего, ломаться нечему.
- Данные критичны и должны быть свежими, ключей мало — ручной сброс по событию модели.
- Много связанных ключей, есть Redis — теги. Один
flush()по группе. - Нужна мгновенная инвалидация на файловом или SQL-драйвере — версионирование ключа.
Что ломается на практике
- Кешируете Eloquent-модель целиком. В кеш кладётся сериализованный объект со связями и служебными полями. Кешируйте примитивы или DTO:
->pluck(),->toArray(), конкретные поля. Меньше объём, нет сюрпризов при обновлении структуры модели. - Ключ зависит от пользователя, но вы этого не заметили. Запрос с
where('user_id', ...), закешированный под общим ключом, отдаст данные одного пользователя другому. Всегда включайте различающий параметр в ключ. - Тегами кешируете на
file. ПолучитеBadMethodCallException. ПроверьтеCACHE_STOREв.env(в старых версиях переменная называласьCACHE_DRIVER— сверьтесь с вашимconfig/cache.php). - Cache stampede. Когда популярный ключ истёк, сотни запросов одновременно уходят в базу пересчитывать одно и то же. Помогает
Cache::lock()вокруг пересчёта либо небольшой случайный разброс TTL, чтобы ключи не истекали синхронно. - Забыли, что
Cache::rememberне кешируетnullодинаково на всех драйверах. Если запрос легитимно вернул пустоту, продумайте, кешируется ли она, чтобы не бить в базу каждым запросом впустую.
Что настроить дальше
Переведите кеш на Redis, если ещё на file: это открывает теги, атомарные lock и вытеснение по памяти. В проде смотрите на два показателя — hit rate кеша (в Redis это keyspace_hits и keyspace_misses из INFO stats) и долю запросов, доходящих до базы. Если hit rate низкий при коротком TTL — TTL слишком мал либо ключей слишком много вариаций. Инвалидация связанных данных при сложных зависимостях — отдельная тема, которую стоит проектировать до того, как кеш расползётся по проекту.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: Laravel Cache — официальная документация, Redis INFO command
Частые вопросы
Теги кеша не работают, падает исключение — что не так?
У меня старая версия Laravel, там переменная CACHE_DRIVER, а не CACHE_STORE — приёмы подойдут?
Как понять, какой TTL ставить?
Кеш не сбрасывается после изменения данных, хотя forget вызывается — почему?
Что делать, если после истечения кеша база проседает под лавиной запросов?
Стоит ли кешировать объекты Eloquent целиком?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.