Найгірший стан кешу — не «повільно», а «швидко віддає неправду». Користувач змінив профіль і бачить старе імʼя; менеджер зняв товар з продажу, а він висить у каталозі ще пʼять хвилин. Далі — механіка інвалідації в 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() по тегу, під яким лежать десятки тисяч ключів. Це два найчастіші місця, де кеш перестає бути оптимізацією.