← назад к разделу

Spring Boot умеет работать с Redis «из коробки» — достаточно добавить зависимость, указать адрес и выбрать нужный инструмент: ручные операции через RedisTemplate, автоматический кэш через @Cacheable или хранение сессий через Spring Session.

аннотация работает через прокси: в тело метода заходим только на промахе клиент findById(42) update(product) прокси @Cacheable Redis products::42ключа нет products::42живёт ещё 1 ч GET GET GET DEL промах: идём в тело метода промах: идём в тело метода тело метода → база SET products::42 SET products::42 вызовов тела метода: 0 1 2 промах: ключа нет — Spring выполнил метод и положил ответ в Redis попадание: ответ из Redis, тело метода не выполнялось @CacheEvict удалил ключ — кэш снова пуст снова промах: тело метода выполнено второй раз

Между клиентом и методом стоит прокси, который создаёт @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-классам — при изменении класса десериализация сломается.

по умолчанию объект Product \xac\xed\x00\x05sr читает только Java после настройки объект Product {"id":42,...} читает любой клиент

Одно и то же значение в 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. Это даёт две вещи:

  1. Горизонтальное масштабирование — несколько экземпляров приложения работают с общим хранилищем сессий, пользователь не теряет сессию при переключении между ними.
  2. Переживание перезапуска — сессии не исчезают при рестарте приложения.
вход app-1 завёл сессию Redis сессия лежит тут новый запрос ушёл на app-2 ответ app-2 нашёл сессию

Сессия лежит в 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 лёг: timeout 200 мс вместо минуты по умолчанию, CacheErrorHandler глотает ошибки кэша, размыкатель не добивает, база готова к штурму; сессии и очереди требуют отказоустойчивого Redis.
  • Рабочая конфигурация кэша — это sync = true от толпы, condition/unless по месту, осознанное решение про кэширование пустого ответа и явный ключ вместо генератора по умолчанию.
  • Кэш работает через прокси: самовызов метода того же класса идёт мимо, и заметно это только по нулевому коэффициенту попаданий в метриках cache.gets.
  • В кластере обязательно включают обновление топологии у Lettuce, иначе после переключения мастера клиент ходит на мёртвый узел до перезапуска.

Что почитать дальше