1. Spring-кеш як інфраструктура
Якщо ви тільки починаєте, дуже легко подумати: «кеш — це ж просто Map, навіщо стільки церемоній?». І справді: Map — чудова структура даних… доки у вас один метод і один сценарій. Але щойно кеш стає частиною застосунку, з’являються вже не такі захопливі, зате дуже практичні запитання: де його зберігати, як називати, як не дублювати код, як випадково не закешувати «не те» і як потім під’єднати Redis, не переписуючи половину сервісу. Саме тут Spring і допомагає зробити кешування інфраструктурною річчю, а не «локальними хитрощами в одному методі».
Уявіть, що ви додали ручний HashMap у CatalogService. Сьогодні ви кешуєте читання за id, завтра — список, післязавтра — ще один read-метод, а потім приходить колега й додає «ще один маленький кеш», але вже в іншому місці. За тиждень у вашому проєкті буде три «кеші», чотири місця, де вони живуть, і жодного розуміння, що саме кешується й чому.
У Spring ідея інша: кеш — це наскрізна інфраструктура, яка «обгортає» ваш метод читання. Тобто логіка читання залишається логікою читання, а кешування — це додатковий шар, який можна вмикати й вимикати конфігурацією, не переписуючи контролер або бізнес-код.
Щоб не виникало відчуття магії, корисно тримати в голові ось таку просту схему:
flowchart LR
C[Контролер] --> S[Сервіс запитів каталогу]
S --> P{Проксі кешу}
P -->|влучання| R[Повернути кешоване значення]
P -->|промах| ST[Сховище / репозиторій]
ST --> P
P --> R
Ззовні контролер викликає звичайний метод. Але всередині Spring перед викликом методу перевіряє кеш: якщо значення вже є — повертає його, якщо немає — виконує метод і кладе результат.
2. @EnableCaching: увімкнення кешування
Коли ви бачите анотацію @Cacheable, мозок автоматично очікує, що «ну вона ж є — значить кеш працює». Але Spring тут строгий: спочатку потрібно увімкнути інфраструктуру кешування, і лише потім анотації починають щось робити. Це як увімкнути Wi‑Fi на ноутбуці: можна дуже довго натискати «оновити сторінку», але доки Wi‑Fi вимкнено, сторінка оновлюватиметься виключно у вашій уяві. У Spring роль «перемикача Wi‑Fi» виконує @EnableCaching.
Розміщення @EnableCaching у проєкті
Ми розвиваємо Container-Ready Catalog Service, і в нас є важлива мета: кеш (а пізніше й Redis) — опційний шар. Він не має ламати базовий режим застосунку, а повинен вмикатися через профіль (у нашому випадку це буде cache). Тому гарний навчальний варіант — винести вмикання кешу в окрему конфігурацію й прив’язати її до профілю.
Мінімальна конфігурація може виглядати так:
package com.example.catalog.cache;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
@Configuration
@EnableCaching // Увімкнули інфраструктуру кешування (обробку @Cacheable/@CacheEvict тощо)
@Profile("cache") // Активуємо кешування лише тоді, коли ввімкнено профіль "cache"
public class CacheConfig {
// Конфігурація може бути порожньою: нам важливий сам факт увімкнення кешу.
}
Тут важливі одразу три моменти. По-перше, це @Configuration, щоб Spring узагалі побачив клас як конфігурацію. По-друге, @EnableCaching вмикає обробку анотацій @Cacheable. По-третє, @Profile("cache") робить так, що кешування активується лише тоді, коли ви явно ввімкнули профіль cache, тобто керовано, а не «раптово всюди».
Залежність spring-boot-starter-cache
Щоб не було сюрпризу рівня «чому анотація не знаходиться», у проєкті зазвичай підключають spring-boot-starter-cache. У Gradle це виглядає максимально нудно — і це добрий знак:
dependencies {
// Базова інфраструктура Spring Cache: анотації + CacheManager/Cache abstraction
implementation("org.springframework.boot:spring-boot-starter-cache")
}
Якщо ви вже бачите в проєкті spring-boot-starter-data-redis, не плутайте: Redis-стартер — це про клієнт Redis та інтеграцію з ним, а стартер кешу — про інфраструктуру кешування у Spring. Вони розв’язують різні задачі й потрібні на різних шарах однієї й тієї самої схеми.
3. @Cacheable: кешування читання
Після @EnableCaching настає приємна частина: ми нарешті робимо кешування не «якщо (map.containsKey…)», а декларативно. Анотація @Cacheable говорить Spring приблизно таке: «коли хтось викликає цей метод із такими-то аргументами, спробуй спочатку знайти результат у кеші; якщо знайшов — поверни його, якщо ні — виконай метод і збережи результат». Це важливо: сам метод залишається звичайним. Він і надалі «читає зі сховища», і це нормально. Просто насправді його викликатимуть рідше.
Читання за id
У нашому сервісі природний кандидат — читання елемента каталогу за ідентифікатором. Припустімо, у нас є шар читання CatalogQueryService, який звертається до CatalogItemStorage (а сховище вже всередині себе вибирає in-memory або JPA-шлях залежно від профілів).
Мінімально кешований метод виглядає так:
import com.example.catalog.cache.CacheNames;
import org.springframework.cache.annotation.Cacheable;
@Cacheable(CacheNames.CATALOG_ITEM_BY_ID) // Імʼя кешу: окрема "полиця" під читання за id
public CatalogItem findById(Long id) {
// За замовчуванням ключ будується з аргументів методу: тут ключем буде сам id
return storage.findById(id);
}
Ідея проста: кеш називається catalog-item-by-id, а ключем за замовчуванням стане id. Перший виклик із id=10 призведе до реального читання зі сховища. Другий виклик із тим самим id=10 (якщо кеш живий і не очищений) має повернути значення вже з кешу.
Як працює @Cacheable
Якщо описати роботу без занурення в глибини, Spring виконує послідовність приблизно таку. Він бере імʼя кешу, бере аргументи методу, будує з них ключ, запитує у CacheManager: «чи є значення для такого ключа?». Якщо є — повертає. Якщо немає — викликає ваш метод, бере результат і кладе туди ж.
Можна навіть записати це як псевдокод:
key = makeKey(methodArgs) // Збираємо cache key з аргументів методу (або з SpEL-виразу)
if (cache.contains(key)) {
return cache.get(key) // hit: значення вже є, реальний метод можна не викликати
}
result = callRealMethod() // miss: ідемо в сховище/БД/зовнішній сервіс
cache.put(key, result) // Зберігаємо результат, щоб наступний виклик був швидшим
return result
Сила підходу в тому, що ви перестаєте писати цей шаблон на кожному методі вручну. У проєкті буде менше «копіпасту», менше розбіжностей, а отже — менше сюрпризів.
4. Імена кешів і перелік дозволених
На старті хочеться зробити один кеш: «ну в нас же кеш один, Redis один, давайте все в одну корзину». Але це погана звичка. Для початківця це особливо небезпечно, тому що ви легко змішаєте різні типи даних, різні ключі та різні очікування, і отримаєте «працює, але інколи дивно». Тому в навчальному проєкті ми відразу тримаємо два окремі імені: одне для списку, інше для елемента за id.
Найкорисніша ментальна модель: імʼя кешу — це як назва полиці на складі. Якщо ви на одну полицю почнете складати і «деталі від велосипеда», і «пиріжки», і «документи відділу кадрів», то потім буде цікаво, але недовго. З кешем так само: різні сценарії — різні імена.
Ось невелика таблиця, яка допомагає не заплутатися:
| Сценарій читання | Метод | Імʼя кешу | Що є ключем | Що зберігається у значенні |
|---|---|---|---|---|
| Список елементів | findAll() | catalog-items | all | List<CatalogItem> |
| Елемент за id | findById(Long id) | catalog-item-by-id | id | CatalogItem |
Зверніть увагу: у списку й у елемента різна форма даних. Якщо ви змішаєте їх в одному кеші, то самі створите собі мініголоволомку: «чому я дістав із кешу список там, де чекав один елемент?».
Константи імен кешів
Рядки в анотаціях — класика «тихих багів». Один зайвий дефіс, і у вас раптово два різні кеші: catalog-item-by-id і catalog-item-by-Id. Тому зручний практичний прийом — винести імена в константи.
package com.example.catalog.cache;
public final class CacheNames {
// Кеш для методу findAll(): зберігає загальний список елементів каталогу
public static final String CATALOG_ITEMS = "catalog-items";
// Кеш для методу findById(Long id): зберігає окремі елементи за id
public static final String CATALOG_ITEM_BY_ID = "catalog-item-by-id";
private CacheNames() {
// Забороняємо створення екземплярів: це утилітарний клас із константами
}
}
Тепер ви використовуєте CacheNames.CATALOG_ITEM_BY_ID і помітно знижуєте шанс зробити «кеш через друкарську помилку».
spring.cache.cache-names: список кешів
Коли ви використовуєте @Cacheable("якась-імʼя"), Spring може створити кеш із цим іменем «на льоту» (залежно від конкретного CacheManager). Це зручно для прототипу, але погано для зрілого проєкту й особливо погано для навчального проєкту, де ми хочемо передбачуваності. Властивість spring.cache.cache-names дає змогу явно перелічити кеші, які ми вважаємо допустимими й очікуваними.
Іншими словами, це як список «дозволених полиць на складі». Ви заздалегідь кажете: «у проєкті є ось ці кеші, і тільки вони». І якщо завтра хтось випадково напише @Cacheable("catalog-item-by-iD"), ви принаймні швидко помітите, що щось пішло не так.
Мінімальна конфігурація виглядає так:
spring:
cache:
# Явно перелічуємо імена кешів, які вважаємо допустимими в застосунку
cache-names: catalog-items, catalog-item-by-id
Де це зберігати? У нашому випадку логічно тримати поруч із профільною конфігурацією cache (бо кеш — це можливість, що вмикається профілем), але сам принцип не залежить від того, використовуєте ви in-memory кеш чи Redis. Імена кешів — частина «контракту» всередині застосунку.
5. Ключ кешу
Коли люди вперше чують «ключ кешу», вони часто думають, що ключ — це лише id. Але в Spring ключ — це те, що виходить з аргументів методу (або з вашого явного правила). Це важливо, тому що кешування в анотаціях прив’язане не до HTTP-запиту, а до виклику методу. Тобто ви кешуєте не «GET /api/catalog/items/10», а «findById(10)».
Ключ для findById(Long id)
Тут усе інтуїтивно. Один аргумент — один ключ: 10, 11, 12… тому findById(10) і findById(11) — це два різні ключі, і hit для одного не означає hit для іншого.
Ключ для findAll()
А тут починається приємний навчальний «ага-момент». У методу findAll() немає аргументів, отже «вхідних даних» для ключа немає. Тому за змістом кеш матиме рівно одне значення: результат цього читання «як є». І це нормально: ви ж не просите «знайди список за параметрами», ви просите «дай список».
Щоб новачкам було простіше це усвідомити, інколи роблять ключ явним. Наприклад, ставлять рядковий ключ 'all'. Це не обов’язково, але часто робить код трохи більш промовистим:
import com.example.catalog.cache.CacheNames;
import org.springframework.cache.annotation.Cacheable;
@Cacheable(value = CacheNames.CATALOG_ITEMS, key = "'all'") // SpEL: рядковий ключ "all" для загального списку
public List<CatalogItem> findAll() {
// Метод не приймає аргументів, тому без явного key ключ також буде "один для всіх"
return storage.findAll();
}
Тут key = "'all'" — це рядок, і він явно говорить: «цей кеш зберігає один спільний список».
Лякатися не потрібно: це не «складна магія», а невеликий штрих, щоб поведінка була очевиднішою. Ми не перетворюємо курс на лекцію зі SpEL; нам просто важливо зрозуміти, що кеш працює через ключі, і ключ не завжди дорівнює id.
6. Кешування в проєкті: де має жити логіка
Дуже хочеться «зробити швидко» і почати кешувати прямо в контролері: мовляв, тут запити, тут і кеш. Але контролер — це HTTP-границя, і в неї є корисна суперсила: вона має залишатися максимально простою. Контролер уміє прийняти запит, викликати сервіс і повернути відповідь. Щойно він починає знати про кеш, про Redis і про «коли hit», він перестає бути межею і перетворюється на комбайн. А комбайн, як відомо, добрий тільки в полі. У бекенд-коді він зазвичай з’їдає підтримуваність.
Кешування в сервісі читання
Найкраща картина для нашого курсу така: контролер викликає метод читання, а той уже вирішує, буде це читання з кешу чи зі сховища. Контролер при цьому не змінюється, і саме це нам потрібно для профілів і контейнерного мислення: один і той самий HTTP-шар, різні інфраструктурні режими.
Мінімальний приклад «контролер нічого не знає»:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/api/catalog/items/{id}")
public CatalogItem getById(@PathVariable Long id) {
// Контролер не знає, чи ввімкнено кеш, Redis тощо — він просто викликає сервіс читання
return queryService.findById(id);
}
Контролер викликає queryService.findById(id) і взагалі не цікавиться, звідки прийшов результат. І якщо завтра ви під’єднали Redis або післязавтра вимкнули кеш через профіль — контролер не переписується.
@Cacheable і проксі: частий промах
Ось тут важливий нюанс, який ламає новачків. Анотації кешування в Spring працюють через проксі. Тобто Spring створює об’єкт-обгортку навколо вашого сервісу й перехоплює виклики методів, щоб вставити «перевірку кешу».
Звідси випливає просте правило: кешування спрацює лише тоді, коли метод викликано через Spring, тобто через бін із контексту.
Якщо ви десь, не треба так робити, але новачки інколи так роблять, напишете new CatalogQueryService(...), то жодного кешу не буде, бо Spring узагалі не бере участі. Так само кешування не спрацьовує під час «виклику методу самим собою» всередині одного класу, бо там теж обходиться проксі.
Дуже короткий приклад, який виглядає логічно, але поводиться неочікувано:
import com.example.catalog.cache.CacheNames;
import org.springframework.cache.annotation.Cacheable;
@Cacheable(CacheNames.CATALOG_ITEM_BY_ID) // Працюватиме лише під час виклику через проксі Spring
public CatalogItem findById(Long id) {
return storage.findById(id);
}
public CatalogItem callTwiceInsideSameBean(Long id) {
// ВАЖЛИВО: це внутрішній виклик методу в межах того самого об'єкта, проксі не бере участі
findById(id);
// Тому кешування може НЕ спрацювати: Spring не перехоплює self-invocation
return findById(id);
}
Чому так? Бо findById(id) усередині цього ж класу викликається як звичайний метод, без проходження через проксі. Тобто Spring не встигає «вставити» перевірку кешу.
У навчальному проєкті ми уникаємо цього максимально простим архітектурним рішенням: кешовані методи живуть в окремому сервісі читання, і їх викликає контролер, тобто ззовні біна. Тоді проксі працює, і кеш справді вмикається.
7. Підготовка до Redis без переписування коду
Найприємніша частина всієї історії в тому, що правильний Spring-код можна написати так, щоб йому взагалі було байдуже, де живе кеш: у пам’яті процесу або у зовнішньому Redis. Код залишається тим самим, змінюється оточення і конфігурація.
У термінах проєкту це дуже схоже на принцип same image, different runtime config. Ми не хочемо робити «Redis-версію контролера» або «Redis-версію сервісу». Ми хочемо один і той самий сервіс, який просто в профілі cache отримує додатковий шар поведінки.
Для цього в нас уже є три опори. Кешування вмикається через @EnableCaching і прив’язується до профілю cache, щоб базовий режим застосунку не залежав від Redis. Лише read-методи (findById, findAll) позначені через @Cacheable, контролери залишаються чистими. Імена кешів (catalog-items і catalog-item-by-id) винесені й зафіксовані через spring.cache.cache-names, щоб поведінка була передбачуваною і не з’являлися «нові кеші через описки».
Поки що ми свідомо не обговорюємо, як саме Redis приходить у Compose, який у нього порт, як робити redis-cli ping у healthcheck і як налаштовувати SPRING_DATA_REDIS_HOST. Це вже wiring зовнішньої залежності, а не нова теорія кешування.
До цього місця в нас є лише cache abstraction і анотації. Щоб той самий шар реально поїхав поверх зовнішнього Redis, на classpath потрібна Redis-інтеграція — зазвичай spring-boot-starter-data-redis; одного spring-boot-starter-cache для цього замало. Після цього конфігурація вже просто зв’яже готові @Cacheable-методи з Redis як backend, без переписування CatalogQueryService.
8. Типові помилки під час @EnableCaching і @Cacheable
Помилка № 1: @Cacheable написали, а @EnableCaching забули.
Це найчастіший сценарій: метод позначений, ви чекаєте дива, але Spring кешування не ввімкнено, і анотація залишається декоративною. Лікується просто: переконайтеся, що у вас є конфігураційний клас із @EnableCaching, що він справді підхоплюється контекстом, і, якщо ви використовуєте профілі, що профіль дійсно активовано.
Помилка № 2: кешують методи, які змінюють дані.
На цьому етапі ми кешуємо лише читання. Якщо ви позначите @Cacheable метод, який створює або оновлює сутність, ви отримаєте дивні ефекти й швидко перейдете до обговорення інвалідації кешу, а це окрема велика тема, яку ми сьогодні свідомо не відкриваємо. Тримайте кешування на query-методах, і життя буде простішим.
Помилка № 3: використовують одне імʼя кешу «на все».
Коли в один кеш складають і список, і елементи за id, і ще щось, починаються сюрпризи з ключами та типами значень. У навчальному сервісі краще одразу привчатися до дисципліни: одне імʼя кешу — один сценарій. Це банально, але працює.
Помилка № 4: описка в імені кешу перетворюється на новий кеш.
Якщо у вас десь написано catalog-items, а десь випадково catalog-item, частина CacheManager створить новий кеш «на льоту», і ви дивитиметеся на «кеш не працює» з дуже щирим виразом обличчя. Константи CacheNames і явний список spring.cache.cache-names допомагають зробити цю поведінку більш керованою.
Помилка № 5: кешування «не працює», бо метод викликається всередині того самого біна.
Це той самий ефект проксі: Spring не може перехопити внутрішній виклик методу, і @Cacheable не спрацьовує. Зазвичай це лікується архітектурно: кешовані методи живуть в окремому сервісі читання, який викликається ззовні, наприклад контролером. І так, це той рідкісний випадок, коли «трохи більше класів» робить код простішим для розуміння, а не складнішим.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ