Многоуровневое кеширование с Redis¶
Это руководство знакомит с многоуровневым кешированием в Kora, Caffeine и Redis. В нем рассматривается, как быстрый локальный кеш L1 и общий кеш Redis L2 работают вместе, как Kora объединяет реализации кеша за одним кеш-договором, и как аннотации кеширования на уровне службы сохраняют чтения и инвалидацию согласованными. Вы также увидите, почему Redis рассматривается как общая кеш-инфраструктура, а не как источник истины.
Если в процессе захочется сверить результат, используйте готовое рабочее приложение: Kora Java Cache Multi Level App.
Если в процессе захочется сверить результат, используйте готовое рабочее приложение: Kora Kotlin Cache Multi Level App.
Что вы создадите¶
В этом руководстве вы превратите одноуровневый кеш из предыдущего руководства в двухуровневый кеш, где:
UserCaffeineCacheостается быстрым локальным кешем L1UserRedisCacheстановится общим кешем L2getUser()сначала проверяет Caffeine, затем Redis, затем репозиторийcreateUser()сразу прогревает оба уровня кешаupdateUser()обновляет оба уровня кешаdeleteUser()вытесняет устаревшие данные из обоих уровней кеша- HTTP API
/usersостается неизменным, а поведение кеша становится удобным для нескольких экземпляров приложения
Что понадобится¶
- JDK 17 или новее
- Gradle 7+
- текстовый редактор или среда разработки
- Docker или другая локальная среда выполнения Redis
- пройденное руководство Стратегии кеширования с Kora
Требования¶
Обязательно: пройдите руководство по кешированию
Это руководство предполагает, что вы уже прошли Стратегии кеширования с Kora и у вас уже есть те же Application, UserController, UserService, DTO и договор UserCaffeineCache из того руководства.
Если вы еще не прошли руководство по кешированию, сначала сделайте это, потому что здесь сохраняется тот же API /users и добавляется Redis как второй слой кеша.
Обзор¶
В предыдущем руководстве использовался только локальный кеш в памяти. Это хорошо работает, когда приложение запущено в одной JVM, потому что каждое повторное чтение можно обслужить из локальной памяти.
Но когда приложение запускается в нескольких подах или нескольких экземплярах, одного локального кеша для некоторых нагрузок уже недостаточно:
- у каждого пода свое содержимое кеша
- значение, прогретое в поде A, не становится автоматически видимым в поде B
- обновление, обработанное подом A, не обновляет локальную память пода B
- после перезапуска под снова начинает с пустым локальным кешем
Поэтому распространенный следующий шаг - многоуровневое кеширование.
В этом руководстве мы используем два слоя:
- L1: Caffeine Это тот же локальный кеш в памяти из предыдущего руководства. Это самый быстрый слой, идеально подходящий для горячих повторных чтений внутри одного процесса.
- L2: Redis Это общий распределенный кеш. Он медленнее локальной памяти, но все равно гораздо быстрее, чем каждый раз обращаться к первичному источнику.
Типичный процесс чтения теперь выглядит так:
- Сначала попробовать Caffeine.
- Если в Caffeine промах, попробовать Redis.
- Если в Redis тоже промах, загрузить из репозитория.
- Сохранить значение обратно в кеш, чтобы последующие чтения были дешевле.
Такая слоистая модель полезна, потому что уравновешивает скорость и совместное использование:
- Caffeine дает минимальную задержку для горячих значений внутри одного пода.
- Redis позволяет разным подам переиспользовать одно и то же кешированное значение.
- Репозиторий остается источником истины, когда в обоих кешах промах.
Redis как L2-кеш¶
Redis часто используют как кеш второго уровня, потому что он предоставляет:
- доступ в памяти с низкой задержкой
- общее состояние между экземплярами
- истечение срока действия ключей и префиксы ключей
- зрелые эксплуатационные инструменты
- простое развертывание для локальной разработки и промышленной среды
В этом руководстве Redis не рассматривается как источник истины. Это по-прежнему кеш. Репозиторий остается авторитетным источником, а Redis лишь помогает разным экземплярам приложения не повторять одни и те же обращения.
Модель кеша Kora¶
Полная модель композитного кеша описана в разделе композитного кеша, а Redis-уровень — в разделе Redis.
Поддержка кеша в Kora хорошо подходит для слоистых кешей, потому что кеш-договоры типизированы, а аннотации кеширования можно сочетать.
Это значит, что можно определить два отдельных кеш-интерфейса:
UserCaffeineCache extends CaffeineCache<String, UserResponse>UserRedisCache extends RedisCache<String, @Json UserResponse>
Затем можно разместить несколько аннотаций на одном методе службы:
@Cacheable(UserCaffeineCache.class)@Cacheable(UserRedisCache.class)
Kora применяет их в порядке объявления, сверху вниз. Поэтому на практике:
- сначала проверяется L1 Caffeine
- затем L2 Redis
- затем выполняется исходный метод, если в обоих кешах промах
Та же идея применима к @CachePut и @CacheInvalidate.
Зависимости¶
Добавьте кеширование Redis в существующее приложение руководства по кешированию.
Модули¶
Базовое руководство по кешированию уже включает CaffeineCacheModule. Для многоуровневого кеша графу приложения также нужен RedisCacheModule.
Обновите guides/guide-cache-multi-level-app/src/main/java/ru/tinkoff/kora/guide/cache/Application.java:
package ru.tinkoff.kora.guide.cache;
import ru.tinkoff.kora.application.graph.KoraApplication;
import ru.tinkoff.kora.cache.caffeine.CaffeineCacheModule;
import ru.tinkoff.kora.cache.redis.RedisCacheModule;
import ru.tinkoff.kora.common.KoraApp;
import ru.tinkoff.kora.config.hocon.HoconConfigModule;
import ru.tinkoff.kora.http.server.undertow.UndertowHttpServerModule;
import ru.tinkoff.kora.json.module.JsonModule;
import ru.tinkoff.kora.logging.logback.LogbackModule;
@KoraApp
public interface Application extends
HoconConfigModule,
JsonModule,
LogbackModule,
UndertowHttpServerModule,
CaffeineCacheModule, // <----- Подключили модуль
RedisCacheModule { // <----- Подключили модуль
static void main(String[] args) {
KoraApplication.run(ApplicationGraph::graph);
}
}
Обновите src/main/kotlin/ru/tinkoff/kora/guide/cache/Application.kt:
package ru.tinkoff.kora.guide.cache
import ru.tinkoff.kora.application.graph.KoraApplication
import ru.tinkoff.kora.cache.caffeine.CaffeineCacheModule
import ru.tinkoff.kora.cache.redis.RedisCacheModule
import ru.tinkoff.kora.common.KoraApp
import ru.tinkoff.kora.config.hocon.HoconConfigModule
import ru.tinkoff.kora.http.server.undertow.UndertowHttpServerModule
import ru.tinkoff.kora.json.module.JsonModule
import ru.tinkoff.kora.logging.logback.LogbackModule
@KoraApp
interface Application :
HoconConfigModule,
JsonModule,
LogbackModule,
UndertowHttpServerModule,
CaffeineCacheModule, // <----- Подключили модуль
RedisCacheModule // <----- Подключили модуль
fun main() {
KoraApplication.run(ApplicationGraph::graph)
}
Многоуровневый кеш контракт¶
Подробнее о Redis-кешах, сериализации значений и типизированных контрактах смотрите в Redis-разделе документации кеша.
Предыдущее руководство уже определило UserCaffeineCache. Теперь мы добавляем второй договор для Redis.
Этот договор важен по двум причинам:
- аннотации могут ссылаться на него декларативно
- тот же кеш можно внедрять напрямую, когда нужен ручной контроль
Кеш Caffeine хранит Java-объекты напрямую в локальной памяти, поэтому он может держать UserResponse как есть. Redis устроен иначе: значения должны сериализоваться перед записью в Redis и
десериализоваться при чтении обратно.
Чтобы этот шаблон чисто работал с Kora:
- оставьте
@Jsonна DTO, чтобы Kora знала, как сериализовать и десериализовать полезную нагрузку - пометьте тип значения Redis-кеша как
@Json UserResponse, чтобы кеш-договор явно говорил, какой сериализованный тип хранит Redis
@Json здесь также говорит Kora, что значение должно использовать сериализатор и десериализатор, квалифицированные тегом @Json. Для Redis-кешей модуль Redis предоставляет такой тегированный
преобразователь из коробки, поэтому это работает без дополнительного ручного связывания.
Та же идея гибкая: при желании можно использовать другой тег для ключа или типа значения, если в графе есть соответствующий преобразователь с этим тегом.
Это делает договор самоописательным и позволяет Kora автоматически разрешить правильный преобразователь вместо отдельного ручного преобразователя значения Redis.
В рабочем приложении Redis хранит UserResponse, поэтому договор использует String как ключ и @Json UserResponse как тип значения.
Создайте guides/guide-cache-multi-level-app/src/main/java/ru/tinkoff/kora/guide/cache/service/UserRedisCache.java:
package ru.tinkoff.kora.guide.cache.service;
import ru.tinkoff.kora.cache.annotation.Cache;
import ru.tinkoff.kora.cache.redis.RedisCache;
import ru.tinkoff.kora.guide.cache.dto.UserResponse;
import ru.tinkoff.kora.json.common.annotation.Json;
@Cache("cache.redis.users")
public interface UserRedisCache extends RedisCache<String, @Json UserResponse> {
}
Создайте src/main/kotlin/ru/tinkoff/kora/guide/cache/service/UserRedisCache.kt:
package ru.tinkoff.kora.guide.cache.service
import ru.tinkoff.kora.cache.annotation.Cache
import ru.tinkoff.kora.cache.redis.RedisCache
import ru.tinkoff.kora.guide.cache.dto.UserResponse
import ru.tinkoff.kora.json.common.annotation.Json
@Cache("cache.redis.users")
interface UserRedisCache : RedisCache<String, @Json UserResponse>
Многоуровневый кеш реализация¶
Служба продолжает ту, что была в руководстве по кешированию. Неизмененные части ведут себя так же:
getUsers()по-прежнему применяет сортировку и постраничную выдачу- вспомогательный компаратор остается тем же
- HTTP-поведение
404по-прежнему находится в службе
Здесь мы показываем только методы, которые меняются для многоуровневого кеширования:
getUser()-@Cacheableобъявлена дважды, поэтому Kora сначала проверяет Caffeine, а затем Redis.updateUser()-@CachePutобъявлена дважды, поэтому оба слоя обновляются после успешного обновления.deleteUser()-@CacheInvalidateобъявлена дважды, поэтому оба слоя вытесняют устаревшее значение.
Обновите измененные методы в guides/guide-cache-multi-level-app/src/main/java/ru/tinkoff/kora/guide/cache/service/UserService.java:
@Cacheable(UserCaffeineCache.class)
@Cacheable(UserRedisCache.class)
public Optional<UserResponse> getUser(String id) {
return userRepository.findById(id);
}
@CachePut(value = UserCaffeineCache.class, parameters = { "id" })
@CachePut(value = UserRedisCache.class, parameters = { "id" })
public UserResponse updateUser(String id, UserRequest request) {
boolean updated = userRepository.update(id, request.name(), request.email());
if (!updated) {
throw HttpServerResponseException.of(404, "User not found");
}
return new UserResponse(id, request.name(), request.email(), LocalDateTime.now());
}
@CacheInvalidate(UserCaffeineCache.class)
@CacheInvalidate(UserRedisCache.class)
public void deleteUser(String id) {
boolean deleted = userRepository.deleteById(id);
if (!deleted) {
throw HttpServerResponseException.of(404, "User not found");
}
}
Обновите измененные методы в src/main/kotlin/ru/tinkoff/kora/guide/cache/service/UserService.kt:
@Cacheable(UserCaffeineCache::class)
@Cacheable(UserRedisCache::class)
open fun getUser(id: String): UserResponse? {
return userRepository.findById(id).orElse(null)
}
@CachePut(value = UserCaffeineCache::class, parameters = ["id"])
@CachePut(value = UserRedisCache::class, parameters = ["id"])
open fun updateUser(id: String, request: UserRequest): UserResponse {
val updated = userRepository.update(id, request.name, request.email)
if (!updated) {
throw HttpServerResponseException.of(404, "User not found")
}
return UserResponse(id, request.name, request.email, LocalDateTime.now())
}
@CacheInvalidate(UserCaffeineCache::class)
@CacheInvalidate(UserRedisCache::class)
open fun deleteUser(id: String) {
val deleted = userRepository.deleteById(id)
if (!deleted) {
throw HttpServerResponseException.of(404, "User not found")
}
}
В окружении с N подами это значительно меняет поведение по сравнению с только локальным кешированием:
- промах в одном поде все равно может стать попаданием в Redis
- повторные чтения в том же поде по-прежнему получают пользу от скорости локального Caffeine
- обновления и удаления теперь обновляют или вытесняют общий уровень кеша, а не только память одного пода
Прогрейв кеша¶
createUser() все еще требует ручного управления кешем, потому что идентификатор пользователя сначала генерируется репозиторием.
Это один из самых понятных примеров того, почему типизированные кеш-договоры полезны: те же кеши, которые используются аннотациями, можно внедрять и контролировать напрямую.
Обновите путь создания в guides/guide-cache-multi-level-app/src/main/java/ru/tinkoff/kora/guide/cache/service/UserService.java:
public UserResponse createUser(UserRequest request) {
var generatedId = userRepository.save(request.name(), request.email());
var createdUser = new UserResponse(generatedId, request.name(), request.email(), LocalDateTime.now());
this.userCaffeineCache.put(createdUser.id(), createdUser);
this.userRedisCache.put(createdUser.id(), createdUser);
return createdUser;
}
Обновите путь создания в src/main/kotlin/ru/tinkoff/kora/guide/cache/service/UserService.kt:
fun createUser(request: UserRequest): UserResponse {
val generatedId = userRepository.save(request.name, request.email)
val createdUser = UserResponse(generatedId, request.name, request.email, LocalDateTime.now())
userCaffeineCache.put(createdUser.id, createdUser)
userRedisCache.put(createdUser.id, createdUser)
return createdUser
}
Так следующее чтение может сразу обслужиться из кеша, а Redis уже содержит новую сущность и для других экземпляров.
Конфигурация¶
Оставьте настройку HTTP-сервера из предыдущего руководства как есть. В этом руководстве обновите только конфигурацию кеша и Redis-клиента.
Обновите guides/guide-cache-multi-level-app/src/main/resources/application.conf:
Полный справочник по конфигурации смотрите в разделе Кеш.
cache.caffeine.users {
maximumSize = 1000 //(1)!
expireAfterWrite = "10m" //(2)!
}
cache.redis.users {
keyPrefix = "users-" //(3)!
expireAfterWrite = "30m" //(4)!
}
lettuce {
uri = "redis://localhost:6379" //(5)!
uri = ${?REDIS_URL} //(6)!
user = ${?REDIS_USER} //(7)!
password = ${?REDIS_PASS} //(8)!
socketTimeout = 15s //(9)!
commandTimeout = 15s //(10)!
}
- Максимальное число записей кеша перед началом вытеснения.
- Время, через которое запись Caffeine истекает после записи.
- Префикс, добавляемый к ключам, которые хранятся в Redis.
- Время, через которое запись Redis истекает после записи.
- URI подключения к локальному Redis по умолчанию.
- URI подключения. Необязательное переопределение из
REDIS_URL. - Имя пользователя, которое используется клиентским подключением. Необязательное переопределение из
REDIS_USER. - Пароль пользователя базы данных. Необязательное переопределение из
REDIS_PASS. - Тайм-аут операции сокета.
- Тайм-аут выполнения команды Redis.
cache:
caffeine:
users:
maximumSize: 1000 #(1)!
expireAfterWrite: "10m" #(2)!
redis:
users:
keyPrefix: "users-" #(3)!
expireAfterWrite: "30m" #(4)!
lettuce:
uri: ${?REDIS_URL:"redis://localhost:6379"} #(5)!
user: ${?REDIS_USER} #(6)!
password: ${?REDIS_PASS} #(7)!
socketTimeout: 15s #(8)!
commandTimeout: 15s #(9)!
- Максимальное число записей кеша перед началом вытеснения.
- Время, через которое запись Caffeine истекает после записи.
- Префикс, добавляемый к ключам, которые хранятся в Redis.
- Время, через которое запись Redis истекает после записи.
- URI подключения с локальным значением по умолчанию и необязательным переопределением из
REDIS_URL. - Имя пользователя, которое используется клиентским подключением. Необязательное переопределение из
REDIS_USER. - Пароль пользователя базы данных. Необязательное переопределение из
REDIS_PASS. - Тайм-аут операции сокета.
- Тайм-аут выполнения команды Redis.
Здесь важны несколько деталей:
cache.caffeine.usersсоответствуетUserCaffeineCachecache.redis.usersсоответствуетUserRedisCachekeyPrefixпредотвращает пересечения внутри RedisREDIS_URLпозволяет тестам или другим окружениям переопределить локальный URI по умолчанию
Docker Compose¶
Для локальных запусков руководства самый простой вариант - небольшой файл Docker Compose.
Создайте docker-compose.yml в каталоге модуля приложения:
services:
redis:
image: redis:8.2-alpine
ports:
- "6379:6379"
command: redis-server --save 60 1 --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
Запустите Redis:
Проверьте, что он здоров:
Запуск приложения¶
Используйте стандартный процесс запуска:
Рабочий сопутствующий модуль также проверяет многоуровневое поведение сфокусированными компонентными тестами с Redis.
Проверка приложения¶
Сначала создайте пользователя:
curl -X POST http://localhost:8080/users \
-H "Content-Type: application/json" \
-d '{"name":"John Doe","email":"john@example.com"}'
Прочитайте того же пользователя дважды. Второй запрос должен обслуживаться из кеша, а в настоящем развертывании с несколькими экземплярами Redis дает общий запасной слой, когда у другого экземпляра промах в локальном Caffeine.
Обновите пользователя. Оба уровня кеша обновляются:
curl -X PUT http://localhost:8080/users/1 \
-H "Content-Type: application/json" \
-d '{"name":"John Updated","email":"john.updated@example.com"}'
Удалите пользователя. Оба уровня кеша инвалидируются:
Если после этого хотите остановить локальный Redis:
Лучшие практики¶
- Оставляйте Caffeine первым слоем кеша для горячих чтений внутри процесса.
- Используйте Redis как общий слой кеша, а не как замену источника истины.
- Делайте кеш-договоры типизированными и явными, чтобы их можно было использовать и декларативно, и императивно.
- Ставьте
@Jsonна тип значения Redis, когда кешируете пользовательские DTO. - Держите семантику обновления кеша рядом с бизнес-операциями: обновляйте при изменении, вытесняйте при удалении, явно прогревайте при создании.
Итоги¶
Вы расширили руководство по одноуровневому кешу до многоуровневой кеш-архитектуры.
Получившееся приложение теперь использует:
- локальный
UserCaffeineCacheдля сверхбыстрых повторных чтений внутри одного экземпляра - общий
UserRedisCacheдля переиспользования между экземплярами - слоистые чтения
@Cacheable - слоистые обновления
@CachePut - слоистые вытеснения
@CacheInvalidate - ручной прогрев двух кешей в
createUser()
Ключевые понятия¶
- многоуровневое кеширование объединяет преимущества локальной задержки и общего распределенного переиспользования
- Caffeine и Redis решают разные задачи и хорошо работают вместе
- Kora применяет несколько аннотаций кеширования в порядке объявления
- значения Redis-кеша для пользовательских DTO требуют явного JSON-осознанного типа
- типизированные кеш-договоры можно внедрять напрямую для ручного управления кешем
Устранение неполадок¶
Сборка графа падает для RedisCacheValueMapper<...>:
Если вы кешируете пользовательский DTO в Redis, убедитесь, что договор Redis-кеша использует @Json на типе значения, например:
Без этого Kora может не сгенерировать преобразователь значения Redis для вашего DTO.
Подключение к Redis падает при запуске:
Проверьте, что Redis запущен и lettuce.uri указывает на доступный экземпляр:
Gradle зависает или неожиданно падает:
Остановите демоны Gradle и повторите:
Windows AccessDeniedException в кеше Gradle:
Если Windows держит открытые файловые дескрипторы в .gradle или build/, остановите демоны Gradle, закройте процессы среда разработки, которые все еще наблюдают за каталогом, и повторите команду.
Тесты Redis на основе Testcontainers падают:
Убедитесь, что Docker запущен и доступен из текущей оболочки. Тесты сопутствующего приложения используют Redis Testcontainers и динамически внедряют REDIS_URL.
Проблемы сборки Docker или контекста compose:
Если docker compose не может найти файл или запускается из неправильного места, выполняйте его из каталога модуля приложения, где находится docker-compose.yml.
Проверки готовности падают в последующих шагах наблюдаемости:
Если продолжаете это приложение с наблюдаемостью, помните, что закрытый управляющий API использует порт 8085, а готовность проверяется по /system/readiness.
Что дальше?¶
- Шаблоны устойчивости, чтобы защитить вызовы до того, как они заполнят локальный и распределенный кеш.
- Наблюдаемость, чтобы наблюдать за попаданиями в кеш, вызовами Redis, задержкой и сбоями.
- База данных JDBC, если хотите приложение с постоянным хранилищем перед сквозным тестированием как черный ящик.
- Сообщения с Kafka, когда кешированные модели чтения должны реагировать на события.
Помощь¶
Если столкнулись с проблемой:
- сравните с Kora Java Cache Multi Level App и Kora Kotlin Cache Multi Level App
- проверьте документацию кеша
- проверьте пример Redis-кеша
- вернитесь к Стратегиям кеширования за базой одноуровневого кеша