L8. Кеш перед базою
Побачити на працюючому застосунку те, що в модулі 14 описано цифрами: що дає кеш, чому він бреше після запису і що робить база, коли ключ зникає під навантаженням. Після роботи ви вмієте поставити кеш перед повільним запитом так, щоб він мав інвалідацію, не створював лавини запитів до бази й не робив застосунок залежним від свого життя.
Завдання
Section titled “Завдання”Результат: каталог 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 виводить «усе гаразд». Чекер перевіряє чотири речі:
- Кеш. Повторні запити тієї самої картки не викликають
product_card(), а в Valkey з’являються ключі (кеш не в змінній процесу). - Інвалідація. Після
POST /reviewsнова картка видна не пізніше ніж за 2 с, хоча TTL у перевірці 6 с. Отже, одного TTL мало. - Stampede. Картку прочитали, дочекались кінця TTL, і 200 одночасних запитів дали не більше двох викликів
product_card(); усі 200 відповідей правильні. - Без Valkey. Чекер зупиняє контейнер Valkey. Картка має відповідати за 3 с (з бази), а новий відгук має прийматися.
Чого робити не треба: змінювати схему, lib/fixture.sql, server.js і контракт, міняти check/, кешувати в пам’яті процесу замість Valkey.
Перед початком
Section titled “Перед початком”Прочитайте модуль 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:
docker compose --profile postgres --profile redis up -d --waitcp -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.
-
Запустіть заготовку й виміряйте її. В одному терміналі
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/carddocker compose exec postgres psql -U shop -d shop -c "SELECT count(*) FROM l08.calls WHERE product_id = 7" -
Cache-aside з TTL. Створіть
solution/cache.jsіз клієнтомredisі напишітьgetCard()уsolution/card.js: ключcard:<id>, значення JSON,EXзCACHE_TTL. Запустітьrun.sh solutionіmeasure.sh: повторні запити не мають збільшуватиl08.calls. -
Інвалідація. У
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. -
Захист від stampede.
measure.shпокаже, скільки викликів дають 200 одночасних запитів після закінчення TTL. Додайте один із захистів: блокування на перерахунок (SET … NX PX, решта чекає або йде в базу після таймауту) або stale-while-revalidate (прострочене віддається одразу, перераховує один). Перевірте, що викликів стало один-два. -
Valkey вимкнено. Зупиніть його (
docker compose --profile redis stop redis) і зробіть запит. Застосунок має відповісти з бази, а не зависнути чи повернути 500. Не забудьте запустити Valkey знову. -
Здача.
./labs/l08-redis-cache/check.sh. Кожен пункт, що не пройшов, каже, на якому кроці це видно й що перевірити.
Перевірка
Section titled “Перевірка”./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 й перевіряє застосунок без нього. Лічильник викликів веде функція в базі, тож обманути його не вийде. Якщо допоміжний процес чекера впав або не дав результату, чекер це повідомляє як помилку, а не мовчить. Перевірка триває близько хвилини.
Часті помилки
Section titled “Часті помилки”Команди до недоступного Valkey стоять у черзі клієнта. redis за замовчуванням тримає команди, поки не відновить з’єднання, тож запит висне. Вимкніть чергу (disableOfflineQueue: true) або поставте таймаут і ловіть помилку: на ній застосунок іде в базу.
DEL до INSERT. Між ними читач кладе в кеш стару картку, і вона житиме до кінця TTL. Спершу запис у базу, потім скидання ключа.
Блокування без PX. Якщо власник упав, блокування лишається назавжди, і ключ не оновлюється ніколи. Знімати блокування треба порівнянням токена, а не просто DEL.
Необроблена помилка у фоновому перерахунку. У варіанті stale-while-revalidate перерахунок запускають без await. Відхилений Promise без .catch() завершує процес Node, і застосунок падає разом із чекером.
Кеш у змінній процесу. Map теж не викликає функцію вдруге, але чекер дивиться на ключі у Valkey, а застосунків може бути кілька.
TTL зашито в код. Чекер задає CACHE_TTL=6. Застосунок із TTL 60 с не дасть очікуваного закінчення ключа і стане чекати.
Далі, якщо цікаво
Section titled “Далі, якщо цікаво”Додайте до TTL випадковий розкид (jitter) і виміряйте, як змінюється кількість викликів при безперервному навантаженні: RATE=100 SECS=30 на одну картку дають п’ять закінчень TTL. Реалізуйте раннє ймовірнісне оновлення (формула в модулі 14) і порівняйте його з блокуванням за кількістю викликів і за запитами довшими за 200 мс. Спробуйте maxmemory 2mb із allkeys-lru і з volatile-ttl на сотнях карток і подивіться на evicted_keys та hit rate.