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

Які види кешу є в Yii2 і як інвалідувати кеш через залежності?

У Yii2 чотири рівні: кеш даних через компонент `cache` (`get`/`set`/`getOrSet`), кеш фрагментів через `beginCache()`/`endCache()`, кеш сторінок фільтром `yii\filters\PageCache` і HTTP-кеш фільтром `yii\filters\HttpCache` з `Last-Modified`/`ETag`. Інвалідація - або за часом, або через обʼєкт `Dependency`: `DbDependency` перевіряє результат дешевого SQL, `TagDependency` позначає групу ключів застарілою через `TagDependency::invalidate()`.

Чим `beginCache()` у view відрізняється від фільтра `PageCache` у контролері?
Ми поставили `TagDependency`, викликали `invalidate()`, а в Redis ключі лишились на місці. Це баг?
Сторінку закешували на годину, але база все одно отримує запит на кожен хіт. Звідки він?
Після деплою кеш прогрівається, і на першій хвилині база лягає від 200 однакових запитів. Що зробити?
Чому `Yii::$app->db->cache(...)` не спрацював, хоча тривалість передали?
Yii2 кешування TagDependency DbDependency PageCache HttpCache cache stampede

Чотири види кешу в Yii2 відрізняє не сховище, а місце в життєвому циклі запиту. yii\filters\HttpCache - фільтр дії: він читає If-Modified-Since та If-None-Match, питає ваші колбеки lastModified і etagSeed, і за збігом віддає 304 без тіла, навіть не доходячи до рендеру (тільки для GET і HEAD). yii\filters\PageCache стоїть у тому ж місці, але зберігає в кеші готовий Response::$data разом із заголовками зі cacheHeaders і куками зі cacheCookies, тому при попаданні дія взагалі не виконується. Кеш фрагментів - віджет yii\widgets\FragmentCache під обгорткою $this->beginCache($id, $options) / $this->endCache() всередині view; його беруть тоді, коли сторінку цілком кешувати не можна, а важкий сайдбар - можна. Кеш даних через компонент cache не привʼязаний до жодної точки циклу: get(), set(), add(), multiGet(), getOrSet() приймають будь-що серіалізовне, від масиву до HTML.

Нижче цих чотирьох рівнів живе кеш запитів, який регулярно плутають із кешем даних. Yii::$app->db->cache(fn () => ..., $duration, $dependency) перехоплює yii\db\Command всередині колбека й зберігає сирі рядки результату в компоненті, вказаному в $db->queryCache. Те саме можна поставити на окремий запит: Post::find()->where([...])->cache(600). Виграш тут суто на мережі до БД та її роботі: гідратація в обʼєкти Post відбувається заново при кожному читанні, бо в кеші лежать масиви з драйвера. Коли дорога саме гідратація, правильніше кешувати asArray()->all() звичайним getOrSet(). І не забувайте про enableSchemaCache: без нього Yii робить SHOW FULL COLUMNS для кожної моделі в кожному запиті, що на проді з трьома десятками таблиць видно в профайлері.

Інвалідація зводиться до двох варіантів: час або обʼєкт Dependency. Механіка проста, але має неочевидний наслідок: залежність перевіряється в get(), а не в set(). Коли Yii знаходить запис, він викликає Dependency::isChanged() і тільки за false віддає дані. Для DbDependency це означає реальний SQL-запит на кожне читання: ви замінили важкий агрегат на SELECT MAX(updated_at) ... WHERE category_id = :cid, але звернення до бази залишилось. Звідси симптом «сторінка закешована, а база отримує звернення на кожен хіт». Якщо залежність використовується кілька разів у межах одного запиту, поставте 'reusable' => true - результат перевірки ляже в статичну властивість, і SQL виконається один раз. Цінність DbDependency - у ситуаціях, коли дані змінює хтось поза вашим кодом: інший сервіс, імпорт дампом, стара адмінка.

TagDependency працює інакше й дешевше. Теги зберігаються як окремі ключі з випадковим значенням від Yii::$app->security->generateRandomKey(); set() запамʼятовує поточні значення біля даних, isChanged() їх перечитує і порівнює, а TagDependency::invalidate($cache, $tags) генерує нові. Вартість інвалідації не залежить від кількості звʼязаних записів - перезаписується стільки ключів, скільки тегів. Побічний ефект, який дивує на першому Redis-моніторингу: дані нікуди не зникають. Вони просто перестають сходитись при порівнянні й лежать до кінця власного TTL, до спрацювання gcProbability у FileCache/DbCache або до витіснення за maxmemory-policy. Логічне місце для invalidate() - afterSave() і afterDelete() моделі, тоді читання не платить нічого. Коли потрібна комбінація умов, є ChainedDependency з прапорцем dependOnAll.

Окрема тема, на якій валиться багато відповідей, - cache stampede. Популярний ключ протермінувався, сто паралельних воркерів одночасно отримали false і пішли будувати значення самі; база стає вузьким місцем у найгірший момент. getOrSet() тут не допомагає, це синтаксичний цукор: між його внутрішнім get() і set() жодного замка немає. Робочі підходи: компонент mutex (MysqlMutex, PgsqlMutex, FileMutex, yii\redis\Mutex) із коротким таймаутом на acquire(), щоб перебудовував один процес, а решта або чекали, або віддавали заглушку; схема з двома TTL, де службовий ключ протермінується раніше за дані й дозволяє віддавати трохи застарілий результат, поки один воркер оновлює; прогрів консольною командою в cron. І обовʼязково розсинхронізуйте TTL - 3600 + random_int(0, 300) замість рівної години, інакше після деплою всі ключі помруть одночасно й ви отримаєте те саме, тільки за розкладом.

variations ламають продакшн частіше за будь-яку теорію з цього списку. PageCache і FragmentCache нічого не знають про те, від чого залежить ваш вивід. Двомовний сайт без 'variations' => [Yii::$app->language] віддасть англійцеві українську сторінку. Блок, що показує Yii::$app->user->identity->name або містить CSRF-токен, без варіації за id користувача покаже чуже імʼя й зламає POST-форми. Те саме з фільтрами списку: якщо вивід залежить від $_GET['sort'], цей параметр мусить бути у варіаціях або в ключі. Наостанок про Yii::$app->cache->flush() на проді: так робити не варто, бо він зносить усе, включно з метаданими схеми й сесіями, якщо вони в тому ж сторі. Для «чистого старту» після деплою достатньо змінити keyPrefix у конфігурації компонента.

use yii\caching\DbDependency;
use yii\caching\TagDependency;

// 1. Кеш даних. getOrSet() зʼявився у 2.0.11; масив-ключ Yii нормалізує через buildKey()
$top = Yii::$app->cache->getOrSet(
    ['top-posts', 'category' => $categoryId, 'lang' => Yii::$app->language],
    fn () => Post::find()->where(['category_id' => $categoryId])->limit(10)->asArray()->all(),
    3600 + random_int(0, 300),                      // розсинхронізований TTL проти stampede
    new TagDependency(['tags' => ["category-$categoryId", 'posts']])
);

// 2. Інвалідація за тегом: перезаписує службові ключі, самі записи лишаються в storage
TagDependency::invalidate(Yii::$app->cache, "category-$categoryId");

// 3. Залежність від стану БД. isChanged() виконує цей SQL на КОЖНОМУ get(),
//    тому умова мусить мати індекс; reusable схлопує перевірки в межах запиту
$freshness = new DbDependency([
    'sql' => 'SELECT MAX(updated_at) FROM {{%post}} WHERE category_id = :cid',
    'params' => [':cid' => $categoryId],
    'reusable' => true,
]);

// 4. Кеш запитів: у кеш ідуть сирі рядки, гідратація в моделі робиться щоразу
$count = Yii::$app->db->cache(
    fn () => Post::find()->where(['category_id' => $categoryId])->count(),
    600,
    $freshness
);

// 5. Важкий звіт під mutex: перебудовує лише той воркер, що взяв замок
$key = ['report', 'month' => $month];
$report = Yii::$app->cache->get($key);
if ($report === false) {
    $lock = 'report-' . $month;
    if (Yii::$app->mutex->acquire($lock, 3)) {      // 3 с очікування, далі false
        try {
            $report = Yii::$app->cache->getOrSet($key, fn () => $this->buildReport($month), 900);
        } finally {
            Yii::$app->mutex->release($lock);
        }
    } else {
        $report = $this->reportPlaceholder();        // решта не стають у чергу до БД
    }
}
Що рівні кешу відрізняються не технологією, а точкою в життєвому циклі: HTTP-кеш обриває запит до рендеру, PageCache - після `beforeAction`, фрагмент - усередині view, кеш даних - будь-де.
Що залежність перевіряється на `get()`, а не на `set()`: `DbDependency` виконує свій SQL при кожному читанні з кешу, тому важка умова в залежності зводить виграш до нуля.
Що `TagDependency::invalidate()` не видаляє даних: воно перезаписує службові ключі тегів, і старі записи просто перестають проходити `isChanged()`.
Що кеш запитів (`$db->cache()`, `->cache()` на `ActiveQuery`) кешує рядки результату, а не гідратовані моделі, і потребує `enableQueryCache` на підключенні.
Що `variations` обовʼязкові там, де вивід залежить від мови, ролі або параметрів фільтра, інакше користувач побачить чужу сторінку.
Що від cache stampede вбудованого захисту немає: Yii дає компонент `mutex`, а `getOrSet()` ніяких блокувань не ставить.
Думати, що `getOrSet()` захищає від паралельної перебудови: він робить `get()`, і якщо пусто - викликає колбек, скільки б процесів туди не зайшло одночасно.
Чекати, що `TagDependency::invalidate()` звільнить памʼять у Redis чи видалить файли FileCache: записи лежать до закінчення TTL або до GC.
Ставити `DbDependency` з `sql => 'SELECT COUNT(*) FROM order'` на велику таблицю: сторінка «з кешу» починає з повного підрахунку, у InnoDB це seq scan.
Викликати `beginCache()` без перевірки повернутого значення - тоді тіло рендериться завжди, а `endCache()` падає з винятком про незбалансований виклик.
Забути `variations` у `PageCache` на двомовному сайті й віддавати україномовну сторінку англійській версії.
Кешувати у фрагменті блок, який залежить від CSRF-токена або `Yii::$app->user`, без варіації за id користувача.
Міняти схему таблиці на проді й не чистити `enableSchemaCache`: Yii продовжує вважати, що колонки немає.
ПОРАДА

Відповідайте по осі «де в життєвому циклі обривається робота»: HttpCache - 304 ще до рендеру, PageCache - готовий HTML із заголовками, fragment - частина view, data cache - будь-який серіалізовний результат. Потім одним реченням про інвалідацію: «за часом, або `Dependency::isChanged()` на `get()`, причому `DbDependency` коштує запит, а `TagDependency` - лише читання службового ключа». Згадка про mutex проти stampede додає бал майже завжди.

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

Теги зберігаються як окремі ключі з випадковим значенням; `set()` запамʼятовує це значення біля даних, `get()` звіряє його через `isChanged()`, а `invalidate()` лише генерує нове. Звідси і дешевизна операції (не залежить від кількості записів), і те, що памʼять не повертається - дані прибирає TTL, GC або витіснення в Redis.