Spring Boot умеет работать с Redis «из коробки» — достаточно добавить зависимость, указать адрес и выбрать нужный инструмент: ручные операции через RedisTemplate, автоматический кэш через @Cacheable или хранение сессий через Spring Session.
Между клиентом и методом стоит прокси, который создаёт @EnableCaching. Он идёт в Redis за ключом products::42 и заходит в тело метода, только если ключа там нет; результат он же и кладёт обратно со сроком жизни. Отсюда два следствия: вызов метода изнутри того же класса минует прокси и кэш не работает, а выбросить ключ раньше срока — дело @CacheEvict.
Подключение и клиент Lettuce
Первое, что нужно приложению, — соединение с Redis, которое можно делить между потоками и не открывать на каждый запрос. Его даёт клиент Lettuce, асинхронный, потокобезопасный, основанный на Netty, и Spring Data Redis использует его по умолчанию. Добавляем зависимость:
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
Настройка в application.yml:
spring:
data:
redis:
host: localhost
port: 6379
# password: secret # если включена аутентификация
# database: 0 # номер логической базы (0 по умолчанию)
timeout: 2s
Пул соединений здесь намеренно не настроен. Клиент Lettuce держит одно соединение и мультиплексирует по нему все команды — этого хватает подавляющему большинству приложений. Пул (lettuce.pool) нужен в редких случаях вроде блокирующих команд или транзакций, и включается он не сам: без зависимости commons-pool2 в classpath настройки просто игнорируются.
Spring Boot автоматически создаёт LettuceConnectionFactory, а вместе с ним два шаблона: RedisTemplate<Object, Object> и StringRedisTemplate. Переопределять бин подключения нужно только при нестандартной конфигурации (кластер, Sentinel, TLS).
RedisTemplate и StringRedisTemplate
Кэш через аннотации закрывает не всё: счётчик просмотров, очередь задач или множество пользователей онлайн требуют команд Redis напрямую. Для этого есть RedisTemplate, универсальный инструмент для прямых операций, с «операциями» по типу структуры данных:
@Service
public class CounterService {
private final StringRedisTemplate redis;
public CounterService(StringRedisTemplate redis) {
this.redis = redis;
}
public void increment(String key) {
Long value = redis.opsForValue().increment(key);
if (value != null && value == 1L) {
redis.expire(key, Duration.ofHours(1));
}
}
public Long get(String key) {
String value = redis.opsForValue().get(key);
return value != null ? Long.parseLong(value) : 0L;
}
}
Проверка value == 1L здесь не для красоты. increment возвращает новое значение счётчика, и единица означает «ключа только что не было, счётчик завели сейчас» — только в этот момент и нужно ставить срок жизни. Выставлять его после каждого вызова нельзя: срок будет сбрасываться на каждом запросе, и под постоянным потоком счётчик не истечёт никогда.
StringRedisTemplate — это RedisTemplate<String, String> со строковой сериализацией. Подходит, когда и ключ, и значение — строки.
Операции по типу структуры:
| Метод | Тип данных Redis |
|---|---|
opsForValue() | String (скалярное значение) |
opsForHash() | Hash |
opsForList() | List |
opsForSet() | Set |
opsForZSet() | Sorted Set |
Грабля: сериализация по умолчанию
Если использовать RedisTemplate<String, Object> без настройки, значения сохраняются через JdkSerializationRedisSerializer. Данные в Redis выглядят как нечитаемый байтовый поток и привязаны к конкретным Java-классам — при изменении класса десериализация сломается.
Одно и то же значение в Redis: сверху байты JDK-сериализации, снизу тот же объект текстом JSON.
Правильный подход — настроить JSON-сериализацию:
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
PolymorphicTypeValidator validator = BasicPolymorphicTypeValidator.builder()
.allowIfSubType("com.example.")
.build();
ObjectMapper mapper = new ObjectMapper();
mapper.activateDefaultTyping(validator, ObjectMapper.DefaultTyping.NON_FINAL);
Jackson2JsonRedisSerializer<Object> serializer =
new Jackson2JsonRedisSerializer<>(mapper, Object.class);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(serializer);
return template;
}
}
Сериализаторов здесь четыре, потому что у значения в Redis два уровня. У обычной строки есть ключ и есть само значение, за них отвечают setKeySerializer и setValueSerializer. У хэша внутри ключа лежат ещё поле и значение поля, и им нужны свои сериализаторы: setHashKeySerializer и setHashValueSerializer. Имена, то есть ключ и поле, делают строковыми, чтобы их было видно глазами в redis-cli, а данные пишут тем же JSON.
Список разрешённых пакетов здесь обязателен: без него Jackson восстановит объект любого класса, имя которого записано в самом значении, — и тот, кто может писать в Redis, выполнит код в приложении.
После такой настройки значение в Redis уже читается глазами, но голым объектом оно не выглядит. Включённая типизация оборачивает его в пару «имя класса, данные»:
["com.example.Product",{"id":42,"name":"Widget","price":9.99}]
Имя класса — это и есть то, по чему Jackson потом восстанавливает нужный тип. Оно же означает, что данные в Redis привязаны к вашим классам: переименуете Product или перенесёте его в другой пакет — старые значения перестанут читаться. Обычно с этим мирятся и полагаются на срок жизни ключа, который рано или поздно вытеснит старый формат.
Spring Cache поверх Redis
Spring Cache — абстракция над кэшем: метод помечается аннотацией, а Spring сам решает, идти в базу или вернуть сохранённый результат.
Зависимость и включение
implementation 'org.springframework.boot:spring-boot-starter-cache'
@SpringBootApplication
@EnableCaching
public class Application { ... }
@Cacheable, @CacheEvict, @CachePut
@Service
public class ProductService {
@Cacheable(value = "products", key = "#id")
public Product findById(Long id) {
// вызывается только при отсутствии в кэше
return repository.findById(id).orElseThrow();
}
@CacheEvict(value = "products", key = "#product.id")
public void update(Product product) {
repository.save(product);
// кэш для этого ключа сбрасывается
}
@CachePut(value = "products", key = "#result.id")
public Product create(Product product) {
return repository.save(product);
// метод всегда выполняется, результат записывается в кэш
}
@CacheEvict(value = "products", allEntries = true)
public void invalidateAll() {
// удалить весь кэш products
}
}
У последней строки есть цена, о которой аннотация не предупреждает. Чтобы вычистить весь кэш, Spring должен сначала найти все ключи по шаблону products::* — и по умолчанию делает это командой KEYS, той самой, которая проходит всё пространство ключей за один раз и на это время лишает Redis возможности отвечать кому бы то ни было. На пустой базе разницы не видно, на боевой с миллионами ключей invalidateAll() превращается в короткую остановку всего сервиса.
Лечится это при сборке RedisCacheManager — ниже он всё равно понадобится для сроков жизни: вместо менеджера по умолчанию берут писателя с постраничным обходом, и тогда ключи собираются командой SCAN порциями:
RedisCacheWriter writer = RedisCacheWriter
.nonLockingRedisCacheWriter(factory, BatchStrategies.scan(1000));
Механику видно и без Spring: прокси — обёртка, которая смотрит в хранилище по ключу и заходит в тело метода, только когда там пусто.
живой пример
import java.util.HashMap;
import java.util.Map;
public class CacheProxyDemo {
static int bodyCalls = 0;
public static void main(String[] args) {
Map<String, String> redis = new HashMap<>();
System.out.println("1) " + findById(redis, 42));
System.out.println("2) " + findById(redis, 42));
redis.remove("products::42");
System.out.println("@CacheEvict удалил products::42");
System.out.println("3) " + findById(redis, 42));
System.out.println("вызовов тела метода: " + bodyCalls);
}
static String findById(Map<String, String> redis, int id) {
String key = "products::" + id;
String cached = redis.get(key);
if (cached != null) {
return cached + " <- из Redis";
}
bodyCalls++;
String value = "{\"id\":" + id + ",\"title\":\"Widget\"}";
redis.put(key, value);
return value + " <- из тела метода";
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
RedisCacheManager и TTL
По умолчанию кэш живёт бесконечно. Задать TTL можно через RedisCacheManager:
@Configuration
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration defaults = RedisCacheConfiguration
.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.serializeValuesWith(
RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer())
);
Map<String, RedisCacheConfiguration> cacheConfigs = Map.of(
"products", defaults.entryTtl(Duration.ofHours(1)),
"sessions", defaults.entryTtl(Duration.ofMinutes(5))
);
RedisCacheWriter writer = RedisCacheWriter
.nonLockingRedisCacheWriter(factory, BatchStrategies.scan(1000));
return RedisCacheManager.builder(writer)
.cacheDefaults(defaults)
.withInitialCacheConfigurations(cacheConfigs)
.build();
}
}
RedisCacheConfiguration — настройка одного кэша, RedisCacheManager собирает их вместе и регистрирует в Spring. Писатель с BatchStrategies.scan — то самое лечение allEntries = true, о котором шла речь выше: без него менеджер соберёт ключи командой KEYS.
Настройки кэша, которые отличают рабочую конфигурацию от демонстрационной
sync = true — при промахе значение вычисляет один поток, остальные ждут результата. Это штатный ответ на толпу за одним ключом, и у него есть граница: синхронизация работает внутри одной виртуальной машины, на трёх экземплярах сервиса будет три вычисления.
condition и unless — когда кэшировать. condition проверяется до вызова метода (по аргументам), unless — после (по результату). Типичное применение: не класть в кэш пустые ответы или слишком большие значения.
@Cacheable(cacheNames = "products", key = "#id", sync = true, unless = "#result == null")
public ProductView byId(long id) { ... }
Кэширование пустого ответа. По умолчанию null в кэш не кладётся вовсе (а в конфигурации Redis его можно и запретить явно — disableCachingNullValues). Решение тут не техническое, а продуктовое: если запросы к несуществующим идентификаторам приходят массово, пустой ответ надо кэшировать — это и есть лекарство от пробивания кэша, разобранное в статье про шаблоны кэширования. Тогда unless не ставят, а срок жизни для пустых делают коротким.
Как строится ключ по умолчанию. Если key не задан, работает генератор по умолчанию: нет аргументов — ключ-пустышка, один аргумент — сам аргумент, несколько — составной ключ из всех. Отсюда две ловушки. Метод от двух аргументов даёт ключ, у которого строковое представление зависит от toString аргументов, — с объектами без осмысленного toString ключи получаются нечитаемыми и, что хуже, у разных объектов могут совпасть. И добавление третьего аргумента в метод молча меняет форму ключа, то есть весь прежний кэш становится недостижимым. Поэтому ключ задают явно: key = "#id" или key = "#customerId + ':' + #status".
Самовызов. Аннотация работает через прокси, поэтому вызов соседнего метода того же класса кэш не задействует — как и в случае с транзакциями. То есть this.byId(id) из другого метода того же бина пойдёт мимо кэша, и никакой ошибки не будет: просто кэш перестанет работать, а обнаружится это по нулевому коэффициенту попаданий. Лечится так же: вынести кэшируемый метод в отдельный бин.
Как измерить, что кэш вообще работает
Spring публикует метрики кэшей, если у менеджера кэшей включена статистика. Тогда в Actuator появляются cache.gets с тегом результата (hit или miss), cache.puts, cache.evictions — разложенные по именам кэшей. Это и есть ответ на вопрос «работает ли аннотация»: ноль обращений означает, что метод вызывается мимо прокси; много промахов при том же ключе — что ключ каждый раз разный.
Рядом смотрят метрики клиента Redis (задержка команд) и INFO stats самого сервера. Порядок разбора простой: сначала убедиться, что обращения к кэшу вообще есть, потом — что они попадают, потом — что это экономит время.
Когда Redis недоступен
По умолчанию всё устроено неудачно: исключение из операции кэша вылетает в вызывающий метод, то есть недоступный кэш роняет запрос, который прекрасно отработал бы через базу.
Правильное поведение задаётся тремя настройками. Короткие таймауты у клиента (единицы-десятки миллисекунд на команду — кэш либо отвечает быстро, либо не нужен). CacheErrorHandler, который перехватывает ошибки чтения и записи, пишет их в журнал с метрикой и позволяет работе продолжиться. И размыкатель, чтобы при длительной недоступности не платить таймаутом на каждом запросе.
@Configuration
class CacheConfig implements CachingConfigurer {
@Override
public CacheErrorHandler errorHandler() {
return new SimpleCacheErrorHandler() {
@Override
public void handleCacheGetError(RuntimeException e, Cache cache, Object key) {
log.warn("кэш {} недоступен, идём в базу", cache.getName());
}
};
}
}
Обязательная проверка к этому решению: база должна выдерживать полную нагрузку без кэша. Если не выдерживает, деградация превращается в каскадный отказ, и нужен второй уровень — локальный кэш в памяти приложения.
Кластер и Sentinel: обновление топологии
Настройки подключения к кластеру обычно берутся из конфигурации сами, но одну вещь надо включить руками, и её пропускают почти всегда: обновление топологии у клиента. По умолчанию Lettuce запоминает раскладку слотов по узлам при подключении и больше её не перечитывает. Переключился мастер, узел уехал — клиент продолжает ходить по старым адресам и отвечает ошибками до перезапуска приложения.
@Bean
LettuceClientConfigurationBuilderCustomizer topologyRefresh() {
return builder -> builder.clientOptions(ClusterClientOptions.builder()
.topologyRefreshOptions(ClusterTopologyRefreshOptions.builder()
.enablePeriodicRefresh(Duration.ofSeconds(30))
.enableAllAdaptiveRefreshTriggers()
.build())
.build());
}
Периодическое обновление перечитывает раскладку по таймеру, адаптивное — при ошибках вида «слот переехал». Для Sentinel смысл тот же: клиент должен узнать о смене мастера, и проверяется это учениями — погасить мастер и посмотреть, восстановится ли приложение само.
Сессии через Spring Session
Spring Session переносит пользовательские HTTP-сессии из памяти приложения в Redis. Это даёт две вещи:
- Горизонтальное масштабирование — несколько экземпляров приложения работают с общим хранилищем сессий, пользователь не теряет сессию при переключении между ними.
- Переживание перезапуска — сессии не исчезают при рестарте приложения.
Сессия лежит в Redis, поэтому следующий запрос того же пользователя обслуживает другой экземпляр и вход не теряется.
Зависимость:
implementation 'org.springframework.session:spring-session-data-redis'
В application.yml:
spring:
session:
timeout: 30m
redis:
flush-mode: on-save # писать сессию в конце запроса, а не сразу
namespace: "myapp:session"
Отдельно указывать тип хранилища не нужно: в Spring Boot 3 свойства store-type больше нет, хранилище выбирается по зависимости в classpath. Boot сам создаст RedisSessionRepository и подключит фильтр SessionRepositoryFilter. Если понадобится искать сессии по идентификатору пользователя или получать события об их истечении, включите расширенный вариант: spring.session.redis.repository-type: indexed. Существующий код с HttpSession работает без изменений.
Если проект уже на Spring Boot 4, имена свойств переехали: префикс spring.session.redis стал spring.session.data.redis, чтобы совпадать с названием зависимости. Смысл настроек прежний, меняется только путь.
Как это выглядит в Redis
Ключи в Redis после запуска выглядят примерно так:
# Кэш через @Cacheable
> KEYS products::*
1) "products::42"
2) "products::17"
> GET "products::42"
"{\"@class\":\"com.example.Product\",\"id\":42,\"name\":\"Widget\",\"price\":9.99}"
> TTL "products::42"
(integer) 3542 # секунд до истечения
# Сессии через Spring Session
> KEYS myapp:session:*
1) "myapp:session:sessions:a3f9c..."
2) "myapp:session:sessions:b12ef..."
Две оговорки к этому выводу. Первая: KEYS здесь — команда для локального стенда и только для него. На боевом сервере ею пользоваться нельзя по той же причине, что и в разделе про allEntries: она проходит всё пространство ключей разом. Посмотреть ключи на живом Redis — это SCAN 0 MATCH products::* COUNT 100.
Вторая: поле @class в значении — работа GenericJackson2JsonRedisSerializer, которым настроен кэш выше. Он пишет имя Java-класса рядом с данными, чтобы знать, во что их потом разворачивать. JSON остаётся читаемым, но значения оказываются привязаны к именам ваших классов — ровно как и в случае с RedisTemplate и включённой типизацией.
Глубже: когда Redis лежит: таймауты, размыкатель и работа мимо кэшарасширенное
Кэш по определению можно потерять, и сервис обязан это пережить. На практике первый инцидент Redis выглядит иначе: кэш недоступен, а сервис не «работает медленнее», а лежит вместе с ним. Причина в умолчаниях клиента.
У Lettuce таймаут команды по умолчанию шестьдесят секунд. Пока Redis не отвечает, каждый запрос пользователя ждёт минуту на попытке прочитать кэш, потоки Tomcat заканчиваются, и сервис перестаёт отвечать целиком, включая ручки, которым кэш не нужен. Первое, что делают, это короткий таймаут и таймаут на подключение:
spring:
data:
redis:
timeout: 200ms
connect-timeout: 500ms
Двести миллисекунд для кэша много: нормальный ответ занимает доли миллисекунды, и всё, что дольше, уже беда. Второе: ошибки кэша не должны превращаться в ошибки ответа. У Spring Cache для этого есть CacheErrorHandler: бин, который логирует ошибку get и put и идёт дальше, как будто в кэше ничего не было. Без него @Cacheable пробрасывает исключение подключения наверх, и страница падает вместо того, чтобы сходить в базу.
Третье: не добивать. Когда Redis не отвечает, каждый запрос всё равно ждёт свои двести миллисекунд и открывает соединения. Размыкатель цепи (Resilience4j CircuitBreaker вокруг обращений к кэшу или обёртка над CacheManager) после нескольких ошибок подряд на полминуты отвечает «кэша нет» мгновенно, а потом пробует снова. То же для повторов: повторять чтение из кэша бессмысленно, у него есть источник правды, а повторять запись в очередь на Redis Streams нужно, но с ограничением попыток.
Четвёртое, и это уже не про клиента: что происходит с базой, когда кэш пропал. Если кэш держал 95 % чтений, база получает в двадцать раз больше запросов, чем привыкла, и статья про шаблоны кэширования называет это штурмом. Готовятся заранее: база выдерживает нагрузку без кэша хотя бы в деградированном режиме, самые тяжёлые ручки умеют отвечать «попробуйте позже», а на графике есть доля промахов кэша, по которой инцидент видно раньше, чем по ошибкам.
Сессии в Redis и очереди на Redis это другая история: без них сервис не деградирует, а теряет функцию. Для сессий это означает выход всех пользователей, и здесь Redis обязан быть отказоустойчивым (реплика и Sentinel из статьи про эксплуатацию), а не «просто кэшем».
Коротко
- Lettuce — клиент по умолчанию в Spring Boot; потокобезопасный, настраивается в
application.yml. StringRedisTemplate— для строковых операций;RedisTemplate<String, Object>— для сложных значений.- Сериализацию менять обязательно:
JdkSerializationRedisSerializerпо умолчанию пишет нечитаемые байты. Замена —GenericJackson2JsonRedisSerializer, но он добавляет в значение поле@classс именем Java-класса, так что данные всё равно остаются привязаны к вашим классам. @Cacheable/@CacheEvict/@CachePut— декларативный кэш;@EnableCachingвключает механизм.RedisCacheManagerнастраивает TTL отдельно для каждого кэша.- Spring Session переносит HTTP-сессии в Redis — масштабирование без потери сессий.
- Redis лёг:
timeout200 мс вместо минуты по умолчанию,CacheErrorHandlerглотает ошибки кэша, размыкатель не добивает, база готова к штурму; сессии и очереди требуют отказоустойчивого Redis. - Рабочая конфигурация кэша — это
sync = trueот толпы,condition/unlessпо месту, осознанное решение про кэширование пустого ответа и явный ключ вместо генератора по умолчанию. - Кэш работает через прокси: самовызов метода того же класса идёт мимо, и заметно это только по нулевому коэффициенту попаданий в метриках
cache.gets. - В кластере обязательно включают обновление топологии у Lettuce, иначе после переключения мастера клиент ходит на мёртвый узел до перезапуска.
Что почитать дальше
- Основы Redis — структуры данных, TTL, персистентность
- Паттерны кэширования — cache-aside, write-through, write-behind, когда что применять
- Redis не только для кэша — блокировки, ограничение запросов, очереди
- Эксплуатация Redis — мониторинг, репликация, Sentinel