在 Spring Cache 接入 Redis 之后,最常见的问题通常不是"能不能用",而是"默认配置够不够用":
- 所有缓存都用同一个过期时间,明显不合理
- 热点缓存希望访问后续期,普通缓存只需要固定 TTL
- 清理缓存时如果直接走
KEYS,大 key 空间下可能拖慢 Redis - 批量预热缓存时,一条条写入 RTT 太高,希望结合 pipeline 优化
如果把这些问题拆开看,核心其实只有两件事:
- Spring 是怎么把
@Cacheable映射到 Redis 的 - 你该在哪一层自定义过期策略和批量写入策略
先说结论
RedisCacheConfiguration决定缓存序列化、key 前缀、默认 TTL、是否开启 TTI。RedisCacheManagerBuilderCustomizer适合做"不同 cacheName 对应不同过期时间"的精细化配置。TTL是写入后固定过期,TTI是访问后刷新空闲时间,两者不是一回事。- 开启 TTI 后,读操作会改成类似
GETEX的语义,因此要求 Redis 支持对应命令(redis 6)。 pipeline不会改变缓存过期策略本身,但很适合批量预热、批量回填这类高吞吐写入场景。
Spring Cache 到 Redis 的调用链
先看一遍抽象层级,后面的配置才不容易乱:
text
@Cacheable
-> CacheInterceptor
-> CacheResolver
-> CacheManager
-> RedisCache
-> RedisCacheWriter
-> RedisConnectionFactory: 从Lettuce中获取底层连接对象
-> RedisConnection
从职责上理解:
CacheInterceptor负责拦截@Cacheable、@CachePut、@CacheEvictCacheResolver/CacheManager负责找到具体的缓存对象RedisCache代表某个cacheName对应的一块缓存空间: 通过RedisCacheConfiguration构建,包含了ttl、序列化相关配置RedisCacheWriter负责把get/put/evict翻译成底层 Redis 命令
因此,"Spring 自定义 Redis 超时"真正该改的,通常不是业务代码,而是 RedisCacheConfiguration 和 RedisCacheManager。
全局默认 TTL:先把基础配置定下来
如果你什么都不配,所有缓存基本都会走默认行为。这通常不适合线上系统,因为:
- 序列化格式可能不符合你的兼容性要求
- key 前缀不统一,排查问题不方便
- 所有缓存 TTL 一刀切,不利于分层治理
一个更稳妥的基础配置如下:
java
@EnableCaching
@Configuration(proxyBeanMethods = false)
public class RedisCacheConfig {
@Value("${spring.application.name:app}")
private String applicationName;
@Bean
public RedisCacheConfiguration redisCacheConfiguration() {
return RedisCacheConfiguration.defaultCacheConfig()
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer()
)
)
.prefixCacheNameWith(applicationName + "::")
.entryTtl(Duration.ofMinutes(10));
}
}
这段配置做了三件事:
- value 统一使用 JSON 序列化,避免默认 JDK 序列化带来的可读性和兼容性问题
- 所有缓存 key 自动加上应用名前缀,便于隔离和排查
- 给所有 cache 一个默认 TTL,例如 10 分钟
如果你的系统只是中小规模项目,统一 TTL 已经够用;但只要业务稍微复杂一点,很快就会需要"按 cacheName 配不同过期时间"。
按缓存名称配置不同 TTL
这是 Spring Cache 接 Redis 最常见、也最实用的自定义能力。
例如:
- 用户基础信息缓存 30 分钟
- 热门榜单缓存 1 分钟
- 字典类缓存 12 小时
这类需求适合用 RedisCacheManagerBuilderCustomizer 做增量配置:
java
@EnableCaching
@Configuration(proxyBeanMethods = false)
public class RedisCacheConfig {
@Value("${spring.application.name:app}")
private String applicationName;
@Bean
public RedisCacheConfiguration redisCacheConfiguration() {
return RedisCacheConfiguration.defaultCacheConfig()
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer()
)
)
.prefixCacheNameWith(applicationName + "::")
.entryTtl(Duration.ofMinutes(10));
}
// 这里只是演示:实际应该定义到CacheService相关的地方,也可以通过配置文件的方式进行配置
@Bean
public Map<String, Duration> cacheTtls() {
Map<String, Duration> ttlMap = new HashMap<>();
ttlMap.put("user", Duration.ofMinutes(30));
ttlMap.put("hot", Duration.ofMinutes(1));
ttlMap.put("dict", Duration.ofHours(12));
return ttlMap;
}
@Bean
public RedisCacheManagerBuilderCustomizer redisCacheManagerBuilderCustomizer(
RedisConnectionFactory redisConnectionFactory,
RedisCacheConfiguration redisCacheConfiguration,
ObjectProvider<Map<String, Duration>> cacheTtls) {
Map<String, RedisCacheConfiguration> configMap = new HashMap<>();
cacheTtls.stream()
.flatMap(map -> map.entrySet().stream())
.forEach(entry -> configMap.put(
entry.getKey(),
redisCacheConfiguration.entryTtl(entry.getValue())
));
// 在为每个cacheName配置创建RedisCache对象时,会从configMap中获取RedisCacheConfiguration对象进行构建
return builder -> builder
.withInitialCacheConfigurations(configMap)
.cacheWriter(RedisCacheWriter.nonLockingRedisCacheWriter(
redisConnectionFactory,
BatchStrategies.scan(500)
));
}
}
这段配置的重点有两个:
withInitialCacheConfigurations(configMap)作用是为不同的cacheName指定不同的RedisCacheConfiguration。redisCacheConfiguration.entryTtl(...)不是原地修改,而是基于基础配置派生出一份新的配置对象。
这意味着你可以先定义一份"全局通用配置",再在它的基础上按 cache 维度覆盖 TTL,而不用重复写序列化、前缀等公共设置。
为什么这里顺手把清理策略也配掉
上面的 cacheWriter(...) 不只是"顺手一写",它其实很实用。
很多项目默认不会关注 cache clear 的实现细节,但当 key 空间变大时,KEYS 的阻塞风险会很快暴露出来。相比之下:
java
BatchStrategies.scan(500)
更适合线上环境,因为它会通过 SCAN 以游标形式分批遍历 key,避免一次性扫全量 key 带来的阻塞压力。
如果你的缓存空间不大,这个差异不明显;但一旦进入高并发或大 key 空间场景,SCAN 策略通常更稳。
TTL 和 TTI 不是一回事
很多文章会把"超时"混着讲,实际上至少要区分两个概念:
TTL:写入之后固定多久过期TTI:空闲多久过期,读操作会刷新过期时间
如果你希望"只要一直有人访问,这个热点缓存就继续存活",那应该考虑 TTI,而不是单纯把 TTL 调大。
Spring Data Redis 提供了对应能力:
java
@Bean
public RedisCacheConfiguration redisCacheConfiguration() {
return RedisCacheConfiguration.defaultCacheConfig()
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer()
)
)
.prefixCacheNameWith("app::")
.entryTtl(Duration.ofMinutes(10))
.enableTimeToIdle();
}
这里的含义是:
- 写入时仍然有初始 TTL
- 读取命中缓存时,会刷新空闲过期时间
这很适合:
- 热点详情页缓存
- 频繁访问的用户会话缓存
- 某些只要持续命中就应该续期的热点数据
但要注意两点:
- TTI 会改变redis读取命令为
GETEX,该命令会携带一个EX参数,表示空闲 TTL - 它依赖 Redis 对应命令能力,通常要确认服务端版本满足要求
如果你的 Redis 版本不支持这类读时续期命令,就不要直接开启。
业务代码应该长什么样
当底层配置好之后,业务代码应该尽量保持简单,而不是把 TTL 逻辑散落到各个 Service 里。
java
@Service
public class DemoCacheService {
// 可以在这里定义各个缓存的TTL
@Bean
public Map<String, Duration> cacheTtls() {
Map<String, Duration> ttlMap = new HashMap<>();
ttlMap.put("user", Duration.ofMinutes(30));
retunr ttlMap;
}
@Cacheable(cacheNames = "user", key = "#id")
public String queryUser(Long id) {
System.out.println("load user from db...");
return "user-" + id;
}
@Cacheable(cacheNames = "hot", key = "#keyword")
public String queryHotData(String keyword) {
System.out.println("load hot data...");
return "hot-" + keyword;
}
}
这里不需要在 @Cacheable 上额外写 TTL,因为 TTL 已经由 cacheName -> RedisCacheConfiguration 这层统一管理。
这样做的好处是:
- 业务代码更干净
- 过期策略集中治理
- 后续调整 TTL 不需要全局搜注解
Pipeline 应该怎么融入这套方案
pipeline 和"自定义超时"不是一层概念,但它们在真实项目里经常一起出现。
原因很简单:
- 平时的缓存读写,交给
@Cacheable就够了 - 一旦遇到批量预热、批量回填、冷启动灌缓存,逐条写入的 RTT 就会变得很明显
这时就可以把 pipeline 当成"批量写缓存的性能优化手段"。
要先明确一点:
- pipeline 不会自动帮你决定 TTL
- pipeline 的作用是减少 flush 次数、减少 RTT、提高吞吐
- 每条缓存写入仍然需要你显式设置过期时间
一个批量回填缓存的示例
下面这个例子适合"批量写入字符串缓存,并显式设置 TTL"的场景:
java
@Service
public class CacheWarmService {
private final StringRedisTemplate stringRedisTemplate;
public CacheWarmService(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public void warmHotCache(Map<String, String> values) {
StringRedisSerializer serializer = StringRedisSerializer.UTF_8;
stringRedisTemplate.executePipelined((RedisCallback<Object>) connection -> {
values.forEach((key, value) -> connection.stringCommands().setEx(
serializer.serialize("hot::" + key),
60,
serializer.serialize(value)
));
return null;
});
}
}
这个例子里:
setEx负责写值并设置 TTLexecutePipelined()负责把多条命令打包发送- 适合缓存预热、榜单刷新、批量导入等场景
换句话说,TTL 负责"活多久",pipeline 负责"写得快不快",两者是互补关系。
如果底层是 Lettuce,需要优化 flush 策略
如果 Spring Data Redis 的底层客户端是 Lettuce,需要调整默认 pipeline 的 flush 策略。
因为Spring-lettuce默认的 pipeline 策略是每条命令都进行flush, 这里有体现:LettuceConnectionFactory#pipeliningFlushPolicy
java
@Component
public class LettuceFactoryPostProcessor implements BeanPostProcessor {
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (bean instanceof LettuceConnectionFactory factory) {
factory.setPipeliningFlushPolicy(
LettuceConnection.PipeliningFlushPolicy.buffered(100)
);
}
return bean;
}
}
这个配置的含义是:
- pipeline 模式下,不是每条命令都立刻 flush
- 每累计一定数量的命令再 flush 一次
- 关闭 pipeline 时,再把剩余命令统一刷出去
它适合:
- 批量缓存预热
- 一次性导入大量缓存数据
- 对单条请求延迟不敏感、但对整体吞吐敏感的任务
但它不适合直接套到所有场景,因为批次越大:
- 单次堆积的命令越多
- 客户端和服务端的缓冲压力越大
- 出问题时排查也更复杂
什么时候该用哪种方案
可以把这套能力简单分成三层:
- 普通缓存读写 使用
@Cacheable+RedisCacheManager配 TTL,已经足够。 - 热点缓存续期 在确认 Redis 版本支持的前提下,开启 TTI。
- 批量缓存预热或回填 使用
executePipelined(),必要时再叠加 Lettuce 的 buffered flush 策略。
这样分层之后,配置和性能优化就不会混在一起。
总结
Spring 自定义 Redis 超时,真正要抓住的是四个点:
- 用
RedisCacheConfiguration定义全局默认行为 - 用
RedisCacheManagerBuilderCustomizer按cacheName精细化配置 TTL - 用 TTI 解决"热点数据访问后续期"的问题
- 用 pipeline 解决"批量写缓存吞吐不足"的问题
如果把这四层区分清楚,Spring Cache、Redis TTL 和 Lettuce pipeline 就能自然地衔接起来,而不是各写各的、后期越改越乱。