Опирается на правила: R-CACHE-CFG-1R-CACHE-CFG-5 и R-CACHE-CFG-X1R-CACHE-CFG-X4 из Caching Style Guide → раздел 2. Конфигурация.

Важно знать

  • RedisCacheManager в проде, не in-memory ConcurrentMapCacheManager. В multi-instance каждый pod имел бы свой локальный кеш.
  • JSON-сериализация через GenericJackson2JsonRedisSerializer. Java native — security risk (deserialization CVE).
  • Per-cache TTL — каждый именованный кеш имеет explicit duration; не один глобальный default.
  • @ConfigurationProperties + @Validated для cache settings — TTL меняется в application.yml, не в коде.
  • В тестахConcurrentMapCacheManager или Testcontainers Redis. Моки Cache-интерфейса теряют поведение TTL/eviction.
  • @EnableCaching без CacheManager-бина — Spring создаёт NoOpCacheManager, @Cacheable молча ничего не кеширует.

Redis вместо in-memory в проде

R-CACHE-CFG-1: production cache backend — Redis.

Почему не ConcurrentMapCacheManager (Spring default simple):

  • Multi-instance. 10 реплик в K8s = 10 локальных кешей. Тот же userId=42 попадает в разные реплики и читает разные значения. Инкосистенция reads.
  • Invalidation race. Write на pod-1 evict-ит локальный кеш pod-1, но не трогает pod-2 — там старое значение живёт TTL.
  • Persistence. Redis может пережить рестарт (RDB/AOF). In-memory — теряет всё при scale-down.
  • Observability. Redis-side метрики, cluster mode, отдельный экспортер для Prometheus.
@Configuration
@EnableCaching
@RequiredArgsConstructor
public class CacheConfig {

    private final CacheSettings settings;

    @Bean
    RedisCacheManager cacheManager(RedisConnectionFactory cf, ObjectMapper mapper) {
        var defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
            .serializeValuesWith(SerializationPair.fromSerializer(
                new GenericJackson2JsonRedisSerializer(mapper)))
            .entryTtl(Duration.ofMinutes(15))
            .disableCachingNullValues();

        var perCache = settings.caches().entrySet().stream()
            .collect(Collectors.toMap(
                Map.Entry::getKey,
                e -> defaultConfig.entryTtl(e.getValue().ttl())
            ));

        return RedisCacheManager.builder(cf)
            .cacheDefaults(defaultConfig)
            .withInitialCacheConfigurations(perCache)
            .build();
    }
}

JSON сериализация — никогда Java native

R-CACHE-CFG-2: значения в Redis сериализуются JSON через GenericJackson2JsonRedisSerializer(mapper).

Почему не JdkSerializationRedisSerializer:

  1. Security. Java deserialization — известный класс уязвимости (CVE-2015-7501 family). Если attacker может вписать произвольный blob в Redis (через слабый key, через скомпрометированный сервис), readObject() может выполнить произвольный код.
  2. Читаемость. JSON в Redis viewable через redis-cli GET cache:user-profiles::42 — видно содержимое для дебага. Binary blob — нет.
  3. Forward-compat. Jackson по умолчанию игнорирует unknown fields. Добавили поле в DTO → старые закешированные значения читаются (без нового поля). Java native — InvalidClassException при изменении serialVersionUID.

GenericJackson2JsonRedisSerializer хранит class info в JSON ("@class": "ru.vikulinva.UserProfile"), что позволяет десериализовать в правильный тип без явного TypeReference. Цена — pollination class names в Redis; для UCP это приемлемо.

Per-cache configuration

R-CACHE-CFG-3: каждый кеш — свой TTL.

cache:
  caches:
    user-profiles:
      ttl: 15m
    currencies:
      ttl: 6h
    feature-flags:
      ttl: 60s
    user-balances:
      ttl: 30s
    order-summaries:
      ttl: 5m

user-profiles и feature-flags физически разные данные с разной частотой изменений и разной критичностью — общий TTL компромисс ни для кого. Подробнее — TTL.

@ConfigurationProperties + @Validated

R-CACHE-CFG-4: TTL живёт в application.yml, не в коде.

@ConfigurationProperties("cache")
@Validated
public record CacheSettings(
    @Valid @NotEmpty Map<String, CacheConfig> caches
) {
    public record CacheConfig(
        @NotNull Duration ttl,
        boolean disableNullValues
    ) {}
}
@Configuration
@EnableConfigurationProperties(CacheSettings.class)
public class CacheConfigRegistration {}

Что это даёт:

  • SRE/admin меняет TTL в config-map без redeploy. Достаточно перезагрузить config (Spring Cloud Config) или передеплоить с новым ENV.
  • @Validated ловит invalid YAML при старте (TTL без значения, отрицательная Duration).
  • @Valid в Map нужен Spring 3.0+, иначе nested @NotNull не проверяется.

В тестах — ConcurrentMapCacheManager или Testcontainers

R-CACHE-CFG-5: два режима тестирования кеша.

@TestConfiguration
public class TestCacheConfig {

    @Bean
    @Primary
    CacheManager testCacheManager() {
        return new ConcurrentMapCacheManager(
            "user-profiles", "currencies", "feature-flags"
        );
    }
}

ConcurrentMapCacheManager для unit-тестов handler-ов: проверяем «второй вызов handler-а с теми же params не дёрнул repository» через verify(repository, times(1))....

Для integration-тестов, проверяющих real Redis behaviour (TTL, eviction под memory pressure) — Testcontainers:

@SpringBootTest
@Testcontainers
class CacheIntegrationTest {

    @Container
    static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
        .withExposedPorts(6379);

    @DynamicPropertySource
    static void redisProps(DynamicPropertyRegistry registry) {
        registry.add("spring.data.redis.host", redis::getHost);
        registry.add("spring.data.redis.port", redis::getFirstMappedPort);
    }
}

Не моки Cache-интерфейса через Mockito. Mock возвращает что напишут, теряет реальное поведение TTL и eviction.

Что запрещено

JdkSerializationRedisSerializer

R-CACHE-CFG-X1: security risk + не читается через redis-cli. Всегда GenericJackson2JsonRedisSerializer.

ConcurrentMapCacheManager в multi-instance проде

R-CACHE-CFG-X2: каждый pod = свой кеш, нет consistency. Stage-окружение с одной репликой может работать, прод с 10 репликами — race-условия на каждом write.

Один глобальный TTL

R-CACHE-CFG-X3: одно значение на все кеши = либо user-balances слишком долго stale (15 мин), либо currencies бессмысленно пересчитываются (60 сек). Per-cache config обязателен.

@EnableCaching без CacheManager-бина

R-CACHE-CFG-X4: Spring создаёт NoOpCacheManager по умолчанию.

// КАТАСТРОФА — silent skip
@Configuration
@EnableCaching
public class CacheConfig {
    // нет @Bean RedisCacheManager
}

@Cacheable методы выполняются как будто аннотации нет, но без warning. Hit rate всегда 0%, никто не замечает, пока не открывают Grafana и не видят, что кеша не существует.

Всегда явно объявлять @Bean RedisCacheManager или подключать spring-boot-starter-data-redis с правильной configuration.

Что запрещено — таблица

АнтипаттернПравилоЧто взамен
JdkSerializationRedisSerializerR-CACHE-CFG-X1GenericJackson2JsonRedisSerializer
ConcurrentMapCacheManager в multi-instance продеR-CACHE-CFG-X2RedisCacheManager
Один глобальный TTLR-CACHE-CFG-X3per-cache configuration
@EnableCaching без CacheManager-бинаR-CACHE-CFG-X4явный @Bean RedisCacheManager
TTL hard-coded в JavaR-CACHE-CFG-4@ConfigurationProperties + application.yml
Mock Cache через MockitoR-CACHE-CFG-5ConcurrentMapCacheManager или Testcontainers
disableCachingNullValues() пропущенR-CACHE-CFG-1включить, иначе кеш заполняется null'ами

Куда дальше