Кеш в Yii под нагрузкой: dogpile и когда TagDependency стоит дорого
Кеш на TagDependency и Cache::getOrSet выглядит безобидно, пока трафик низкий. Под нагрузкой оба дают эффекты, которые заметны только на графиках Redis и времени ответа.

Есть типичная картина: приложение на Yii 2.0.x держит нагрузку, время ответа держится в пределах 80–120 мс, кеш попадает. Затем истекает TTL популярного ключа, приходит всплеск в несколько сотен параллельных запросов — и вместо одного пересчёта вы получаете сотни одновременных походов в базу. Redis при этом почти простаивает, а MySQL уходит в потолок по CPU. Это dogpile, он же cache stampede, и в Yii он вылезает ровно там, где код выглядит аккуратно.
Разберём три места, где кеш в Yii под нагрузкой ведёт себя не так, как на dev-машине: массовый одновременный промах, стоимость TagDependency и накладные расходы на сериализацию. Инвалидацию по бизнес-логике и выбор бэкенда (FileCache против Redis) здесь не трогаем — предмет только про поведение под параллельной нагрузкой.
Dogpile: один истёкший ключ кладёт базу
Классический код кеширования в Yii выглядит так и на низком трафике не вызывает вопросов:
// Yii 2.0.x, компонент cache настроен на yii\redis\Cache
$data = Yii::$app->cache->getOrSet('catalog:top', function () {
// тяжёлый запрос: агрегация по каталогу, ~200 мс
return CatalogService::buildTop();
}, 3600);Проблема в том, что getOrSet не берёт блокировку. Когда ключ истёк, каждый запрос, который не нашёл значения, независимо решает пересчитать его сам. При 300 параллельных запросах вы получаете 300 вызовов buildTop() за те доли секунды, пока первый ещё не записал результат в кеш.
На графиках это выглядит как ровная линия времени ответа, которая раз в час (по TTL) прыгает вертикально вверх, а иногда — уходит в каскадный отказ, потому что MySQL исчерпал пул соединений.
Промах кеша под нагрузкой опаснее, чем полное отсутствие кеша: без кеша база получает равномерные 300 rps, а с плохим кешем — 300 запросов в одну миллисекунду, синхронизированных истечением TTL.
Мьютекс на пересчёт
Идея: пересчитывает значение только один запрос, остальные ждут или отдают слегка устаревшие данные. В Yii есть компонент yii\mutex\Mutex с реализациями под MySQL, файлы и Redis (yii\redis\Mutex в пакете yii2-redis). Оборачиваем пересчёт в блокировку:
// $mutex — компонент yii\redis\Mutex
$data = Yii::$app->cache->get('catalog:top');
if ($data === false) {
$lockName = 'lock:catalog:top';
// acquire с таймаутом: ждём до 3 секунд шанса взять блокировку
if (Yii::$app->mutex->acquire($lockName, 3)) {
try {
// перепроверяем: пока ждали, кто-то мог уже записать
$data = Yii::$app->cache->get('catalog:top');
if ($data === false) {
$data = CatalogService::buildTop();
Yii::$app->cache->set('catalog:top', $data, 3600);
}
} finally {
Yii::$app->mutex->release($lockName);
}
} else {
// блокировку не взяли — отдаём фолбэк, а не встаём в очередь к базе
$data = CatalogService::buildTopFallback();
}
}Цена этого подхода: без блокировки была одна лишняя миллисекунда, теперь появляется round-trip к Redis за мьютексом на каждый промах и риск, что ожидающие запросы упрутся в таймаут. Двойная проверка (get после acquire) обязательна — иначе смысла в блокировке нет.
Логическое истечение вместо жёсткого TTL
Альтернатива мьютексу — не давать ключу истекать по-настоящему. Храните в значении собственную метку времени и TTL ставьте с большим запасом. Когда «логический» срок вышел, первый запрос запускает пересчёт, а остальные продолжают отдавать старое значение, пока новое не готово. Это убирает синхронный всплеск полностью, ценой того, что часть пользователей видит данные на несколько секунд устаревшими и вы сами храните метку в payload.
TagDependency: почему инвалидация масштабируется хуже кеша
yii\caching\TagDependency удобен: пометили десяток ключей тегом, вызвали TagDependency::invalidate() — все разом протухли. Под нагрузкой у него две особенности, которые не видны на демо.
Во-первых, теги не удаляют записи. invalidate лишь обновляет версию тега в отдельном ключе. При каждом чтении помеченного значения Yii дополнительно читает ключ тега и сравнивает версии. То есть один get по значению с тегом — это минимум два обращения к бэкенду кеша. На Redis с сетевой задержкой это удваивает число round-trip'ов на горячем пути.
Во-вторых, невалидированные, но уже мёртвые записи остаются в Redis до истечения собственного TTL и занимают память. При активной инвалидации теговых ключей вы платите памятью за то, что физически данные не чистятся.
| Механизм | Round-trip на чтение | Стоимость инвалидации | Когда уместно |
|---|---|---|---|
| Ключ без зависимостей | 1 | del по ключу | горячий путь, точечная инвалидация |
| TagDependency | ≥2 (значение + ключ тега) | обновление версии тега, O(1) | групповой сброс, чтения не самые частые |
| Логическое истечение | 1 | нет, живёт по TTL | максимально горячий путь, допустима устарелость |
Сериализация: скрытый расход на больших значениях
Yii по умолчанию сериализует значения через serialize()/unserialize() перед записью в бэкенд. Для объекта ActiveRecord или большого массива это заметный CPU-расход на каждом чтении, и он ложится на PHP-процесс, а не на Redis. Под нагрузкой это видно как рост CPU веб-нод при, казалось бы, хорошем hit rate кеша.
Два практических следствия:
- не кладите в кеш объекты
ActiveRecordцеликом — храните массивы или скалярные значения, которые нужны шаблону. Меньше данных для serialize, меньше памяти в Redis. - если значения большие, свойство
$serializerкомпонента кеша можно отключить (false), когда вы кладёте уже готовую строку, либо задать свою пару функций. Проверьте формат в документации вашей версии Yii, поведение$serializerуточняйте по API yii\caching\Cache.
Что и как мерить
Не гадайте, где узкое место — снимите цифры под реальной или синтетической нагрузкой (ab, wrk, k6). Смотрите на:
- Hit rate кеша. В Redis —
INFO stats, поляkeyspace_hitsиkeyspace_misses. Если miss rate растёт синхронно со всплесками времени ответа — это dogpile. - Число запросов к БД на один HTTP-запрос. В Yii это видно в панели отладки (модуль
yii\debug\Module) на dev, а в проде — по логам медленных запросов MySQL. - CPU веб-нод при высоком hit rate. Если кеш попадает, а CPU PHP высокий — подозревайте сериализацию тяжёлых значений.
- Число round-trip'ов к Redis на запрос.
MONITORна короткий срез (в проде осторожно, он сам грузит Redis) покажет, не удваивает лиTagDependencyваши чтения.
Что делать дальше
Порядок действий, а не абстрактные выводы:
- найдите горячие ключи с большим TTL и высокой ценой пересчёта — именно они дают dogpile. Оберните их пересчёт в мьютекс или переведите на логическое истечение.
- пройдитесь по использованию
TagDependencyна горячем пути: там, где инвалидация точечная, замените тег на прямое удаление ключа. - проверьте, что кладёте в кеш: массивы вместо
ActiveRecord, только нужные поля. - повторите нагрузочный тест и сравните графики времени ответа в момент истечения TTL до и после.
Настройка бэкенда (persistence Redis, eviction policy, размер пула соединений к MySQL) — отдельная тема; здесь мы работали только с поведением кеша на стороне приложения.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: Yii 2.0 Guide — Caching, yii\caching\Cache — API Reference 2.0
Частые вопросы
У меня Yii 2.0.x, а не 2.0.49 конкретно — код с мьютексом подойдёт?
А если у меня FileCache, а не Redis — dogpile тоже будет?
Обернул пересчёт в мьютекс, но всплески на базе остались. Что не так?
Насколько TagDependency реально тормозит? Стоит ли его вообще убирать?
Почему нельзя просто увеличить TTL, чтобы ключи реже истекали?
Как понять, что рост CPU — из-за сериализации, а не из-за самого кода?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.