Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Кешування

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

8 питань

Драйвер задається в config/cache.php і змінною CACHE_STORE. Вибір визначає не швидкість, а те, чи бачать процеси спільний кеш.

array - у памʼяті одного процесу, зникає після запиту. Стандартний драйвер для тестів: кеш не тягнеться між тестами й нічого не треба чистити.

file - файли в storage/framework/cache. Працює без жодного налаштування, тому зручний локально. На кількох серверах кожен матиме свій кеш, а очищення тегів файловий драйвер не підтримує.

database - таблиця в базі. Спільний для всіх серверів, але кожне читання - це запит до тієї самої бази, яку кеш і мав розвантажити.

redis / memcached - окремий сервер у памʼяті. Спільний для всіх процесів, витримує теги й атомарні блокування. Це стандартний вибір для проду.

Практичне правило: локально file, у тестах array, у проді redis.

Що важливо пам'ятати про різницю. Код, написаний під Redis, може мовчки не працювати на file:

Cache::tags(['posts'])->put('list', $posts, 600);   // file-драйвер кине виняток

Блокування (Cache::lock()) файловий драйвер підтримує, але блокування живе на диску одного сервера - на кількох вузлах воно вже нічого не гарантує.

Тому середовища краще тримати на однаковому драйвері - інакше помилка знайдеться вже в проді.

Докладніше в документації: Кеш

Cache::remember() повертає значення з кешу, а якщо його немає - виконує замикання, зберігає результат і повертає.

$categories = Cache::remember('categories:menu', now()->addHour(), function () {
    return Category::query()->orderBy('position')->get(['id', 'name', 'slug']);
});

Перший запит за годину піде в базу, решта отримають готовий результат.

Ключ:

  • має містити все, від чого залежить результат: posts:page:3, user:42:stats, products:locale:uk. Забули мову в ключі - українці побачать англійське меню;
  • з префіксом за сутністю - щоб було зрозуміло, що це, і легко скинути.

Час життя (TTL):

  • скільки часу застарілі дані прийнятні для користувача: курс валют - хвилини, меню категорій - години;
  • rememberForever() - лише коли кеш гарантовано скидають при змінах, інакше застарілі дані житимуть вічно.

Чого уникати:

  • кешувати те, що й так швидке: простий запит за первинним ключем часто швидший за звернення до Redis з десеріалізацією;
  • кешувати дані конкретного користувача під спільним ключем - це витік чужих даних.

Докладніше в документації: Отримання елементів з кешу

Єдиний API поверх драйверів для зберігання результатів важких обчислень чи запитів. Драйвери: database (за замовчуванням у нових застосунках), file, redis, memcached, dynamodb, array (для тестів). Задається через CACHE_STORE у .env.

remember - найпоширеніший патерн (дістати з кешу або обчислити й закешувати):

$users = Cache::remember('active_users', 3600, function () {
    return User::where('active', true)->get();
});

Cache::put('key', $value, now()->addMinutes(10));
$value = Cache::get('key', 'default');
Cache::forget('key');

Теговане кешування (лише Redis/Memcached) - для групового скидання:

Cache::tags(['posts'])->put('post.1', $post, 600);
Cache::tags(['posts'])->flush(); // скинути всю групу

Атомарні блокування проти гонок (один процес у критичній секції):

Cache::lock('processing', 10)->get(function () {
    // критична секція
});

Найскладніше - інвалідація: кеш скидають у подіях/обзерверах моделей при зміні даних. Застарілий кеш часто гірший за його відсутність.

Докладніше в документації: Кешування

Найскладніше в кешуванні - не покласти значення, а вчасно його прибрати. Є три основні підходи.

1. Явне видалення при зміні:

class Category extends Model
{
    protected static function booted(): void
    {
        static::saved(fn () => Cache::forget('categories:menu'));
        static::deleted(fn () => Cache::forget('categories:menu'));
    }
}

Просто, але треба пам'ятати всі ключі, які залежать від моделі. Масові оновлення Category::query()->update() подій моделі не викликають - кеш лишиться старим.

2. Теги - скинути групу ключів однією командою:

Cache::tags(['posts', 'user:42'])->remember('user:42:posts', 3600, fn () => /* ... */);

Cache::tags('posts')->flush();   // усе, що стосується постів

Працює лише з драйверами, що підтримують теги (Redis, Memcached), - не з file і database.

3. Версія в ключі - нічого не видаляти, а змінювати ключ:

$version = Cache::rememberForever('posts:version', fn () => 1);
$posts = Cache::remember("posts:v{$version}:page:{$page}", 3600, fn () => /* ... */);

// при зміні
Cache::increment('posts:version');

Старі ключі просто перестають читатися й самі зникають за TTL. Працює з будь-яким драйвером.

Правило: короткий TTL - страховка на випадок, якщо скидання десь забули. Довгий TTL без надійного скидання - гарантовані скарги на застарілі дані.

Докладніше в документації: Теги кешу

Cache::flexible() реалізує підхід «stale-while-revalidate»: коли значення застаріло, користувач одразу отримує старе, а нове рахується у фоні.

$stats = Cache::flexible('dashboard:stats', [300, 900], function () {
    return DashboardStats::calculate();   // повільний розрахунок
});

Масив - два пороги в секундах:

  • до 300 - значення свіже, віддається як є;
  • від 300 до 900 - застаріле, але прийнятне: віддається одразу, а перерахунок запускається після відправлення відповіді;
  • після 900 - надто старе, рахується синхронно, як у remember().

Проблема remember(), яку це вирішує: коли TTL закінчився, саме той користувач, який прийшов першим, чекає на весь повільний розрахунок. На популярній сторінці кілька таких запитів одночасно ще й навантажать базу (cache stampede).

Коли підходить:

  • дані, де хвилина затримки не має значення: статистика, рейтинги, лічильники, зовнішні API;
  • розрахунок довгий, а сторінку відкривають часто.

Коли ні: ціни в кошику, залишки на складі, права доступу - там застаріле значення означає помилку, а не трохи старіші цифри.

Перерахунок у фоні виконується через відкладені функції (defer) після відправлення відповіді, тож окрема черга для цього не потрібна.

Докладніше в документації: Stale while revalidate

Cache Stampede (dogpile) - коли популярний ключ кешу протермінувався, і сотні одночасних запитів кидаються перераховувати важке значення одночасно, перевантажуючи БД.

Рішення в Laravel:

// блокування: лише один процес перераховує, інші чекають результат
$value = Cache::lock('report:lock', 10)->block(5, function () {
    return Cache::remember('report', 3600, fn () => $this->heavyReport());
});

Інші стратегії:

  • Cache::flexible() (stale-while-revalidate) - віддає «протухле» значення, поки одне фонове оновлення його перераховує.
  • Розмазування TTL (jitter), щоб ключі не протухали одночасно.
  • Прогрів кешу (cache warming) за розкладом, а не за запитом користувача.

Докладніше в документації: Кеш: атомарні блокування

Cache::lock() дає розподілене блокування: лише один процес на всіх серверах виконує ділянку коду одночасно.

$lock = Cache::lock("invoice:{$order->id}:generate", 30);

if ($lock->get()) {
    try {
        $order->generateInvoice();
    } finally {
        $lock->release();
    }
}

Або коротше - з очікуванням до 5 секунд:

Cache::lock("invoice:{$order->id}:generate", 30)->block(5, function () use ($order) {
    $order->generateInvoice();
});

Де це потрібно:

  • подвійний клік чи повторний вебхук не має створити два рахунки;
  • запланована команда, що запускається на кількох серверах;
  • перерахунок дорогого кешу - лише один процес рахує, інші чекають.

Важливі деталі:

  • Час життя блокування (30 с) - страховка, якщо процес упав і не звільнив його. Якщо робота може тривати довше, блокування закінчиться посередині, і зайде другий процес. Його подовжують через refresh().
  • Власник: release() звільняє лише своє блокування. Щоб звільнити з іншого процесу (наприклад, у завданні черги), передають токен через owner() і restoreLock().
  • Драйвер: потрібен спільний для всіх серверів - Redis, Memcached, database, DynamoDB. file блокує лише в межах одного сервера.

Блокування не замінює унікальних індексів у базі: індекс - остання лінія захисту, блокування лише зменшує кількість конфліктів.

Докладніше в документації: Атомарні блокування

Модель у кеші серіалізується цілком - з атрибутами, завантаженими зв'язками й службовим станом. Звідси кілька проблем.

1. Безпека десеріалізації. unserialize() даних, які можна підробити, - класичний шлях до виконання коду. У Laravel 13 конфігурація кешу має опцію serializable_classes, і за замовчуванням об'єкти з кешу не відновлюються. Щоб кешувати моделі, їхні класи (і класи вкладених колекцій) доводиться явно дозволити.

2. Застарілі дані. Закешована модель із завантаженими зв'язками - знімок усього дерева. Змінили автора - а в кеші поста він старий, і скинути треба вже не один ключ.

3. Зміна коду. Після деплою з новим атрибутом чи кастом старі серіалізовані об'єкти можуть відновитися в неконсистентному стані.

4. Розмір. Серіалізована модель зі зв'язками займає в рази більше пам'яті Redis, ніж потрібні поля.

Що кешують замість цього:

// ідентифікатори - а моделі дістають швидким запитом за ключем
$ids = Cache::remember('posts:popular', 600, fn () => Post::popular()->limit(10)->pluck('id')->all());
$posts = Post::with('author')->findMany($ids);

// або готові масиви для відображення
$menu = Cache::remember('menu', 3600, fn () => Category::query()->get(['name', 'slug'])->toArray());

Правило: кешують результат дорогого обчислення в найпростішій формі - скаляри, масиви, ID. Дорогим зазвичай є пошук і агрегація, а не завантаження рядка за первинним ключем.

Докладніше в документації: Збереження елементів у кеші