<? phpukraine СТАТТІ
Пошук по платформі
ПРОДУКТИВНІСТЬ 5 вересня 2026 · 7 хв читання

Кеш у Laravel без застарілих даних: теги, lock і інвалідація по подіях

Кеш віддає старе не тому, що «TTL завеликий», а тому що інвалідація має дірки: масові апдейти повз модельні події, скидання всередині транзакції, дві паралельні перебудови одного ключа. Розбираємо, як насправді працюють теги в Redis, коли вони не потрібні, як накрити перебудову атомарним локом і куди правильно вішати інвалідацію.

РP
Редакція phpukraine
Редакція платформи

Найгірший стан кешу — не «повільно», а «швидко віддає неправду». Користувач змінив профіль і бачить старе імʼя; менеджер зняв товар з продажу, а він висить у каталозі ще пʼять хвилин. Далі — механіка інвалідації в Laravel 13 на Redis: що насправді роблять теги, де ламається інвалідація по модельних подіях і чим накривати перебудову дорогого ключа.

Три способи, якими кеш стає брехливим

Ключ ніхто не скинув. Дані змінилися шляхом, про який код інвалідації не знає: Post::where('status', 'draft')->update([...]), міграція, ручний SQL, інший сервіс у ту саму базу. Query Builder не піднімає модельні події — жоден обзервер тут не спрацює.

Ключ скинули не тоді. Інвалідація виконалась усередині транзакції, яка ще не закомітилась. Паралельний запит одразу після Cache::forget() читає з БД старий стан (транзакція не видима) і кладе його назад. Транзакція комітиться — у кеші лежить доміткова версія з повним TTL.

Ключ скинули, і всі кинулися його будувати. Дорогий агрегат протух, у цю секунду прийшло 200 запитів — усі 200 виконали важкий SQL. Це dogpile, і Cache::remember() від нього не рятує: він не має жодного локу всередині.

Перше лікується вибором точки інвалідації, друге — afterCommit, третє — атомарним локом. За порядком.

Теги: що це насправді на Redis

Теги доступні лише на сторах, які успадковують TaggableStore — це redis, memcached, array та apc. На file і database виклик Cache::tags() кидає BadMethodCallException: This cache store does not support tagging. Тобто «локально file, у продакшені redis» — це конфігурація, у якій половина коду впаде тільки в одному з середовищ.

На Redis тег — це sorted set із іменем tag:{name}:entries. Коли ви пишете значення з тегами, Laravel робить ZADD у кожен такий сет: member — ключ кешу, score — timestamp протухання (-1 для forever). Сам ключ значення отримує префікс sha1(namespace), де namespace — це імена тегів, склеєні через |.

Звідси перша пастка: порядок тегів має значення для читання.

Cache::tags(['posts', 'author:7'])->put('feed', $feed, 600);

Cache::tags(['author:7', 'posts'])->get('feed'); // null — інший sha1

Для скидання порядок неважливий: ключ лежить членом обох сетів, тож Cache::tags(['posts'])->flush() його знайде.

Друга пастка — ціна flush(). Він не робить нічого схожого на «видалити одним рухом»: Laravel ітерує sorted set через ZSCAN порціями по 1000 і виконує Lua-скрипт, який робить DEL на ключі та ZREM на посиланні. Це лінійно від кількості записів у тегу і не атомарно. Тег на 200 тисяч ключів — це довга серія команд у Redis у момент, коли вам потрібна швидка відповідь. Теги мають бути вузькими: author:7, а не posts.

Третя — посилання протухають не самі. Ключ зник за TTL, а його member у sorted set залишився і далі бере памʼять та сповільнює flush(). Для цього є команда, яку треба поставити в розклад:

// routes/console.php
Schedule::command('cache:prune-stale-tags')->hourly();

Вона робить ZREMRANGEBYSCORE від 0 до поточного часу, тобто прибирає посилання з простроченим score. Записи, покладені через forever (score -1), лишаються назавжди — ще одна причина не використовувати forever разом із тегами.

Версія ключа замість тегів

Коли треба скинути цілу групу одним рухом і швидко, дешевший прийом — версіонувати namespace, а не ходити по списку ключів. Інвалідація стає однією командою INCR, старі ключі просто перестають бути адресованими і йдуть за TTL.

final readonly class ArticleCache
{
    public function version(int $authorId): int
    {
        return (int) Cache::rememberForever("v:author:{$authorId}", fn (): int => 1);
    }

    public function feed(int $authorId): array
    {
        $version = $this->version($authorId);

        return Cache::remember(
            "author:{$authorId}:v{$version}:feed",
            600,
            fn (): array => $this->buildFeed($authorId),
        );
    }

    public function invalidate(int $authorId): void
    {
        Cache::increment("v:author:{$authorId}");
    }
}

Мінус — сміття: старі версії лежать до кінця свого TTL і їдять памʼять. Тому обовʼязково скінченний TTL на значеннях і maxmemory-policy allkeys-lru на інстансі кешу. Плюс — інвалідація за O(1) і жодних гонок: паралельний запит або бачить стару версію, або нову, але ніколи не пише старі дані в новий namespace.

Lock: одна перебудова замість двохсот

Cache::lock() працює на сторах, що реалізують LockProvider (redis, memcached, dynamodb, database, file, array). На Redis це SET key owner NX EX seconds — атомарна операція, а не «перевірив і поставив».

use Illuminate\Contracts\Cache\LockTimeoutException;

public function stats(): array
{
    return Cache::remember('stats:global', 300, function (): array {
        $lock = Cache::lock('stats:global:build', 30);

        try {
            $lock->block(5);

            // Ще одна перевірка: поки чекали лок, сусід міг усе побудувати.
            return Cache::get('stats:global') ?? $this->buildStats();
        } catch (LockTimeoutException) {
            return $this->buildStatsApproximate();
        } finally {
            $lock->release();
        }
    });
}

Три деталі, які легко пропустити. По-перше, TTL лока (30) має бути більшим за найгіршу тривалість перебудови, інакше лок протухне посеред роботи і сусід почне другу. По-друге, release() на Redis перевіряє owner через Lua і не зніме чужий лок — а от forceRelease() зніме, тож у продакшен-коді його не місце. По-третє, якщо лок береться в HTTP-запиті, а звільняється у черзі, передавайте власника явно:

$lock = Cache::lock('import:catalog', 600);

if ($lock->get()) {
    ProcessCatalog::dispatch($lock->owner());
}

// у джобі:
Cache::restoreLock('import:catalog', $this->lockOwner)->release();

Готовий варіант «віддавати старе, поки перебудовується нове» — Cache::flexible():

Cache::flexible('stats:global', [300, 3600], fn (): array => $this->buildStats(), lock: [
    'seconds' => 60,
]);

Перші 300 секунд значення свіже. Між 300 і 3600 запит отримує старе значення миттєво, а перебудова ставиться в defer() і виконується після відправки відповіді. Четвертий аргумент задавати варто: за замовчуванням внутрішній лок береться з seconds => 0, тобто без TTL. Якщо воркер помре посеред фонового оновлення, лок залишиться висіти і ключ не оновиться до кінця повного TTL.

Інвалідація по подіях: де вона протікає

Обзервер — правильне місце, але з двома обовʼязковими застереженнями.

final class ArticleObserver
{
    public function __construct(private readonly ArticleCache $cache) {}

    public function saved(Article $article): void
    {
        $this->invalidate($article);
    }

    public function deleted(Article $article): void
    {
        $this->invalidate($article);
    }

    private function invalidate(Article $article): void
    {
        $authorId = $article->author_id;

        DB::afterCommit(function () use ($article, $authorId): void {
            $this->cache->invalidate($authorId);

            if ($article->wasChanged('author_id')) {
                $this->cache->invalidate($article->getOriginal('author_id'));
            }
        });
    }
}

DB::afterCommit() поза транзакцією виконує колбек одразу, всередині — після коміту. Це закриває сценарій «скинули до коміту, сусід повернув старе». Якщо інвалідація йде через слухач події або джобу — те саме дає інтерфейс ShouldHandleEventsAfterCommit на слухачі та $afterCommit = true на джобі.

Другий момент — інвалідувати треба і стару, і нову приналежність. При зміні author_id стрічка попереднього автора теж бреше; getOriginal() після збереження все ще віддає доміткове значення.

Що обзервер не побачить взагалі:

  • Article::where(...)->update([...]) і ->delete() — Query Builder не піднімає модельних подій;
  • $article->authors()->attach($id) — зміни в pivot не піднімають подій батьківської моделі;
  • increment() / decrement() на моделі — вони пишуть напряму;
  • будь-який запис повз застосунок: SQL-консоль, міграція, сторонній сервіс.

Практичний висновок: інвалідація по подіях — оптимізація, а не гарантія. Скінченний TTL має стояти завжди, навіть коли ви впевнені, що скидаєте ключ у всіх місцях. rememberForever() без явного шляху інвалідації — це заявка на дані, які житимуть до наступного інциденту.

Конфігурація, яка дозволяє все це не зламати

Cache::flush() на Redis-сторі виконує FLUSHDB — на всю базу, а не тільки на ключі з вашим префіксом. Якщо кеш, сесії, черги й локи лежать в одній базі, php artisan cache:clear знесе черги разом із кешем. Тому в config/database.php тримайте окреме зʼєднання cache з іншим database (у стандартному скелеті це REDIS_CACHE_DB, за замовчуванням 1), а в ідеалі — окремий інстанс Redis під кеш.

Політика витіснення для кешу — allkeys-lru: кеш має право втрачати дані під тиском памʼяті. Але цю ж політику не можна ставити на інстанс, де живуть черги й локи: витіснений лок означає, що дві джоби виконаються одночасно. Черги — окремий інстанс із noeviction.

І остання перевірка, яку варто зробити просто зараз: чи є у вашому коді хоч один Cache::remember() без TTL і хоч один Cache::tags([...])->flush() по тегу, під яким лежать десятки тисяч ключів. Це два найчастіші місця, де кеш перестає бути оптимізацією.

ПИШЕТЕ ПРО PHP?Опублікуйте розбір або історію з проєкту на платформіРедактор із чеклістом, редактура, авторська сторінка. Републікація з блогу отримує canonical на оригінал. Відкрити редактор →
РP
Редакція phpukraine
Редакція платформи
Матеріали, які готує команда платформи на основі власних даних: каталогу вакансій, зарплатного звіту й банку питань. Кожна цифра в них рахується з бази, а не береться з голови.
оновлено 4 вересня 2026 · ліцензія CC-BY-SA-4.0
ДАЛІ ПО ТЕМІ
ЧИТАТИ ДАЛІ
← Усі статті