概述
小视频列表是全站刷新最频繁的接口,每次请求都要跨 Redis、Mongo、MySQL 三个存储跑一遍。这里用这条真实链路算一笔账,推导出「必须加缓存」的结论,再拆解手写缓存代码的四个毛病,它们正是 SpringCache 要解决的问题。
纲要
- 问题场景:视频列表接口的三段跨存储开销
- Redis 读推荐 vid 列表
- Mongo 分页查视频
- Dubbo 调用 → MySQL 批查用户资料
- 数据特征分析:为什么视频列表「适合」缓存(读多写少 + 容忍短暂旧数据)
- 缓存决策三问:缓存什么、缓存多久、失效怎么办
- 手写 Redis 缓存的样板代码及其四个问题
- 解决方向预告:AOP + 注解 = 声明式缓存(SpringCache)
- 无缓存 vs 有缓存的请求路径对比
- 常见误解澄清
从视频列表的请求开销说起
在已完成的小视频功能里,SmallVideosService#queryVideoList 是客户端首页的核心接口。用户每滑一下、每次下拉刷新,都会触发一次这个方法。先看它一次请求到底做了什么(此处只看链路,列表查询的完整实现见查询视频列表系列文章):
// 查询视频列表(加缓存前的现状:每次请求都完整走一遍三段链路)
public PageResult queryVideoList(Integer page, Integer pagesize) {
// 第一段:Redis —— 读该用户的推荐 vid 列表
String redisKey = Constants.VIDEOS_RECOMMEND + UserHolder.getUserId();
String redisValue = redisTemplate.opsForValue().get(redisKey);
List<Video> list = new ArrayList<>();
int redisPages = 0;
if (!StringUtils.isEmpty(redisValue)) {
// 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(Long::valueOf)
.collect(Collectors.toList());
list = videoApi.findMovementsByVids(vids); // 第二段:Mongo 按 vid 批量查
}
redisPages = PageUtil.totalPage(values.length, pagesize);
}
// Redis 没有推荐数据:直接 Mongo 分页
if (list.isEmpty()) {
list = videoApi.queryVideoList(page - redisPages, pagesize); // 第二段:Mongo 分页
}
// 第三段:提取 userId,跨服务批查用户资料
List<Long> userIds = CollUtil.getFieldValues(list, "userId", Long.class);
Map<Long, UserInfo> map = userInfoApi.findByIds(userIds, null); // Dubbo → MySQL
// 组装 VideoVo ...
return new PageResult(page, pagesize, 0L, vos);
}
把链路画出来就是这样:
单次请求看着不重,但这类「刷feed」接口的访问模式是高频、重复、内容短期不变。算一笔账:
| 假设 | 数值 |
|---|---|
| 日活用户 | 10 万 |
| 人均日均刷新次数 | 30 次 |
| 日请求量 | 300 万 |
| 峰值 QPS(按流量集中在 4 小时估算) | 数百次/秒 |
也就是说,每秒几百次请求,每一次都完整执行「Redis + Mongo + Dubbo远程调用 + MySQL」三段链路,而其中绝大多数请求拿到的结果和上一次一模一样。Mongo 的分页查询(skip/limit 页码越大越慢)、MySQL 的批量用户查询、Dubbo 的网络往返,全部在重复劳动。数据库的容量是按「存取数据」设计的,不是按「扛流量」设计的,压力最终都会砸在 Mongo 和 MySQL 上。
这就是缓存要解决的问题:把昂贵的查询结果存起来,让重复请求不再穿透到数据库。
这份数据适合缓存吗
不是所有数据都值得缓存。判断标准看两条:读写比例,以及业务对旧数据的容忍度。
读写比例:视频的发布频率是「一个用户几天发一条」,浏览频率是「一个用户一天刷几十次」。读写比悬殊,典型的读多写少。
旧数据容忍度:列表里晚几秒出现一条新视频,用户完全无感知;某条视频的作者改了昵称,列表里旧昵称多显示几分钟也无伤大雅。它不是订单金额、账户余额那种强一致数据。
两条都满足,视频列表就是教科书级的可缓存场景。反例也有:库存数量(写多、强一致)、风控状态(必须实时)就不适合。
给哪里加缓存:三个决策问题
确定要缓存之后,剩下的是三个设计决策。
一、缓存什么? 按「读频率 × 计算成本」给候选数据排序:
| 候选数据 | 读频率 | 计算成本 | 结论 |
|---|---|---|---|
| 视频分页列表结果 | 极高(每次刷新) | 高(三段链路) | 优先缓存,收益最大 |
| 用户资料 | 高(每个列表都要拼) | 中(跨服务 + MySQL) | 可缓存,但注意修改场景的失效 |
| 互动状态(点赞/喜欢) | 高 | 低(单次 Redis 读) | 本身已是缓存层,不必再套一层 |
二、缓存多久? TTL 是「命中率」和「数据新鲜度」的权衡。TTL 太短(比如几秒),缓存刚写进去过期了,命中率趋近于零,等于白做;TTL 太长(比如一天),用户要等一天才能看到新视频。视频列表这种场景,分钟级 TTL(5~10 分钟)是合理的量级,足够扛住峰值流量,又保证「晚几分钟看到新内容」在业务可接受范围内。用户资料这类更稳定的数据可以给到 30 分钟以上。
三、失效怎么办? 光靠 TTL 过期是被动的:用户发完新视频,自己刷新列表却看不到,要等 TTL 到期,体验不可接受。所以原则是**「写时失效」**:发布视频的写操作执行后,主动删掉列表缓存,下次读请求自然回源重建。TTL 只作兜底,不作唯一手段。
这三个问题的答案,最终会落到两个注解上(缓存、失效各一个)。但在引入框架之前,先看看自己动手写是什么样子。
手写缓存:三段样板代码
不用任何缓存框架,靠 SpringDataRedis 手写,每个需要缓存的方法都要套这个模板。以「查询所有用户」为例:
package com.mf.server.service;
import com.mf.model.domain.UserInfo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.concurrent.TimeUnit;
@Service
public class UserInfoService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private UserInfoApi userInfoApi;
// 手写缓存查询所有用户:读缓存 → miss 查库 → 写回
public List<UserInfo> queryAll() {
// 1、先查缓存
String key = "USER_INFO_ALL";
String cacheJson = redisTemplate.opsForValue().get(key);
if (cacheJson != null && !cacheJson.isEmpty()) {
// 2、缓存命中:反序列化直接返回
return JSON.parseArray(cacheJson, UserInfo.class);
}
// 3、缓存未命中:查数据库
List<UserInfo> list = userInfoApi.findAll();
// 4、写回缓存,并设置过期时间
redisTemplate.opsForValue().set(key, JSON.toJSONString(list), 10, TimeUnit.MINUTES);
// 5、返回
return list;
}
}
流程本身没问题,问题在于这个模板会以什么方式蔓延。画出来更直观:
绿色那条支路是缓存的全部价值,红色两条支路是每个方法都要重复书写的成本。逐条数一下这种写法的毛病:
样板重复。「查缓存 → 判空 → 查库 → 写回」这五步在每个需要缓存的方法里原样出现一遍。十个接口要缓存,就抄十遍,改 TTL 或序列化方式时十处一起改。
缓存逻辑侵入业务。方法真正的业务可能只有一句 userInfoApi.findAll(),剩下全是缓存操作代码。业务方法和缓存策略被焊死在同一个方法体里,读业务逻辑时必须跳过缓存代码,改缓存策略时必须打开业务类。
key 管理散乱。key 以字符串字面量的形式散落在各个 Service 里,"USER_INFO_ALL"、"VIDEOS_" + userId + "_" + page……没有统一的命名规范,拼错一个下划线就是一次缓存永不命中,而且很难排查。
容易漏失效。写操作和缓存失效是两处独立代码,改用户资料的 update 方法里必须记得删 USER_INFO_ALL 这个 key。新人加一个修改入口忘了删,缓存里就是一份永不过期(直到 TTL 到期)的旧数据,这类 bug 不报错,只有用户投诉才能发现。
还有一个隐性问题:缓存实现和 Redis 绑死。哪天要换成 Memcached 或进程内缓存,每个方法体都要动刀。
解决方向:把缓存从方法体里抽出来
回到本质看上面那段手写代码:方法内真正干活的只有一句查库调用,其余的缓存操作,本质上都是围绕这句调用做的「增强」,查之前先拦一下,命中就短路返回;没命中就放行执行,拿到结果再补写缓存。
「不修改源代码的前提下对方法增强」,这个描述指向的就是动态代理。在 Spring 里,就是 AOP:
思路顺理成章,但自己实现还有两个坎:
- key 怎么统一生成?A 方法用
A做 key,B 方法用B,代理层怎么知道每个方法该用什么 key?,在方法上标注解,把 key 的规则写在注解里,让代理解析。 - 怎么适配多种缓存?Redis、Memcached 接口各异。,面向接口编程,代理层只依赖缓存抽象,换实现只改一处。
把这两件事做规范,就得到了「注解 + AOP + 缓存抽象」的完整方案。这套方案不需要自己从零写,Spring 官方已经做好了,就是 Spring Cache:一个基于 AOP 的声明式缓存框架,方法上加一个注解即具备缓存能力,且不关心底层用的是 Redis 还是别的缓存实现。它和声明式事务 @Transactional 是同一个思想:把横切逻辑交给框架,业务代码只留业务。
具体怎么用 @Cacheable、@CacheEvict 落到视频列表上,将在后续文章《通用缓存SpringCache:介绍以及入门案例》中展开。
常见误解澄清
| 误解 | 实情 |
|---|---|
| 加了缓存一定更快 | 缓存 miss 时反而更慢:多了一次缓存查询 + 序列化开销,然后照样查库。缓存只有在命中率足够高时才是正收益 |
| 缓存设好就不用管失效 | 不做失效设计,用户发布视频后自己刷新看不到新内容。「写时失效 + TTL 兜底」是标准组合,缺一不可 |
| 所有数据都可以缓存 | 写多读少的数据(库存计数)命中率低白占内存;强一致数据(账户余额)缓存后可能造成资金类事故。缓存前先看读写比和一致性要求 |
| 缓存和数据库会自动保持一致 | 不会。缓存是独立存储,任何一次写库都不会自动通知缓存。一致性完全依赖你设计的失效策略 |
| 缓存 = Redis | Redis 只是常见实现之一。Memcached 同属分布式缓存,Guava/Caffeine 是进程内缓存。Spring Cache 的价值之一就是把这些实现藏在抽象后面 |
官方文档
总结
- 视频列表一次请求要串 Redis、Mongo、MySQL 三段开销,刷 feed 场景下高频重复访问,把这个成本放大成数据库的实际压力。
- 读多写少、容忍短暂旧数据,两个条件同时满足才值得缓存;视频列表恰好都满足,TTL 取分钟级量级,失效靠「写时主动删除 + TTL 兜底」。
- 手写缓存的五步模板本身没错,错在它必须随接口数量复制:样板重复、侵入业务、key 散乱、易漏失效,且与 Redis 实现绑死。
- 解法是熟悉的思路,用动态代理把缓存增强从业务方法中抽走,key 和缓存操作写成注解交给代理解析。这就是 Spring Cache 的全部设计动机,后续文章将从入门案例开始动手。
2136



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



