<? phpukraine СПІВБЕСІДИ
Пошук по платформі
LARAVEL · MIDDLE

Як кешувати в Laravel і не отримати застарілі дані?

Кеш стає застарілим з двох причин: ключ не враховує всього, від чого залежить результат, або інвалідація не спрацювала (масовий update без подій моделі, forget усередині ще не закомміченої транзакції). Лікується не меншим TTL, а тегами й обсерверами з `$afterCommit`, а одночасний промах гарячого ключа — `Cache::lock()` або `Cache::flexible()`.

Редактор виправив заголовок, а на сторінці ще годину висить старий — де шукати причину?
У вас ключ живе 10 хвилин; що станеться о 10:00:01, коли він протух, а на сайті 500 rps?
Чому `Cache::tags()` працює в тестах і падає з BadMethodCallException на проді?
Ви чистите кеш в обсервері після оновлення моделі — чому дані все одно інколи старі?
Cache Redis теги інвалідація stampede

Кеш у Laravel — це тонкий шар над key-value сховищем, і вся його механіка вміщається в чотири рядки Cache::remember(): прочитати ключ, якщо значення не null — повернути, інакше викликати замикання, записати результат із TTL і повернути його. Звідси одразу два наслідки, на яких валяться найчастіше. Перший: null для remember() — це промах, а не значення, тому кешування «нічого не знайдено» не працює й кожен запит за неіснуючим id іде в базу (потрібне значення-заглушка на кшталт false або порожнього масиву). Другий: TTL задається в секундах (так із Laravel 5.8; у 12+ його можна передати замиканням, яке отримує обчислене значення), і саме TTL — єдиний механізм, який працює сам. Усе інше — інвалідація — це ваш код, і якщо його немає, «застарілі дані» просто означають «TTL ще не минув».

Друга причина застарілості тонша: ключ не описує все, від чого залежить результат. Якщо сторінка залежить від локалі, ролі, номера сторінки й набору фільтрів, а в ключі лише posts:list, то ви кешуєте не сторінку, а першу з її версій і показуєте її всім. Робоче правило: усе, що входить у запит, входить і в ключ; довгі набори фільтрів згортаються в хеш; версія коду або схеми серіалізації — у суфікс (posts:popular:v2), щоб деплой не почав читати старий формат новим кодом. Окремо памʼятайте про префікс стора (CACHE_PREFIX): якщо кілька застосунків дивляться в одну базу Redis без різних префіксів, вони бачать ключі одне одного, а php artisan cache:clear виносить усе одразу.

Явна інвалідація має два інструменти. Точковий — Cache::forget("post:{$id}"), коли ви точно знаєте, який ключ зіпсувався. Груповий — теги: Cache::tags(['posts'])->remember(...) і Cache::tags(['posts'])->flush(), коли одна зміна псує десятки похідних ключів (списки, фасети, сайдбари). Теги реалізовані в сторі, а не в Repository, тому доступні лише на таггабельних драйверах — redis, memcached, array, apc; на file, database і dynamodb виклик впаде з BadMethodCallException. Це особливо неприємно тому, що дефолтний стор у Laravel 11+ — саме database, а в тестах тут стоїть CACHE_STORE=array, де теги є: код зелений локально й падає на проді. У Redis теги коштують додаткового запису: кожен ключ реєструється в sorted set свого тега, flush() проходить по цих посиланнях і видаляє ключі пачками, а посилання на ключі, що протухли самі, лишаються в сеті, доки хтось не викличе flushStale().

Інвалідацію по подіях моделі роблять обсервером на saved/deleted, і тут три пастки. Перша — транзакції: якщо Cache::forget() виконався до COMMIT, паралельний процес встигне перечитати ще старий рядок і покласти його в кеш заново, і застарілим він лишиться до кінця TTL. Ліки — public bool $afterCommit = true; на обсервері (диспетчер подій перевіряє цю властивість і відкладає виклик до коміту) або явний DB::afterCommit(fn () => Cache::forget($key)), який поза транзакцією просто виконується одразу. Друга — масові операції: Post::where(...)->update() і ->delete() йдуть повз моделі, тому подій не породжують взагалі; те саме стосується saveQuietly(), withoutEvents(), truncate() і attach()/detach() на pivot. Третя — зміни поза застосунком: імпорт, SQL із консолі, репліка з лагом. Якщо джерел запису кілька, чесніше жити на короткому TTL, ніж вірити в обсервер, який бачить лише половину змін.

Окремий клас проблем — не застарілість, а стемпіда: гарячий ключ протух, і всі паралельні запити одночасно бачать промах та йдуть у базу. Зменшення TTL робить це частіше, а не рідше. Правильні відповіді: Cache::lock("{$key}:lock", 10)->block(5, ...) — рахує один, решта чекають і влучають у вже прогрітий ключ (block() кидає LockTimeoutException, а TTL лока страхує від процесу, що помер, не відпустивши його); Cache::flexible($key, [60, 600], ...) — до 60 с значення свіже, далі віддається старе, а оновлення йде в defer() після відповіді; плюс джиттер у TTL, щоб ключі, прогріті одним деплоєм, не протухали в одну секунду. Нарешті, чого кешувати не варто: даних, які мають бути точними в момент читання (баланси, залишки, ліміти — там блокування в базі, а не кеш), персональних даних у спільному ключі, колекцій Eloquent-моделей (серіалізується модель разом із завантаженими звʼязками, і розпакування буває дорожчим за сам запит — кладіть масиви або DTO) і того, що дешевше порахувати: вибірка по первинному ключу з індексом часто швидша за round-trip у Redis, а повторні читання в межах одного запиту закриває Cache::memo().

final class PopularPosts
{
    // Гарячий ключ: 60 с свіжий, до 600 с віддаємо старе й освіжаємо
    // у defer() після відповіді — під локом, тож рахує лише один процес.
    public function list(): array
    {
        return Cache::flexible('posts:popular:v2', [60, 600], fn () => Post::query()
            ->where('is_published', true)
            ->orderByDesc('views')
            ->limit(10)
            ->get(['id', 'slug', 'title'])
            ->toArray()); // масив, а не моделі: без звʼязків і дешевша серіалізація
    }

    // Важкий звіт: перший бере лок, решта чекають до 5 с і влучають у кеш.
    public function stats(int $companyId): array
    {
        $key = "company:{$companyId}:stats";

        return Cache::get($key) ?? Cache::lock("{$key}:lock", 10)->block(5, fn () => Cache::remember(
            $key, 300, fn () => $this->calculate($companyId)
        ));
    }
}

final class PostObserver
{
    // Без цього forget станеться до COMMIT: сусідній процес перечитає
    // старий рядок і закешує його заново — і так назавжди.
    public bool $afterCommit = true;

    public function saved(Post $post): void
    {
        Cache::forget("post:{$post->id}");
        Cache::tags(['posts'])->flush(); // redis/memcached; на file і database — BadMethodCallException
    }

    public function deleted(Post $post): void
    {
        $this->saved($post);
    }
}

// Пастка: масове оновлення не викликає saved(), інвалідуємо руками.
Post::where('published_at', '<', now())->update(['is_published' => false]);
Cache::tags(['posts'])->flush();
Що `Cache::remember()` не вважає `null` попаданням: якщо callback повернув `null`, значення запишеться, але кожне наступне читання буде промахом і піде в базу.
Що теги підтримують лише таггабельні стори (redis, memcached, array, apc), а `file`, `database` і `dynamodb` кидають BadMethodCallException — і що дефолтний стор у Laravel 11+ саме `database`.
Що від одночасного промаху гарячого ключа рятує `Cache::lock()->block()` або `Cache::flexible()` зі stale-while-revalidate, а не зменшення TTL.
Що `Post::where(...)->update()` і `->delete()` не викликають подій моделі, тому обсервер такої зміни не побачить.
Що інвалідація всередині транзакції — це гонка: сусідній процес перечитає ще не закомічені дані й закешує старе назавжди; звідси `public $afterCommit = true` на обсервері або `DB::afterCommit()`.
Лікувати застарілі дані зменшенням TTL: з 60 хв до 5 хв — це та сама помилка, тільки в 12 разів частіше, плюс у 12 разів більше промахів.
Кешувати «нічого не знайдено»: `Cache::remember($k, 600, fn () => User::find($id))` для неіснуючого id щоразу б'є в базу, бо `null` для `remember()` — це промах.
Класти в ключ лише id сутності й забути про локаль, роль, номер сторінки чи фільтри — і показати одному користувачеві сторінку іншого.
Викликати `Cache::flush()` або `php artisan cache:clear` замість точкової інвалідації: якщо сесії, rate limiter і кеш живуть в одному сторі, зносить і їх.
Класти в кеш колекції Eloquent-моделей: серіалізується модель разом із завантаженими звʼязками, і `unserialize` часом дорожчий за сам SQL-запит.
Перевіряти наявність через `Cache::has()` для значення, яке легально може бути `false` або `null` — `has()` під капотом читає значення й вважає `null` відсутністю.
Писати теги в коді, ганяти тести на `CACHE_STORE=array` (де теги є) і викочувати це на `database`-стор, де їх немає.
ПОРАДА

Скажіть уголос дві речі, які інтервʼюер чекає: «ключ має містити все, від чого залежить відповідь» і «інвалідацію робимо після COMMIT». А далі назвіть три рівні: TTL з джиттером — базова гігієна, теги — інвалідація по сутності, `Cache::lock()`/`Cache::flexible()` — захист від того, що на протухлий ключ одночасно прийдуть сотні запитів.

оновлено 4 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

TTL зменшує вікно застарілості, але не усуває його й лише збільшує кількість промахів. Теги реалізовані у сторі, а не в Repository: `file`, `database` і `dynamodb` їх не підтримують. `remember()` вважає `null` промахом, тож «не знайдено» кешувати ним не вийде. Від стемпіди захищає серіалізація перерахунку — лок або stale-while-revalidate.