概述
SpringCache 的概念与注解体系是基础,本文只回答三个落地时绕不开的问题:key 到底怎么写才不会串、Redis 里的过期时间和序列化在哪里配、数据变了以后缓存什么时候失效。全部围绕交友项目的视频列表这一处真实缓存展开。
纲要
- 缓存存放位置:默认 ConcurrentMap → 换成 Redis 的两步配置
- key 写法专题
- SpEL 表达式速查:
#参数名、#p0、#result、T()静态调用 - 组合 key 的字符串拼接写法与工程实读逐项解释
- key 长度与可读性的权衡
- RedisCacheManager 配置
- 工程实读:
RedisCacheConfig按 cacheName 分配 TTL - 完整可复制配置类:entryTtl + GenericJackson2JsonRedisSerializer
- 写时失效:发布视频时
@CacheEvict(allEntries=true)与失效粒度取舍 - 视频列表落点预演:
queryVideoList实读代码逐项解释 - 时序图:缓存命中与未命中的两条路径
- 踩坑清单与可复现验证
缓存数据到底存在哪
入门案例跑通之后有个疑问:代码里没配 Redis 也没配别的缓存,数据去哪了。默认情况下 SpringCache 用 ConcurrentMapCacheManager,底层是每个命名空间一个 ConcurrentMapCache(内部就是 ConcurrentHashMap),纯内存的本地缓存,应用一重启全部清零,集群部署时各节点各存一份。
换存储介质只需要两步:
第一步,引入 spring-boot-starter-data-redis 依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
第二步,在 application.yml 中配置 Redis 连接:
spring:
redis:
port: 6379
host: 192.168.136.160
只要 classpath 里同时有 spring-boot-starter-cache 和 spring-boot-starter-data-redis,容器自动装配的 CacheManager 就会从 ConcurrentMapCacheManager 切换为 RedisCacheManager,缓存数据随之写入 Redis。项目工程的 mf-app-server 正是这套依赖组合。
存储位置换成了 Redis,但真正决定「同一次调用会不会命中缓存」的是 key 的生成规则,这是下面要展开的重点。
key 写法专题
Redis 中 key 的命名规则
RedisCacheManager 生成的 key 遵循固定格式:
cacheName + "::" + key
cacheName 就是 @Cacheable 的 value 属性。它不是「缓存的值」,而是命名空间(分组):同一业务的数据放在同一个组下,value="user"、key 为 1 和 2 的两条数据,在 Redis 里就是 user::1 和 user::2。分组的好处是按业务隔离,后续做批量清除(allEntries=true)时也只清自己组。
SpEL 表达式速查
key 属性支持 Spring EL 表达式,常用的几种写法:
| 写法 | 含义 | 示例求值结果 |
|---|---|---|
#参数名 | 直接引用方法参数 | key="#userId",传 1 则 key 为 user::1 |
#p0、#p1 | 按参数位置引用(第 0 个、第 1 个参数) | 与 #参数名 等价,参数被编译时去名后仍可用 |
#result | 引用方法返回值(仅 @CachePut 场景有意义) | key="#result.id" |
'字面量' | 字符串常量,单引号包裹 | key="'test' + #id",得到 user::test1 |
T(全限定类名).静态方法() | 调用任意类的静态方法 | key="T(com.mf.server.interceptor.UserHolder).getUserId()" |
#参数名 == null 等表达式 | SpEL 支持的任意运算 | 配合 condition / unless 做条件缓存 |
T() 是最容易被忽略但工程里最实用的一个:它打破了「key 只能从方法参数里取」的限制。
组合 key:工程实读逐项解释
项目的视频列表接口在 mf-app-server 的 SmallVideosService 中,工程终态代码:
//查询视频列表
@Cacheable(
value="videos",
key = "T(com.mf.server.interceptor.UserHolder).getUserId()+'_'+#page+'_'+#pagesize") //userid _ page_pagesize
public PageResult queryVideoList(Integer page, Integer pagesize) {
......
}
逐项拆解:
value="videos":命名空间定为 videos,这条缓存在 Redis 里所有 key 都以videos::开头。T(com.mf.server.interceptor.UserHolder).getUserId():方法签名里只有 page 和 pagesize,没有 userId,登录用户 id 存在 ThreadLocal 里,SpEL 的#参数名够不着它。T()静态调用在表达式求值时现场从 UserHolder 取当前线程的用户 id,解决了「key 依赖不在参数里」的问题。+'_'+#page+'_'+#pagesize:把三个维度拼成一个字符串 key,例如 1 号用户看第 1 页、每页 10 条,最终 key 就是videos::1_1_10。
为什么要拼复合 key?视频列表是「每个用户、每一页」各不相同的数据。如果 key 只用 #page,那么 1 号用户和 2 号用户请求第 1 页会拿到同一份缓存,A 用户刷到的视频 B 用户也能刷到,数据直接串号;如果只用 userId,翻到第 2 页时又会命中第 1 页的缓存。三个维度缺一不可,用下划线隔开拼成一个确定性 key,是「不同请求必须映射到不同缓存」的最直接表达。
分隔符本身也有讲究。写成 key = "#page + #pagesize" 会踩 SpEL 的算术陷阱:两个 Integer 相加执行的是数学加法,page=1, pagesize=10 得到 key videos::11,而 page=11, pagesize=0 同样得到 11,两组完全不同的参数拼出了相同的 key,缓存就串了。用字符串 '_' 做分隔,1_10 和 11_0 天然不同,既保证唯一性,又让 key 在 redis-cli 里肉眼可读。
key 的长度与可读性权衡
| 方案 | 示例 key | 优点 | 缺点 |
|---|---|---|---|
| 精简拼接 | videos::1_1_10 | 短、省内存、redis-cli 里直观 | 见 key 不知语义,需要文档约定 |
| 带语义前缀 | videos::userId1_page1_size10 | 自解释,排障时不用查表 | 长,大 key 量下占额外内存 |
| 参数哈希 | videos::a3f5c... | 长度恒定 | 完全不可读,排查问题必须重算 |
列表类缓存条目多、淘汰频繁,key 留在 Redis 里的时间是按秒计的,精简拼接(工程采用的 userId_page_pagesize)是更划算的选择。关键维度少、生命周期长的配置类缓存,才值得用带语义的长 key。
RedisCacheManager 配置
工程实读:按 cacheName 分配 TTL
工程里确实有缓存配置类,位于 com.mf.server.config.RedisCacheConfig:
package com.mf.server.config;
import com.google.common.collect.ImmutableMap;
import org.springframework.boot.autoconfigure.cache.RedisCacheManagerBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import java.time.Duration;
import java.util.Map;
@Configuration
public class RedisCacheConfig {
//设置失效时间
private static final Map<String, Duration> cacheMap;
static {
cacheMap = ImmutableMap.<String, Duration>builder().put("videos", Duration.ofSeconds(30L)).build();
}
//配置RedisCacheManagerBuilderCustomizer对象
@Bean
public RedisCacheManagerBuilderCustomizer redisCacheManagerBuilderCustomizer() {
return (builder) -> {
//根据不同的cachename设置不同的失效时间
for (Map.Entry<String, Duration> entry : cacheMap.entrySet()) {
builder.withCacheConfiguration(entry.getKey(),
RedisCacheConfiguration.defaultCacheConfig().entryTtl(entry.getValue()));
}
};
}
}
思路:用一个 Map 维护「cacheName → TTL」的对应关系,RedisCacheManagerBuilderCustomizer 在自动装配 RedisCacheManager 时逐个调用 withCacheConfiguration 注册每个命名空间的配置。以后新增一种缓存,只需往 cacheMap 里加一行,不用动配置逻辑。
videos 只给了 30 秒。这是个务实的选择:视频列表对实时性有一定要求(新视频要尽快出现),30 秒的脏窗口可接受,同时把高频刷新动作的数据库压力削掉一大截。
这个写法有个隐患:默认序列化是 JDK 序列化
RedisCacheConfiguration.defaultCacheConfig() 没有指定序列化器,value 默认走 JdkSerializationRedisSerializer。存进去的 PageResult 是一段二进制,redis-cli 里看就是乱码,机翻稿里「存储的数据类似乱码的形式」说的就是它。能用,但有三个代价:对象必须实现 Serializable(PageResult 和 VideoVo 都实现了,见下文)、存储体积大、跨语言不可读。
生产上更常见的做法是显式配置 JSON 序列化。下面给出一份完整可复制的增强版配置类,在工程原版基础上补齐序列化:
package com.mf.server.config;
import com.google.common.collect.ImmutableMap;
import org.springframework.boot.autoconfigure.cache.RedisCacheManagerBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
import java.util.Map;
@Configuration
public class RedisCacheConfig {
//各命名空间的失效时间:cacheName -> TTL
private static final Map<String, Duration> cacheMap;
static {
cacheMap = ImmutableMap.<String, Duration>builder()
.put("videos", Duration.ofSeconds(30L))
.build();
}
//通用配置:key 用字符串,value 用 JSON
private RedisCacheConfiguration cacheConfiguration(Duration ttl) {
return RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(ttl)
//key 序列化为可读字符串,避免出现 \xac\xed\x00\x05 前缀
.serializeKeysWith(
RedisCacheConfiguration.SerializationPair
.fromSerializer(new StringRedisSerializer()))
//value 存 JSON:可读、体积小、不要求实现 Serializable
.serializeValuesWith(
RedisCacheConfiguration.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()))
//null 值不缓存,防止缓存穿透时把 null 也存进去
.disableCachingNullValues();
}
@Bean
public RedisCacheManagerBuilderCustomizer redisCacheManagerBuilderCustomizer() {
return (builder) -> {
//根据不同的 cacheName 设置不同的失效时间与序列化方式
for (Map.Entry<String, Duration> entry : cacheMap.entrySet()) {
builder.withCacheConfiguration(entry.getKey(),
cacheConfiguration(entry.getValue()));
}
};
}
}
为什么选 GenericJackson2JsonRedisSerializer 而不是 JDK 序列化:
| 对比项 | JDK 序列化(默认) | GenericJackson2JsonRedisSerializer |
|---|---|---|
| 可读性 | 二进制乱码 | JSON 文本,redis-cli 直接看 |
| 体积 | 大(类元数据全量写入) | 小 |
| 对象要求 | 必须实现 Serializable | 无硬性要求 |
| 反序列化 | 强依赖原类路径 | GenericJackson2Json 在 JSON 里携带 @class 类型信息,能还原原类型 |
| 跨服务/跨语言 | 仅 Java 之间 | 任何能读 JSON 的端都能解析 |
换 JSON 序列化后有一点要留意:缓存对象里如果只有无参构造缺失或字段类型多态,反序列化可能失败,所以返回值尽量用结构稳定的 VO(VideoVo 这种字段固定的类正合适)。
写时失效:发布视频时清缓存
@Cacheable 只管「读的时候放进去」,不管「数据变了怎么办」。用户发布一条新视频后,如果不清缓存,所有人要在 TTL 窗口内(工程里是 30 秒)继续刷旧列表。常见做法是在发布方法上挂 @CacheEvict:
/**
* 发布小视频
*/
@CacheEvict(value="videos", allEntries = true) //清空缓存
public void saveVideos(MultipartFile videoThumbnail, MultipartFile videoFile) throws IOException {
//上传 FastDFS、OSS,构建 Video 对象,调用 videoApi.save(video) 持久化
......
}
allEntries = true 的含义是清空 videos 命名空间下的全部条目,而不是某一条。为什么必须全清?发布新视频影响的是「所有用户的推荐列表」:新视频会出现在每个用户的第 1 页,缓存里的 key 有 videos::1_1_10、videos::2_1_10、videos::3_2_15……如果要精确失效,发布方根本算不出有哪些用户看过列表、各自停在第几页,key 的组合空间是发散的,逐个清不现实。全清虽然粗暴,但保证一致性,代价只是发布后第一批请求回源数据库。
工程快照说明:当前 mf 工程的 saveVideos 上实测没有这个注解,上述写法以规范做法为准。照抄时记得补上,否则「发布后 30 秒内列表不刷新」会成为一个偶现且难解释的 bug。
失效粒度的取舍:
| 粒度 | 写法 | 适用场景 | 代价 |
|---|---|---|---|
| 精确 key | @CacheEvict(value="videos", key="#userId") | 单条数据被定点修改,且 key 可以由参数算出 | key 带组合维度时算不全,漏清就脏 |
| 全部失效 | @CacheEvict(value="videos", allEntries=true) | 写操作影响整个集合(发布、删除、推荐位调整) | 一次写打掉整个命名空间,命中率瞬时下跌 |
| TTL 兜底 | entryTtl(Duration.ofSeconds(30)) | 长尾不一致可容忍、写入方无法感知失效时机 | TTL 窗口内是脏数据,纯属被动等待 |
三种粒度不是互斥的,工程里的常见组合是「精确失效为主、TTL 兜底」:能算出 key 的就精确清,算不全的依赖 TTL 自然过期,videos 这种发散 key 场景则退化为「全清 + TTL」双保险。
视频列表落点预演:queryVideoList 全解
把工程实读的完整方法贴出来,看缓存注解如何套在一段真实的「三段开销」查询上:
//查询视频列表
@Cacheable(
value="videos",
key = "T(com.mf.server.interceptor.UserHolder).getUserId()+'_'+#page+'_'+#pagesize") //userid _ page_pagesize
public PageResult queryVideoList(Integer page, Integer pagesize) {
//1、查询redis数据(推荐视频的vid列表)
String redisKey = Constants.VIDEOS_RECOMMEND +UserHolder.getUserId();
String redisValue = redisTemplate.opsForValue().get(redisKey);
//2、判断redis数据是否存在,判断redis中数据是否满足本次分页条数
List<Video> list = new ArrayList<>();
int redisPages = 0;
if(!StringUtils.isEmpty(redisValue)) {
//3、如果redis数据存在,根据VID查询数据
String[] values = redisValue.split(",");
//判断当前页的起始条数是否小于数组总数
if( (page -1) * pagesize < values.length) {
List<Long> vids = Arrays.stream(values).skip((page - 1) * pagesize).limit(pagesize)
.map(e->Long.valueOf(e))
.collect(Collectors.toList());
//5、调用API根据VID数组查询视频数据
list = videoApi.findMovementsByVids(vids);
}
redisPages = PageUtil.totalPage(values.length,pagesize);
}
//4、如果redis数据不存在,分页查询视频数据
if(list.isEmpty()) {
//page的计算规则:传入的页码 -- redis查询的总页数
list = videoApi.queryVideoList(page - redisPages, pagesize);
}
//5、提取视频列表中所有的用户id
List<Long> userIds = CollUtil.getFieldValues(list, "userId", Long.class);
//6、批量查询用户信息
Map<Long, UserInfo> map = userInfoApi.findByIds(userIds, null);
//7、构建返回值
List<VideoVo> vos = new ArrayList<>();
for (Video video : list) {
UserInfo info = map.get(video.getUserId());
if(info != null) {
VideoVo vo = VideoVo.init(info, video);
vos.add(vo);
}
}
return new PageResult(page,pagesize,0l,vos);
}
这段方法体本身就有三段开销:MongoDB 分页查询、按 userId 批量查 MySQL 用户信息、读 Redis 推荐列表做状态判断(前文「查询视频列表:代码实现」里已详细分析)。@Cacheable 套在最外层,等于给整段方法体做了一层 membrane:命中时方法体一行都不执行,三段开销全部归零;未命中时才完整跑一遍,把最终 PageResult 写进 videos::userId_page_pagesize。这套配置就是为视频列表量身服务的,读多写少、按用户和页维度天然分片、单条 30 秒内允许不变。
缓存对象的可序列化也在工程里有对应:PageResult 和 VideoVo 都声明了 implements Serializable。即使后续把序列化方式换成 JSON,这个声明保留着也无害;但若走默认 JDK 序列化,漏了它就直接抛 NotSerializableException。
工程结构中与这里相关的文件位置:
mf-app-server/src/main/java/com/mf/server/
├── AppServerApplication.java # @EnableCaching 开启缓存支持
├── config/
│ └── RedisCacheConfig.java # videos 命名空间 TTL=30s
├── interceptor/
│ └── UserHolder.java # ThreadLocal 存登录用户,key 的 T() 调用目标
└── service/
└── SmallVideosService.java # queryVideoList @Cacheable / saveVideos 发布入口
mf-model/src/main/java/com/mf/model/vo/
├── PageResult.java # implements Serializable
└── VideoVo.java # implements Serializable
缓存命中与未命中的两条路径
发布视频那条线则在另一个切面上:saveVideos 先执行入库,方法正常返回后 AOP 清空 videos::* 全部条目,下一次任何人刷列表都是回源重建。
踩坑清单
| 坑 | 现象 | 规避 |
|---|---|---|
key 拼接遗漏或误用 + | 不同参数拼出相同 key,缓存串了(1_10 与 11_0 都变 11) | 组合维度之间必须插入字符串分隔符 +'_'+,拼完先在 redis-cli 里抽查 key 形态 |
| 只配 TTL 不做写时失效 | 发布新视频后 30 秒内列表不刷新,偶现难复现 | 写方法上补 @CacheEvict,TTL 只作兜底 |
| 只做失效不配 TTL | 某次 evict 异常或遗漏后,脏缓存永不消失 | 每个命名空间都给 entryTtl |
| 缓存对象不可序列化 | 抛 NotSerializableException,或 JSON 反序列化失败 | 返回值用实现 Serializable、结构稳定的 VO(PageResult/VideoVo 均已实现) |
| 同类自调用 | 同一个类里 this.queryVideoList() 调用,注解完全不生效 | 自调用不走 AOP 代理,拆到另一个 Bean 或注入自身代理(概念篇已展开) |
| ThreadLocal 时机问题 | T(UserHolder).getUserId() 报 NPE | 拦截器未把用户放入 ThreadLocal(登录校验没走到)就调了缓存方法,检查拦截器路径 |
| 默认序列化乱码 | redis-cli 里 value 是二进制 | 按上文配置 GenericJackson2JsonRedisSerializer |
可复现验证
以 spring-cache-demo 工程为例(项目工程同理,把 key 换成组合 key 即可)。
第一步,验证两次调用第二次命中:
@SpringBootTest
class UserServiceTest {
@Autowired
private UserService userService;
@Test
void testCache() {
userService.findById(1L); //第一次:查库并写入 Redis
userService.findById(1L); //第二次:命中缓存,SQL 日志不再出现
}
}
开启 SQL 日志或断点在 Dao 层,第二次调用不会有任何数据库访问。
第二步,发布视频后观察失效(项目工程):
# 发布前:能看到组合 key 形态
redis-cli keys "videos::*"
# 输出示例:1) "videos::1_1_10" 2) "videos::1_2_10" 3) "videos::2_1_10"
# 查看 TTL
redis-cli ttl videos::1_1_10
# 输出:28(随时间递减,上限 30)
# 调用发布视频接口后
redis-cli keys "videos::*"
# 输出:(empty) —— allEntries=true 已全清
注意 keys 命令仅适合排查环境,生产上用 scan videos::* count 100 代替。
常见误解澄清
| 误解 | 事实 |
|---|---|
value 属性是缓存的值 | value 是命名空间(cacheName),真正缓存值是方法返回值 |
| key 不写就默认用返回值做 key | 默认用 SimpleKey.EMPTY,无参方法共享一条缓存,多参方法用参数对象组合 |
@Cacheable 注解加上就生效 | 必须有 @EnableCaching,且调用要经过 Spring 代理 |
| 过期时间由注解控制 | SpringCache 注解本身没有 TTL 属性,过期由 CacheManager 层(RedisCacheConfiguration.entryTtl)决定 |
| redis-cli 里 value 乱码是 bug | 默认 JDK 序列化的正常表现,想可读就显式换 JSON 序列化器 |
T() 是工具类导入 | 是 SpEL 的静态调用语法,与 Java 的 import 无关 |
API 速览
| 属性 | 所属注解 | 作用 | 示例 |
|---|---|---|---|
value / cacheNames | 两者通用 | 命名空间,Redis key 前缀 | value="videos" |
key | 两者通用 | SpEL 表达式生成 key | "T(...).getUserId()+'_'+#page+'_'+#pagesize" |
allEntries | @CacheEvict | true 时清空整个命名空间 | allEntries=true |
condition | 两者通用 | 方法执行前判断,false 则不走缓存 | condition="#pagesize <= 50" |
unless | @Cacheable | 方法执行后判断,true 则不写缓存 | unless="#result.rows.isEmpty()" |
entryTtl | RedisCacheConfiguration | 该命名空间的过期时间 | entryTtl(Duration.ofSeconds(30)) |
官方文档
总结
- SpringCache 的 key =
cacheName::SpEL求值结果。组合 key 用字符串分隔符拼接,既防维度碰撞又防 SpEL 数字相加;key 依赖不在参数里的上下文(如 ThreadLocal 中的用户)时,用T(类).静态方法()现场取。 - TTL 和序列化都不在注解上配,而在 RedisCacheManager 层:工程用
RedisCacheManagerBuilderCustomizer按 cacheName 注册差异化 TTL(videos 为 30 秒);默认 JDK 序列化建议换成 GenericJackson2JsonRedisSerializer,可读、省内存、不强制 Serializable。 - 失效粒度按「能否算出 key」决定:算得清就精确清,算不清(如发布视频影响所有用户的列表)就
allEntries=true全清,TTL 永远留着兜底。 - 项目视频列表是这套配置的标准受益者:三段开销的查询被一层
@Cacheable整体包住,命中即归零。
下一篇同章的通用缓存SpringCache:常用注解会把 @CachePut、@Caching、条件缓存这些注解逐一展开,spring-cache-demo 的 UserService 里已经能看到 @CachePut 和 @Caching 组合清除的身影,正好承接。
584



被折叠的 条评论
为什么被折叠?



