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