概述
SpringCache 的注解语法讲完后,这里把它们真正落到交友项目的视频列表上:给 queryVideoList 加 @Cacheable,给发布视频加 @CacheEvict,再配上 TTL,把「读缓存 + 写失效」这条缓存一致性的最简闭环完整走一遍。
纲要
- 现状与问题
- 视频列表接口的查询链路(Dubbo + MongoDB + 用户信息聚合)
- 不加缓存时每次分页查询的代价
- 文件清单
- 涉及的工程文件与改动位置
- @Cacheable 落地:视频列表读缓存
- 启动类开启缓存(工程实读)
queryVideoList上的@Cacheable(value="videos", key=userId_page_pagesize)- value 命名、key 三段拼接、命中与未命中两条路径
- RedisCacheConfig:给 videos 命名空间设 TTL
- 发布视频时的缓存失效:@CacheEvict(allEntries=true)
- 为什么必须清全部
- 「只清当前用户缓存」的错误推演
- 闭环验证:四步用 redis-cli 实测
- 时序图:发布视频 → 清缓存 → 回源 → 新缓存
- 坑表与常见误解
- 通用价值:读缓存 + 写失效的适用面
现状与问题
视频列表接口 queryVideoList 的查询链路不短:先从 Redis 取推荐视频 id 列表(VIDEOS_RECOMMEND_ + userId),取不到再走 Dubbo 调 VideoApi 分页查 MongoDB,最后还要按 userId 批量查 UserInfo 拼 VideoVo。一次请求串了两次远程调用,而列表页恰恰是用户反复下拉刷新的高频接口。
缓存注解的语法已在《通用缓存SpringCache:常用注解》中拆解过,这里只做落地。目标形态是一读一写两处:
| 位置 | 注解 | 作用 |
|---|---|---|
SmallVideosService.queryVideoList | @Cacheable(value="videos", key=userId_page_pagesize) | 列表结果按「用户+页码」缓存 |
SmallVideosService.saveVideos | @CacheEvict(value="videos", allEntries=true) | 发布视频后清空整个 videos 命名空间 |
| — | — | — |
SmallVideosService.queryVideoList | @Cacheable(value="videos", key=userId_page_pagesize) | 列表结果按「用户+页码」缓存 |
SmallVideosService.saveVideos | @CacheEvict(value="videos", allEntries=true) | 发布视频后清空整个 videos 命名空间 |
文件清单
改动集中在 mf-app-server 一个模块:
| 文件 | 位置 | 改动 |
|---|---|---|
AppServerApplication.java | com.mf.server | 加 @EnableCaching 开启缓存 |
SmallVideosService.java | com.mf.server.service | queryVideoList 加 @Cacheable;saveVideos 加 @CacheEvict |
RedisCacheConfig.java | com.mf.server.config | 按 cacheName 配置 TTL(videos 30 秒) |
mf-app-server/src/main/java/com/mf/server/
├── AppServerApplication.java # @EnableCaching 开启缓存支持
├── config/
│ └── RedisCacheConfig.java # RedisCacheManagerBuilderCustomizer,按命名空间设 TTL
└── service/
└── SmallVideosService.java # queryVideoList 加 @Cacheable / saveVideos 加 @CacheEvict
依赖方面,Spring Data Redis 工程里本来就有,spring-boot-starter-cache 也不是必须,RedisCacheManager 由 Spring Boot 自动装配,前提只有一个:启动类上有 @EnableCaching。
@Cacheable 落地:视频列表读缓存
启动类开启缓存
工程终态的 AppServerApplication 已经带上了(还顺手排除了 Mongo 自动配置,这个与缓存无关):
package com.mf.server;
import org.springframework.boot.autoconfigure.data.mongo.MongoDataAutoConfiguration;
import org.springframework.boot.autoconfigure.mongo.MongoAutoConfiguration;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication(exclude = {
MongoAutoConfiguration.class,
MongoDataAutoConfiguration.class
}) //排除mongo的自动配置
@EnableCaching
public class AppServerApplication {
public static void main(String[] args) {
SpringApplication.run(AppServerApplication.class, args);
}
}
没有这一行,后面所有缓存注解都只是摆设,注解本身不报错,方法照常执行,缓存完全不生效,这是排查「加了注解没效果」的第一检查点。
queryVideoList 加 @Cacheable
工程里的真实代码(SmallVideosService.java):
package com.mf.server.service;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.PageUtil;
import com.mf.dubbo.api.UserInfoApi;
import com.mf.dubbo.api.VideoApi;
import com.mf.model.domain.UserInfo;
import com.mf.model.mongo.Video;
import com.mf.model.vo.PageResult;
import com.mf.model.vo.VideoVo;
import com.mf.server.interceptor.UserHolder;
import org.apache.dubbo.config.annotation.DubboReference;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
public class SmallVideosService {
@DubboReference
private VideoApi videoApi;
@DubboReference
private UserInfoApi userInfoApi;
@Autowired
private RedisTemplate<String, String> redisTemplate;
//查询视频列表
@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数据
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());
//根据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);
}
}
value 与 key 的设计考量
value=「videos」 的命名。value 就是 cacheName,对应 Redis 里的命名空间前缀。叫 videos 而不是 videoCache 或 videoList,有两个实际考虑:一是它和 RedisCacheConfig 里按名称空间配 TTL 的 map 键对应,两边必须一字不差;二是它会在 Redis key 里露脸(videos::106_1_10),排查问题时一眼能认出归属。
key 的三段拼接:userId_page_pagesize。这份缓存要唯一标识「某个人看到的第某页、每页某条」的列表结果:
userId:推荐列表按人不同,两个人查同一页拿到的数据不一样,缺了 userId 就会串数据;page:同一用户的不同页是不同内容,1 页 1 次缓存;pagesize:客户端分页大小可能变化(10 条/20 条),页码相同、条数不同也不是同一份结果。
三段组合下来,106_1_10 就是「用户 106 的第一页、每页 10 条」,唯一对应一次方法调用的返回值。
T(com.mf.server.interceptor.UserHolder).getUserId() 这段 SpEL 是在调静态方法拿当前登录用户,T() 语法的细节在《通用缓存SpringCache:常用注解》里讲过,不展开。key 里的字符串常量要带引号('_'),方法参数前要带 #,这两处漏了都会启动报 SpEL 解析异常。
命中与未命中的两条路径
一次请求进来,SpringCache 的代理对象先拼 key 查 Redis:
| 路径 | 行为 |
|---|---|
命中(Redis 里有 videos::106_1_10) | 方法体一行都不执行,直接反序列化返回缓存里的 PageResult。两次 Dubbo 调用全省 |
| 未命中 | 执行方法体(查推荐列表 / 走 Dubbo 查 MongoDB),拿到 PageResult 后自动序列化写回 Redis,key 就是拼出来的那串 |
注意一个容易忽略的层次问题:@Cacheable 套在方法外层,而方法体内部自己还有一段「查 VIDEOS_RECOMMEND_ 推荐视频 id」的 Redis 逻辑。也就是说这个接口实际上是两层缓存:外层 SpringCache 缓存的是最终 PageResult,内层手写的 RedisTemplate 逻辑缓存的是推荐 id 列表。外层命中时,内层那套逻辑根本不会跑到,这也解释了为什么发布视频必须清 videos 命名空间,光动内层的 id 列表没用。
RedisCacheConfig:给 videos 命名空间设 TTL
不加配置时,SpringCache 写进 Redis 的缓存 TTL 是 -1(永不过期)。实测现象很典型:第一页数据一旦缓存,之后不管数据库里视频列表怎么变,用户看到的永远是那几条,数据不一致的问题直接摆在了脸上。
解法有两个:写操作时清缓存(后文的 @CacheEvict),以及给缓存设置失效时间兜底。SpringCache 配 TTL 的入口是自定义 RedisCacheManager,工程里放在 config/RedisCacheConfig.java:
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 遍历它给每个命名空间单独设值。以后新增要缓存的业务,往 map 里加一行就行,30 秒对推荐列表这种「允许短暂滞后」的数据刚好,太短了缓存形同虚设,太长了脏数据窗口拉大。
这个配置类属于「查资料拿过来用」的固定写法,不需要背,但要能看懂 map 键和 @Cacheable 的 value 的对应关系,配错了名字(比如写成 video),TTL 不会报错,只是默默不生效,TTL 依旧 -1。
发布视频时的缓存失效:@CacheEvict(allEntries=true)
讲义 6.4 给出的写法,给 saveVideos 补上清缓存动作:
/**
* 发布小视频
*/
@CacheEvict(value = "videos", allEntries = true) //清空缓存
public void saveVideos(MultipartFile videoThumbnail, MultipartFile videoFile) throws IOException {
if (videoFile.isEmpty() || videoThumbnail.isEmpty()) {
throw new BusinessException(ErrorResult.error());
}
//1、将视频上传到FastDFS,获取访问URL
String filename = videoFile.getOriginalFilename(); // abc.mp4
filename = filename.substring(filename.lastIndexOf(".") + 1);
StorePath storePath = client.uploadFile(videoFile.getInputStream(), videoFile.getSize(), filename, null);
String videoUrl = webServer.getWebServerUrl() + storePath.getFullPath();
//2、将封面图片上传到阿里云OSS,获取访问的URL
String imageUrl = ossTemplate.upload(videoThumbnail.getOriginalFilename(), videoThumbnail.getInputStream());
//3、构建Videos对象
Video video = new Video();
video.setUserId(UserHolder.getUserId());
video.setPicUrl(imageUrl);
video.setVideoUrl(videoUrl);
video.setText("我就是我,不一样的烟火");
//4、调用API保存数据
String videoId = videoApi.save(video);
if (StringUtils.isEmpty(videoId)) {
throw new BusinessException(ErrorResult.error());
}
}
这里要说明一处工程实读与讲义的差异:08 章代码快照里的 saveVideos 并没有加 @CacheEvict,只有 queryVideoList 上的 @Cacheable 落了地。讲义 6.4 明确要求补上,否则发布新视频后,所有还在 TTL 内的用户看到的都是旧列表。这不是理论风险,这里坑表里的第一个坑就是它,如果发现「发布成功但列表不更新」,先查这里。
为什么必须 allEntries 清全部
@CacheEvict 有两种清法:按 key 精确清(key = "..."))和按命名空间全清(allEntries = true)。这里只能选后者,原因在 key 的构成上:
- 新发布的视频会进入全局视频池,影响的是所有用户的推荐列表,不只是发布者本人;
- 缓存 key 是
userId_page_pagesize三段拼接,发布者根本不知道「谁、在第几页、每页多少条」会看到这条新视频,这些 key 有多少个、长什么样,写代码时无法枚举。
既然精确 key 列不全,就按命名空间整体清空:allEntries = true 会把 videos::* 的所有 key 一次删掉,下一次任何人查任何页都回源到数据库,重新拿到包含新视频的结果。
错误推演:如果只清当前用户缓存会怎样
假设图省事写成按 key 清:
// 错误示范:只清发布者自己的缓存
@CacheEvict(value = "videos",
key = "T(com.mf.server.interceptor.UserHolder).getUserId()+'_'+#page+'_'+#pagesize")
public void saveVideos(...) { ... }
问题不止一个:
- 发布接口的参数里没有
page和pagesize,SpEL 取不到值,求值直接失败; - 就算改成只拼 userId 清掉该用户的所有页(key 前缀匹配 SpringCache 不支持,
key要求全等),清掉的也只是发布者一个人的缓存; - 用户 A、B、C 的
videos::A_1_10、videos::B_1_10原封不动,他们各自的 TTL 窗口内(最长 30 秒)刷列表,新视频就是不出现,而且服务端日志毫无异常,属于最难受的那种「静默脏读」。
清缓存的粒度跟着「数据影响面」走:影响一个人的,按 key 清;影响所有人的,allEntries 清。这条判断在后面几乎所有写缓存场景里都成立。
闭环验证:四步用 redis-cli 实测
配置写完不代表闭环通了,完整的验证链路是「查列表 → 看缓存 → 再查命中 → 发布清缓存 → 再查回源」。全程用 redis-cli 观察(连接的是 application.yml 里配的 192.168.136.160:6379):
第一步:清场后首次调列表接口(未命中,查库)
# 清掉可能存在的旧缓存,保证从零开始
redis-cli -h 192.168.136.160 keys "videos::*"
# (空)
# 登录用户 106,在模拟器里进入视频列表页,触发 GET /smallVideos/videoList?page=1&pagesize=10
redis-cli -h 192.168.136.160 keys "videos::*"
预期现象:接口第一次返回走的是数据库(日志可见 Dubbo 调用),查询完成后 Redis 里多出一个 key:
1) "videos::106_1_10"
key 形态正是 cacheName::key:videos 是 value 指定的命名空间,106_1_10 是 SpEL 拼出的三段 key,用户 106、第 1 页、每页 10 条。
第二步:确认 TTL 与再次调用(命中)
# 不配 RedisCacheConfig 时这里是 -1(永不过期);配上后是 30 秒起跳递减
redis-cli -h 192.168.136.160 ttl "videos::106_1_10"
# 27
# 再刷一次列表页,观察服务端:方法体不执行、没有 Dubbo 调用日志,响应明显变快
预期现象:TTL 是正数且递减(说明 RedisCacheConfig 生效);第二次请求接口直接返回,queryVideoList 的方法体没有执行,这是命中缓存的特征,可以在方法第一行打日志验证。
第三步:发布新视频,观察 @CacheEvict 生效
# 在模拟器发布一条新视频(POST /smallVideos,含视频文件与封面)
redis-cli -h 192.168.136.160 keys "videos::*"
预期现象:发布接口返回成功后,videos::* 下的 key 全部消失,keys 返回空。如果 key 还在,说明 saveVideos 上的 @CacheEvict 没生效,回头检查是不是工程里漏加了(前面说过,08 章代码快照里正好就漏了这一处)。
第四步:再次调列表接口(回源,重建缓存)
# 再刷一次视频列表
redis-cli -h 192.168.136.160 keys "videos::*"
# 1) "videos::106_1_10"
redis-cli -h 192.168.136.160 ttl "videos::106_1_10"
# 30
预期现象:这次请求走了数据库(新视频出现在列表里),查询完成后缓存被重新写入,TTL 重新从 30 秒起跳。至此「读缓存 → 写失效 → 回源 → 新缓存」四步闭环全部验证通过。
顺带一句:生产环境别用 keys 命令做日常观察(全库扫描会阻塞 Redis),验证时图方便可以,线上排查用 scan 0 match "videos::*" count 100 代替。
时序图:发布视频后的缓存流转
坑表
| 坑 | 现象 | 原因与解法 |
|---|---|---|
| @CacheEvict 漏加 | 发布视频成功,但列表页刷新看不到新视频,TTL 窗口内一直如此 | 08 章工程快照里 saveVideos 就漏了这个注解(实读确认)。清缓存的动作要和写数据绑在同一个方法上,发布后列表必须更新的场景不能只靠 TTL 兜底 |
| allEntries 清全部的性能顾虑 | 高频发布场景下缓存命中率被反复清零 | 这是正确性与命中率的取舍:视频发布频率低、影响面是全体用户,全清是对的;如果是单用户私有数据的更新,必须改成按 key 精确清,否则等于互相踢缓存 |
| key 序列化形态看不懂 | redis-cli 里看到 videos::106_1_10,不知道哪段是哪段 | 默认 RedisCacheConfiguration 的 key 是 String 序列化,格式固定为 cacheName::key;:: 前是 value 指定的命名空间,后是 SpEL 拼出的 key。想要更短的 : 分隔符得自定义 computePrefixWith |
| 缓存对象序列化失败 | 接口首次调用直接抛 SerializationException 或 NotSerializableException | 默认 value 用 JDK 序列化,方法返回值必须实现 Serializable。PageResult 已经实现了所以没事;如果哪天返回了没实现序列化接口的 VO,第一次未命中就会炸 |
| TTL 配置了但不生效 | 明明写了 RedisCacheConfig,redis-cli 里 TTL 还是 -1 | map 里的 cacheName 与 @Cacheable 的 value 对不上(比如写成了 video),不报错但不生效;或者启动类漏了 @EnableCaching |
常见误解澄清
| 误解 | 澄清 |
|---|---|
加了 @Cacheable 缓存就「永远一致」了 | 不是。缓存数据在 TTL 窗口内是滞后的,一致性靠「写时清缓存 + TTL 兜底」两条腿,这条链路里任何一环漏了都有脏读窗口 |
| 清缓存只需要清发布者自己的 | 视频列表是全局池,新视频影响所有用户的缓存。按影响面定粒度,这里必须 allEntries = true |
@Cacheable 命中后方法体还会执行部分逻辑 | 不会。代理在方法体之前就返回了,方法体内的推荐 id 查询、Dubbo 调用全都不会跑 |
| SpringCache 和 RedisTemplate 混用会冲突 | 不会,各管各的 key。但要想清楚层次:本例里 SpringCache 缓存最终结果,方法体内的 RedisTemplate 逻辑缓存的另一份数据,清理时要分别考虑 |
| TTL 设得越长越好 | 越长命中率越高,脏数据窗口也越大。列表类数据 30 秒是经验值,最终以业务对「新鲜度」的容忍度为准 |
API 速览
| 注解 / 配置 | 这里用法 | 说明 |
|---|---|---|
@EnableCaching | 启动类上,开启注解缓存 | 不加则所有缓存注解无效 |
@Cacheable(value, key) | queryVideoList,value=「videos」,key=userId_page_pagesize | 语法细节见前一篇常用注解 |
@CacheEvict(value, allEntries) | saveVideos,allEntries=true 清空 videos 命名空间 | 影响面是全体用户时用全清 |
RedisCacheManagerBuilderCustomizer | 按 cacheName 配 TTL(videos → 30s) | cacheName 与 value 必须一致 |
官方文档
- Spring Framework - Cache Abstraction:@Cacheable / @CacheEvict / SpEL key 的权威定义
- Spring Data Redis - Redis Cache Manager:TTL 配置与 key/value 序列化
总结
- 落地就三步:启动类
@EnableCaching,读方法@Cacheable(value="videos", key=userId_page_pagesize),写方法@CacheEvict(value="videos", allEntries=true),再由RedisCacheConfig给 videos 命名空间配 30 秒 TTL。 - key 的三段拼接对应「谁、第几页、每页几条」,缺一段就串数据;
allEntries全清对应「新视频影响全体用户、key 无法枚举」。 - 工程实读有一处与讲义的差异:08 章代码快照的
saveVideos漏了@CacheEvict,只有 TTL 在兜底,发布后最长 30 秒内列表是旧的。这正是「漏加清缓存」的活案例,写代码时要当成检查项。 - 用 redis-cli 四步(首查建缓存 → 再查命中 → 发布清缓存 → 再查回源)验证闭环,每一步都有可观察的现象,TTL 从 -1 变 30 递减、key 的出现与消失都是判断依据。
- 「读缓存 + 写失效 + TTL 兜底」是缓存一致性的最简闭环,所有读多写少的场景,推荐列表、用户资料、配置项,都可以套这个骨架,区别只在 key 怎么拼、清缓存的粒度多大。做新场景时先过一遍这个自检清单,比事后排查「为什么列表不更新」便宜得多。
545



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



