Перейти до вмісту

L8. Кеш перед базою

середнійспирається на модуль 14

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

Результат: каталог labs/l08-redis-cache/solution/ із застосунком, у якому перед повільною карткою товару стоїть Valkey. Заготовка в labs/l08-redis-cache/starter/: сервер на node:http, db.js із пулом pg, card.js, reviews.js. Файл cache.js вам треба створити самим (клієнт redis, версія 6.3.0 у Docker-томі).

Картка рахується в PostgreSQL функцією product_card(id): середній рейтинг і кількість відгуків. Функція спить 300 мс (pg_sleep) і щоразу записує виклик у l08.calls. Застосунок ходить у базу від ролі l08_app, яка не може читати таблиць, лише викликати product_card() і додавати відгуки. Тому кількість викликів функції чесно рахує база, а обійти функцію власним запитом не вийде.

Контракт (міняти його не можна, чекер на нього спирається):

Маршрут Що повертає
GET /products/:id/card { product_id, title, price, rating, reviews_count }; 404, якщо товару немає
POST /products/:id/reviews тіло { customer_id, rating, title? }, відповідь 201 { review_id }

Змінні середовища: DATABASE_URL, REDIS_URL (адреса Valkey), CACHE_TTL (час життя картки в секундах, ваш застосунок має брати TTL звідти).

Готово, коли ./labs/l08-redis-cache/check.sh виводить «усе гаразд». Чекер перевіряє чотири речі:

  1. Кеш. Повторні запити тієї самої картки не викликають product_card(), а в Valkey з’являються ключі (кеш не в змінній процесу).
  2. Інвалідація. Після POST /reviews нова картка видна не пізніше ніж за 2 с, хоча TTL у перевірці 6 с. Отже, одного TTL мало.
  3. Stampede. Картку прочитали, дочекались кінця TTL, і 200 одночасних запитів дали не більше двох викликів product_card(); усі 200 відповідей правильні.
  4. Без Valkey. Чекер зупиняє контейнер Valkey. Картка має відповідати за 3 с (з бази), а новий відгук має прийматися.

Чого робити не треба: змінювати схему, lib/fixture.sql, server.js і контракт, міняти check/, кешувати в пам’яті процесу замість Valkey.

Прочитайте модуль 14, принаймні розділи про cache-aside, інвалідацію і stampede. Потрібні лише Docker і bash (Git Bash на Windows, WSL, Linux, macOS): застосунок і чекер працюють у контейнері node:24.14.1-alpine, залежності (pg і redis) лягають у Docker-том l08_node_modules під час першого запуску. Підніміть PostgreSQL із датасетом small (Датасет) і Valkey і переконайтеся, що docker context ls показує локальний Docker:

Terminal window
docker compose --profile postgres --profile redis up -d --wait
cp -r labs/l08-redis-cache/starter labs/l08-redis-cache/solution

Якщо ви запускаєте compose під власною назвою проєкту, передайте її: COMPOSE_PROJECT_NAME=моя-назва ./labs/l08-redis-cache/run.sh. Так само для measure.sh і check.sh.

  1. Запустіть заготовку й виміряйте її. В одному терміналі CACHE_TTL=6 ./labs/l08-redis-cache/run.sh (береться starter/, порт 3000, база shop; скрипт заодно створює в shop функцію, лічильник і роль). В іншому ./labs/l08-redis-cache/measure.sh. Заготовка не використовує кеш, тож measure.sh покаже, скільки викликів функції дають 21 послідовний запит і 200 одночасних. Запишіть числа. Перевірте руками:

    Terminal window
    curl -s localhost:3000/products/7/card
    docker compose exec postgres psql -U shop -d shop -c "SELECT count(*) FROM l08.calls WHERE product_id = 7"
  2. Cache-aside з TTL. Створіть solution/cache.js із клієнтом redis і напишіть getCard() у solution/card.js: ключ card:<id>, значення JSON, EX з CACHE_TTL. Запустіть run.sh solution і measure.sh: повторні запити не мають збільшувати l08.calls.

  3. Інвалідація. У solution/reviews.js після INSERT скиньте ключ картки. Перевірте руками: прочитайте картку, POST відгук, прочитайте знову.

    Terminal window
    curl -s -X POST localhost:3000/products/7/reviews -H 'content-type: application/json' \
    -d '{"customer_id": 1, "rating": 2}'

    Подумайте, що буде, якщо скидати ключ до INSERT, і що, якщо читач кладе в кеш стару картку якраз між вашим INSERT і DEL.

  4. Захист від stampede. measure.sh покаже, скільки викликів дають 200 одночасних запитів після закінчення TTL. Додайте один із захистів: блокування на перерахунок (SET … NX PX, решта чекає або йде в базу після таймауту) або stale-while-revalidate (прострочене віддається одразу, перераховує один). Перевірте, що викликів стало один-два.

  5. Valkey вимкнено. Зупиніть його (docker compose --profile redis stop redis) і зробіть запит. Застосунок має відповісти з бази, а не зависнути чи повернути 500. Не забудьте запустити Valkey знову.

  6. Здача. ./labs/l08-redis-cache/check.sh. Кожен пункт, що не пройшов, каже, на якому кроці це видно й що перевірити.

Terminal window
./labs/l08-redis-cache/check.sh # ваш розвʼязок з labs/l08-redis-cache/solution/
./labs/l08-redis-cache/check.sh інший/каталог
L08_TTL=8 ./labs/l08-redis-cache/check.sh # інший CACHE_TTL під час перевірки

Чекер працює на окремій копії датасету (база l08_run у вашому контейнері postgres; shop він не змінює), запускає ваш застосунок з CACHE_TTL=6 і виконує перевірки в такому порядку: кеш, інвалідація, stampede (з паузою 6.7 с на закінчення TTL), потім зупиняє Valkey й перевіряє застосунок без нього. Лічильник викликів веде функція в базі, тож обманути його не вийде. Якщо допоміжний процес чекера впав або не дав результату, чекер це повідомляє як помилку, а не мовчить. Перевірка триває близько хвилини.

Команди до недоступного Valkey стоять у черзі клієнта. redis за замовчуванням тримає команди, поки не відновить з’єднання, тож запит висне. Вимкніть чергу (disableOfflineQueue: true) або поставте таймаут і ловіть помилку: на ній застосунок іде в базу.

DEL до INSERT. Між ними читач кладе в кеш стару картку, і вона житиме до кінця TTL. Спершу запис у базу, потім скидання ключа.

Блокування без PX. Якщо власник упав, блокування лишається назавжди, і ключ не оновлюється ніколи. Знімати блокування треба порівнянням токена, а не просто DEL.

Необроблена помилка у фоновому перерахунку. У варіанті stale-while-revalidate перерахунок запускають без await. Відхилений Promise без .catch() завершує процес Node, і застосунок падає разом із чекером.

Кеш у змінній процесу. Map теж не викликає функцію вдруге, але чекер дивиться на ключі у Valkey, а застосунків може бути кілька.

TTL зашито в код. Чекер задає CACHE_TTL=6. Застосунок із TTL 60 с не дасть очікуваного закінчення ключа і стане чекати.

Додайте до TTL випадковий розкид (jitter) і виміряйте, як змінюється кількість викликів при безперервному навантаженні: RATE=100 SECS=30 на одну картку дають п’ять закінчень TTL. Реалізуйте раннє ймовірнісне оновлення (формула в модулі 14) і порівняйте його з блокуванням за кількістю викликів і за запитами довшими за 200 мс. Спробуйте maxmemory 2mb із allkeys-lru і з volatile-ttl на сотнях карток і подивіться на evicted_keys та hit rate.