1. «Кеш працює» — гіпотеза
З тим, як зберігати дані Redis, можна поводитися по-різному: залишити його тимчасовим або зберегти /data через named volume. Але на сам факт роботи кешу це не впливає. Поки Redis доступний і профіль cache увімкнено, головне запитання одне: чи справді повторне читання перестало звертатися до основного сховища, чи нам це лише здається?
У кешуванні є хитрий психологічний трюк: коли все налаштовано, воно стає… невидимим. Запити як повертали JSON, так і повертають. Код контролера як був простим, так і залишається простим. І саме тому новачок часто перевіряє кеш фразою «ну, ніби швидше стало» — а це приблизно як вимірювати температуру процесора долонею. Іноді працює, але у звіт це краще не писати.
У нашій реальності кеш має підтверджуватися не емоціями, а спостережуваними сигналами. Причому сигнали мають бути зрозумілими у Docker/Compose-середовищі, де є кілька контейнерів, а будь-яке «прискорення» може виявитися випадковим збігом (наприклад, JVM прогрілася, Postgres закешував сторінки, мережа спрацювала вдало). Тому сьогодні ми свідомо обираємо перевірку, яка спрацьовує бінарно: або метод справді сходив в основне сховище, або він взагалі туди не ходив, бо відповідь прийшла з Redis.
І ще одне важливе застереження. Ми не вимірюємо продуктивність, не будуємо бенчмарки й не тюнимо Redis. Для курсу з Docker це зайве. Ми формуємо стійку звичку: будь-який інфраструктурний шар має перевірятися через логи та сигнали так само, як ви перевіряєте, що контейнер узагалі запустився.
2. Що вважаємо cache hit і miss
Щоб не потонути в деталях, домовмося про просте визначення, яке добре працює саме в навчальному проєкті Container-Ready Catalog Service. Cache miss для нас — це ситуація, коли запит на читання змушує застосунок виконати справжню роботу: звернутися до основного сховища (у режимі postgres це буде PostgreSQL через JPA-репозиторій). Cache hit — це ситуація, коли той самий запит на читання повертає той самий результат без повторного звернення до основного сховища, бо значення вже лежить у Redis.
Важливо розуміти один нюанс Spring Cache, який нам стане у пригоді для діагностики. Анотація @Cacheable влаштована так, що під час hit Spring може взагалі не викликати ваш метод, а просто повернути збережений результат. Звучить як магія, але для спостережуваності це подарунок: якщо ми додамо лог саме в тіло кешованого методу, то цей лог з’являтиметься лише на miss. Під час hit його не буде, бо метод не виконувався. Це і є той бінарний сигнал, який потрібен новачкові.
Для наочності корисно тримати в голові таку картинку (дуже спрощено, але правильно за змістом):
sequenceDiagram
participant Client as "Клієнт (curl/Postman)"
participant App as "Застосунок (Spring Boot)"
participant Redis as Redis
participant Pg as PostgreSQL
Note over Client,App: "1-й запит (cache miss)"
Client->>App: GET /api/catalog/items/10
App->>Redis: "GET (ключ)"
Redis-->>App: "(порожньо)"
App->>Pg: SELECT ...
Pg-->>App: рядок
App->>Redis: "SET (ключ=значення)"
App-->>Client: 200 OK + JSON
Note over Client,App: "2-й запит (cache hit)"
Client->>App: GET /api/catalog/items/10
App->>Redis: "GET (ключ)"
Redis-->>App: значення
App-->>Client: 200 OK + JSON
Ще раз: ми не вивчаємо Redis як продукт, ми вивчаємо його як залежність Compose-стека. Тому нас цікавить лише один факт: «перший раз було порожньо, вдруге знайшли».
3. Лог у @Cacheable: робимо miss видимим
Найчастіша помилка новачка — намагатися побачити кеш у контролері. Контролер виконуватиметься на кожен HTTP-запит, незалежно від кешу, і це нічого не доведе. Нам потрібен лог там, де на hit роботи не буде. Ідеальне місце — тіло методу, позначеного @Cacheable.
Уявімо (або згадайте зі свого проєкту), що у нас є CatalogQueryService, який використовує контролер для читання даних. Ми кешуємо 1–2 сценарії: читання елемента за id і, можливо, читання списку. Логи робимо максимально простими, щоб по них можна було навчитися читати поведінку системи, а не вгадувати.
Приклад для читання за id:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cache.annotation.Cacheable;
public class CatalogQueryService {
private static final Logger log = LoggerFactory.getLogger(CatalogQueryService.class);
@Cacheable("catalog-item-by-id") // Ключ кешу залежить від параметра id (за замовчуванням Spring Cache враховує параметри методу)
public CatalogItem findById(Long id) {
// Цей лог з’явиться лише на cache miss: на cache hit метод зазвичай узагалі не викликається
log.info("Cache MISS -> завантажуємо елемент {} зі сховища", id);
// Справжня робота: звернення до основного сховища (БД/репозиторій/зовнішній сервіс)
return storage.findById(id);
}
}
Сенс цього шматка коду такий: якщо ви бачите в логах рядок Cache MISS -> ..., значить Spring виконав метод і ми справді звернулися до основного сховища. Якщо ви робите другий такий самий запит і рядок не повторюється, це сильний доказ, що метод було пропущено, а результат узяли з кешу.
Для списку ідея така сама. У списку немає аргументів, тому ключ «за замовчуванням» буде той самий (не лякайтеся цього; просто пам’ятайте, що findAll() — це один повторюваний read-сценарій):
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cache.annotation.Cacheable;
public class CatalogQueryService {
private static final Logger log = LoggerFactory.getLogger(CatalogQueryService.class);
@Cacheable("catalog-items") // Кешуємо результат "списку цілком" під одним ключем (аргументів немає)
public List<CatalogItem> findAll() {
// На повторному запиті цього логу не буде, якщо спрацював cache hit
log.info("Cache MISS -> завантажуємо список каталогів зі сховища");
// Тут ми явно показуємо момент звернення до сховища
return storage.findAll();
}
}
У цей момент зазвичай з’являється запитання: «А чому б не логувати прямо в репозиторії?» Можна, але тоді ви втрачаєте ключову перевагу: під час hit ваш метод не виконується, і ви бачите відсутність логу. Якщо лог поставити нижче, усередині репозиторію, то при hit ви теж не побачите його, але для новачка простіше триматися за сервісний метод, який він сам позначив @Cacheable.
І ще одне маленьке, але важливе зауваження про Docker-середовище. Логи мають іти в stdout/stderr (у нас це вже базовий рівень), отже ці log.info(...) будуть нормально читатися через docker compose logs. Тобто ми не будуємо окрему локальну діагностику; ми одразу робимо діагностику зручною для контейнерів.
4. Перевірка через HTTP і docker compose logs
Перевірка кешу має бути як хороший тест: повторюваною і нудною. Якщо вона викликає емоції — ви щось зробили не так, і це, найімовірніше, буде не кеш, а ефект плацебо. Тому ми обираємо один endpoint і один конкретний запит, який можна повторити двічі поспіль.
Типовий сценарій для навчального сервісу — читання елемента за id:
# Двічі поспіль робимо ОДИН І ТОЙ САМИЙ запит: так ми фіксуємо перехід miss -> hit
curl -i http://localhost:8080/api/catalog/items/10
curl -i http://localhost:8080/api/catalog/items/10
Якщо ви використовуєте .http файл або Postman — логіка та сама. Головне, щоб id був той самий. Інакше ви самі руками створюєте cache miss, а потім ображаєтеся на Redis.
Паралельно дивимося логи контейнера застосунку. У Compose це зручно робити так (ми не перетворюємо це на окремий «урок із команд», просто показуємо ідею):
# Дивимося stdout/stderr застосунку: там має з’явитися лог про MISS лише на першому запиті
docker compose logs -f app
І ви маєте побачити приблизно таку динаміку: на перший запит з’являється рядок Cache MISS -> ..., на другий — ні. Якщо ваш лог виглядає інакше, це не «погано», це просто сигнал, що потрібно зрозуміти, що саме ви перевіряєте.
Інколи зручно дивитися й логи Redis, щоб бачити, що контейнер живий і не падає на старті:
# Перевіряємо, що Redis як контейнер узагалі живий і пише логи
docker compose logs -f redis
Але не очікуйте, що Redis друкуватиме «cache hit» сам по собі. Redis — це просто сховище. «Hit» і «miss» — це зміст, який ми надаємо читанню на боці застосунку.
У контейнерному середовищі є ще одна корисна ознака того, що система взагалі жива, — health status. Якщо ви налаштували healthcheck (ми це робили в лекції про Compose wiring), то команда docker compose ps покаже healthy/unhealthy (формат залежить від версії Docker, але ідея одна). Це допомагає відрізнити ситуацію «кеш не працює» від ситуації «Redis навіть не стартував».
5. Перевіряємо Redis: exec, ping, monitor
Коли ви вперше додаєте нову залежність у Compose-стек, хочеться мати мінімум інструментів, щоб швидко переконатися, що це справді Redis, а не «контейнер, який удає роботу». Тут корисна проста дисципліна: спочатку перевіряємо готовність самої залежності, потім — інтеграцію застосунку з нею, і лише потім — бізнес-ефект (hit/miss).
Найпростіша перевірка Redis всередині контейнера — PING. У Compose це виглядає так:
# Перевіряємо, що redis-cli достукався до Redis-сервера всередині контейнера
docker compose exec redis redis-cli ping
# PONG
Якщо замість PONG ви бачите помилку, у вас проблема рівня «Redis не готовий або не працює» чи «ми execʼаємо не туди», а не проблема рівня Spring Cache.
Іноді — лише для розуміння, без перетворення дня на курс із Redis — корисно один раз побачити, що застосунок справді надсилає команди Redis. Для цього є режим підслуховування MONITOR. Він друкує всі команди, які Redis отримує. Це шумно, але в навчальному сценарії на кілька запитів — дуже наочно:
# УВАГА: monitor дуже шумний, запускайте на короткий час лише для діагностики
docker compose exec redis redis-cli monitor
Далі ви робите два однакові HTTP-запити в іншій вкладці, і в моніторі побачите, що Redis отримує GET/SET (точні команди та формат залежать від серіалізації й реалізації менеджера кешу, але сенс один: на першому запиті буде запис, на другому — читання вже наявного ключа).
Тут важливо не потрапити в типову пастку новачка: «Я не бачу в MONITOR рівно того, що очікував, отже кеш не працює». Не поспішайте. Spring Cache — це абстракція, вона може генерувати ключі не так, як ви б написали вручну, а серіалізація може бути бінарною. У цій лекції нас цікавить факт появи активності в Redis на першому запиті та більш «чисте» читання на другому, а не точна назва ключа. Точна назва ключа — це вже інша тема, і вона легко перетворює Docker-курс на Redis-клуб.
Якщо ж ви хочете найчесніший сигнал саме на рівні застосунку, то лог у методі @Cacheable (з попереднього розділу) залишається найкращим: це ваш код, ви йому довіряєте, і він безпосередньо прив’язаний до того, виконувався метод чи ні.
6. Якщо hit не видно: розбираємо причини
На практиці проблема «кеш не спрацював» майже завжди розпадається на кілька дуже земних причин. І чим раніше ви навчитеся відрізняти їх, тим менше часу проводитимете в режимі «я переписую YAML у надії, що всесвіт помітить мої старання».
Нижче — таблиця, яка допомагає швидко класифікувати ситуацію. Це не «чекліст на всі випадки життя», а саме підказка для нашого стека app + postgres + redis, щоб ви не плутали шари.
| Що бачите в поведінці | На що це схоже | Який сигнал перевірити насамперед |
|---|---|---|
| Лог Cache MISS -> ... з’являється і на першому, і на другому однаковому запиті | Кешування не спрацьовує (анотація не працює або виклик іде повз проксі) | Переконатися, що є @EnableCaching, метод public і викликається ззовні, а не зсередини того самого класу |
| Помилка під час старту застосунку про Redis host/connection refused | Redis недоступний по мережі або неправильний host | Перевірити SPRING_DATA_REDIS_HOST=redis (не localhost), docker compose ps, healthcheck Redis |
| Застосунок стартує, але в логах є повідомлення про те, що Redis вимкнено або не налаштовано (або взагалі тиша про cache) | Профіль cache не активовано | Перевірити SPRING_PROFILES_ACTIVE=postgres,cache і те, що ви справді стартували через Compose, а не через старий ручний запуск |
| У redis-cli ping немає PONG | Redis-контейнер не живий або не готовий | Спочатку лікуємо Redis як сервіс: логи, healthcheck, коректний image/tag, доступність redis-cli |
| Ви чекаєте hit, але щоразу запитуєте інший id | Перевірка неправильна | Для hit потрібен однаковий запит: той самий id, та сама кінцева точка |
Зверніть увагу: усі ці перевірки вкладаються в підхід курсу. Ми не будуємо складну observability-платформу, не вмикаємо важкі профілі й не йдемо в глибини JVM. Ми просто чесно дивимося на те, що відбувається: профілі, логи, readiness, DNS-імена в Compose.
І ще один нюанс, який інколи дивує. Якщо ви ввімкнули кеш і одразу робите запис (POST/PATCH), а потім читаєте — ви можете отримати «дивні» результати: кеш зберігає результат читання, а запис уже змінив дані. Ми свідомо не розв’язуємо це завдання сьогодні (це про інвалідацію й оновлення кешу). Тому smoke-check має бути максимально «чистим»: два однакові читання поспіль, без змін даних між ними. Це не тому, що «в реальному світі так не буває», а тому, що ми вчимося перевіряти сам факт увімкнення шару, а не проєктувати всі наслідки.
7. Правильна перевірка «кеш не працює»
Інколи Redis під’єднаний ідеально, анотації стоять правильно, Compose здоровий, а студент усе одно каже: «Не бачу cache hit». І далі зʼясовується, що перевірка була приблизно така: «Я двічі викликав GET /api/catalog/items/{id}, але id був різний». Або: «Я спочатку зробив PATCH, потім GET і очікував, що кеш одразу якось сам здогадається».
Кешування — річ сувора. У нього немає людської інтуїції. Воно працює за ключем, і ключ зазвичай залежить від аргументів методу. Якщо аргумент інший, то й ключ інший, а отже miss абсолютно чесний. Для людини «я двічі читаю одну й ту саму кінцеву точку» звучить як «те саме», але для кешу id=10 і id=11 — це два різні світи.
У нашому навчальному сценарії найкраще вибрати один елемент із seed-даних і працювати лише з ним. Тоді запити виходять максимально однаковими, ви не додаєте шуму, і поведінка miss → hit видно так само чітко, як різницю між docker compose down і docker compose down -v.
І тут же варто згадати важливий архітектурний принцип дня: контролер не має знати, чи увімкнено Redis, чи ні. Якщо ви починаєте змінювати контролер «щоб помітити кеш», ви ламаєте ідею профільного розширення. Контролер — це HTTP-межа, вона залишається однаковою. Змінюється поведінка всередині сервісу читання.
8. Типові помилки спостереження cache hit/miss
Помилка № 1: smoke-check робиться різними запитами, але очікується hit.
Найпопулярніший сценарій: «Я двічі запросив елемент, але id був різний». У цей момент кеш абсолютно правий, що щоразу робить miss, бо ключі різні. Для перевірки кешу в навчальний день потрібна одна й та сама кінцева точка з одним і тим самим параметром. Якщо ви хочете перевірити кеш списку, то список теж має бути «тим самим»: без випадкових фільтрів і без зміни даних між запитами.
Помилка № 2: забули активувати профіль cache, і Redis залишається «просто контейнером поруч».
У Compose Redis може бути ідеально запущений, redis-cli ping може відповідати PONG, але якщо застосунок стартував із профілем postgres без cache, то Spring Cache може навіть не бути увімкнено в потрібному вигляді. Тут допомагає проста звичка: в логах застосунку на старті ви маєте бачити активні профілі (ми це вже закріплювали в модулі про логи). Якщо немає cache — значить, ви перевіряєте кеш у режимі, де кешу немає. Це не баг, а правильна поведінка.
Помилка № 3: вказали Redis host як localhost всередині Compose.
Це вічна класика, і вона не застаріє, доки існує localhost. Усередині контейнера localhost — це сам контейнер. Redis живе в іншому контейнері й називається redis (service name). Тому SPRING_DATA_REDIS_HOST=redis — не естетика, а фізика. Якщо ви бачите connection refused, спочатку перевірте саме це, а не анотації в коді.
Помилка № 4: @Cacheable стоїть, але метод усе одно виконується на кожен запит — бо виклик іде повз проксі.
Кешування в Spring зазвичай реалізоване через проксі. Людською мовою це означає, що анотація «спрацьовує», коли метод викликається як bean-метод ззовні. Якщо ви всередині того самого класу викликаєте свій же @Cacheable-метод (самовиклик), проксі не бере участі, і кеш не застосовується. У навчальному проєкті найпростіше тримати кешовані читання в окремому сервісі (CatalogQueryService), який викликає контролер. Тоді виклик іде ззовні, і анотація працює очікувано.
Помилка № 5: намагаються довести hit через прискорення відповіді, а не через зникнення звернення до основного сховища.
«Стало швидше» — поганий індикатор. JVM може прогрітися, Postgres може кешувати сторінки, мережа може поводитися по-різному. Для нас хороший індикатор — зникнення справжньої роботи: лог Cache MISS -> ... перестав з’являтися на повторному запиті, або в Redis MONITOR видно, що другий запит — це читання вже наявного значення без запису.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ