概述
小视频页是滑动即刷的信息流,列表接口的好坏直接决定首页体验。客户端要的是一页「带作者资料的短视频卡片」,而不是一堆裸的 Mongo 文档。这一篇把 GET /smallVideos 这条链路从方案到代码完整讲透:推荐视频优先(Redis 里的 vid 列表)、无推荐时按时间倒序分页兜底,再批查用户资料组装 VideoVo 返回。查询策略为什么这样做、接口文档长什么样先在需求侧想清楚,再把代码落到工程。
业务场景
小视频 Tab 的交互很直接:双列瀑布流铺满封面图,上滑加载更多,点进某条全屏播放。这是典型的信息流页面,用户对它的预期只有两条,内容要「新」(有人持续发布)、加载要快。
列表数据从哪来?课程给出的需求规则只有一句话,但信息量足够:
如果有推荐的视频,优先返回推荐的视频内容;如果没有,按照时间倒序查询数据库。
拆开看,这其实是两个数据源、一种视图:
| 数据源 | 内容 | 排序依据 | 何时使用 |
|---|---|---|---|
Redis 推荐列表(VIDEOS_RECOMMEND_ + 用户 id) | 运营/推荐系统为该用户挑好的 vid 串 | 推荐系统给的顺序(即推荐优先级) | 有推荐数据且够分一页 |
Mongo video 集合 | 全量视频表 | created 倒序(最新的在前) | 无推荐数据,或推荐数据已被翻完 |
这里容易和圈子动态的列表混在一起,放一张对照表:
| 圈子动态 | 小视频 | |
|---|---|---|
| 推荐数据 Redis key | MOVEMENTS_RECOMMEND_ | VIDEOS_RECOMMEND_ |
| 兜底数据 | Mongo movement 集合 | Mongo video 集合 |
| 兜底排序 | 创建时间倒序 | created 倒序 |
| 关注关系 | 好友动态查 timeline | 关注关系存 Redis(FOCUS_USER_KEY_ 前缀,见关注功能篇),本模块只用于 hasFocus 状态 |
套路完全同构:圈子动态的推荐列表也是从 Redis 取推荐 pid、翻完了回落数据库。理解了圈子动态的列表实现,这一节几乎是「换个集合再走一遍」。
顺带回答一个自然的疑问,为什么不直接查数据库? 视频表的量级远大于用户自己产生的关系数据,直接翻库意味着每次刷新都是一次全表范围的 skip/limit 深分页;推荐数据放 Redis,一是把热门内容挡在数据库前面,二是保留了按用户个性化排序的可能。数据库只做「兜底」,这是后面 SpringCache 缓存之外的另一层削峰。
查询设计:推荐优先 + 时间倒序兜底
把上面两条规则翻译成执行流程,是这样一个顺序判断(判断条件比直觉多,因为推荐数据的量是任意的):
对应到工程代码,三个方法各管一段:
mf
├── mf-app-server # SmallVideosService.queryVideoList:流程编排 + VO 组装
├── mf-dubbo-interface # VideoApi:findMovementsByVids / queryVideoList
├── mf-dubbo-mongo # VideoApiImpl:Mongo 查询实现
└── mf-model # Video(实体)/ VideoVo(返回对象)
第一步:推荐区,Redis 取 vid 串,vid in 批查
推荐数据不是 Set,也不是 Hash,而是一个逗号分隔的 vid 字符串,key 为 VIDEOS_RECOMMEND_ 拼用户 id:
public static final String VIDEOS_RECOMMEND = "VIDEOS_RECOMMEND_";
//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());
//调用API根据VID数组查询视频数据
list = videoApi.findMovementsByVids(vids);
}
redisPages = PageUtil.totalPage(values.length,pagesize);
}
注意分页发生在 Redis 侧而不是数据库侧:skip((page-1)*pagesize).limit(pagesize) 是在 vid 数组上截取本页要哪几条,然后一次性 vid in [...] 查 Mongo。推荐顺序由数组顺序决定,Mongo 查回来不会保持这个顺序(in 查询无排序),这是「推荐优先级」最终排序的隐患之一,属于实读时能看出的真实瑕疵。
方法名 findMovementsByVids 带着明显的复制粘贴痕迹,「Movements」是圈子动态的词汇,功能上它就是「按 vid 批查视频」:
@Override
public List<Video> findMovementsByVids(List<Long> vids) {
Query query = Query.query(Criteria.where("vid").in(vids));
return mongoTemplate.find(query, Video.class);
}
第二步:兜底区,created 倒序分页
三种情况会走到兜底:Redis 里压根没推荐数据、推荐 vid 翻完了(起始条数越界)、vid 批查结果为空(推荐的视频已被删)。兜底查询不看用户,只按时间倒序取一页:
//4、如果redis数据不存在,分页查询视频数据
if(list.isEmpty()) {
//page的计算规则:传入的页码 -- redis查询的总页数
list = videoApi.queryVideoList(page - redisPages, pagesize);
}
@Override
public List<Video> queryVideoList(int page, Integer pagesize) {
Query query = new Query().limit(pagesize).skip((page -1) * pagesize)
.with(Sort.by(Sort.Order.desc("created")));
return mongoTemplate.find(query, Video.class);
}
三个实读结论,回答「到底排除谁、按什么排」:
- 不排除自己:查全量视频表,自己发的视频也会出现在列表里;
- 不排除已看过的:教学工程没有观看历史,翻页刷新会重复出现;
- 排序就是
created倒序,没有任何热度加权。
页码换算:page - redisPages 的坑
推荐区和兜底区在「一页」的尺度上要无缝衔接。假如推荐串里有 23 条、每页 10 条,则第 1~3 页(redisPages = 3)从推荐区出;用户请求第 4 页时,推荐区已越界,落到兜底区,但 Mongo 的页码不能还是 4,得减去推荐区占掉的部分,4 - 3 = 1,即从视频表第 1 页接着取。
这个换算有个前提:两次翻页之间推荐串不能变化。推荐数据一刷新,页码错位,用户会看到重复或跳过的内容。这类一致性问题在缓存篇会再碰面。
互动状态怎么算
关注关系(FOCUS_USER_KEY_ 前缀,存 Redis,后续章节落库)和点赞状态(VIDEO_LIKE_HASHKEY)都是「当前登录用户 × 视频」的维度,天然无法缓存复用。这里只做需求定位:hasLiked、hasFocus 是 VideoVo 的固定字段,本章代码里 VideoVo.init 先统一置 0 占位,点赞/关注互动实现在后续章节再逐个填充,列表接口的字段结构现在就定死,避免前端跟着改。
接口与文件清单
完整链路涉及的代码分布如下,代码实现一节会逐文件展开:
| 文件 | 所在模块 | 职责 |
|---|---|---|
SmallVideosController | mf-app-server | 接收 GET /smallVideos 请求,透传分页参数 |
SmallVideosService | mf-app-server | 编排整条链路:Redis 推荐 → Mongo 兜底 → 组装 VO |
VideoApi | mf-dubbo-interface | 定义 queryVideoList / findMovementsByVids 两个 Dubbo 接口 |
VideoApiImpl | mf-dubbo-mongo | MongoTemplate 实现分页与 vid 批查 |
VideoVo | mf-model | 封装返回给客户端的视图对象 |
mf
├── mf-app-server
│ └── src/main/java/com/mf/server
│ ├── controller
│ │ └── SmallVideosController.java
│ ├── service
│ │ └── SmallVideosService.java
│ └── interceptor
│ └── UserHolder.java # ThreadLocal 持有当前登录用户 id
├── mf-dubbo
│ ├── mf-dubbo-interface
│ │ └── src/main/java/com/mf/dubbo/api
│ │ └── VideoApi.java
│ └── mf-dubbo-mongo
│ └── src/main/java/com/mf/dubbo/api
│ └── VideoApiImpl.java
└── mf-model
└── src/main/java/com/mf/model
├── mongo
│ └── Video.java # video 集合实体
└── vo
└── VideoVo.java # 返回给客户端的视图对象
Controller:列表接口
列表接口沿用发布视频同一个 Controller,@RequestMapping("/smallVideos") 类级路径 + 无参 @GetMapping,所以实际请求路径就是 GET /smallVideos?page=1&pagesize=10(实测工程代码,路径与接口文档一致)。
package com.mf.server.controller;
import com.mf.model.vo.PageResult;
import com.mf.server.service.SmallVideosService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
@RestController
@RequestMapping("/smallVideos")
public class SmallVideosController {
@Autowired
private SmallVideosService videosService;
/**
* 视频列表
* 请求路径:GET /smallVideos?page=1&pagesize=10
* page、pagesize 均有默认值,客户端不传也能查第一页
*/
@GetMapping
public ResponseEntity queryVideoList(@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pagesize) {
PageResult result = videosService.queryVideoList(page, pagesize);
return ResponseEntity.ok(result);
}
}
Controller 没有任何业务逻辑,连登录用户 id 都不取,后面 Service 里用 UserHolder 从 ThreadLocal 拿,拦截器在进 Controller 之前已经把 token 解析完放进去了。
Service:queryVideoList 完整实现
查询策略
推荐池放 Redis:VIDEOS_RECOMMEND_ + userId 这个 key 里存着推荐系统算好的 vid 列表,逗号分隔。查询时两种情况:
页码换算是这段代码最容易看漏的地方:推荐列表占 2 页时,客户端请求第 3 页,落到 Mongo 里其实是第 1 页,所以才有 page - redisPages。
完整代码
package com.mf.server.service;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.PageUtil;
import com.github.tobato.fastdfs.domain.conn.FdfsWebServer;
import com.github.tobato.fastdfs.service.FastFileStorageClient;
import com.mf.autoconfig.template.OssTemplate;
import com.mf.commons.utils.Constants;
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.ErrorResult;
import com.mf.model.vo.PageResult;
import com.mf.model.vo.VideoVo;
import com.mf.server.exception.BusinessException;
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 org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
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 {
@Autowired
private FastFileStorageClient client;
@Autowired
private FdfsWebServer webServer;
@Autowired
private OssTemplate ossTemplate;
@DubboReference
private VideoApi videoApi;
@DubboReference
private UserInfoApi userInfoApi;
@Autowired
private RedisTemplate<String,String> redisTemplate;
/**
* 分页查询视频列表
* @Cacheable 是本章后续 SpringCache 部分加上的(方法结果按 userId_page_pagesize 缓存),
* 分析执行链路时先当它不存在
*/
@Cacheable(
value = "videos",
key = "T(com.mf.server.interceptor.UserHolder).getUserId()+'_'+#page+'_'+#pagesize")
public PageResult queryVideoList(Integer page, Integer pagesize) {
//1、拼接推荐列表的 Redis key,读取推荐系统预置的 vid 串(格式如 "1001,1002,1003")
String redisKey = Constants.VIDEOS_RECOMMEND + UserHolder.getUserId();
String redisValue = redisTemplate.opsForValue().get(redisKey);
//2、推荐列表存在时,判断推荐数据是否够分到当前页
List<Video> list = new ArrayList<>();
int redisPages = 0;
if(!StringUtils.isEmpty(redisValue)) {
//3、vid 之间以逗号分隔,切分后按分页规则截取本页的 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());
//4、按 vid 批量查询推荐视频
list = videoApi.findMovementsByVids(vids);
}
//推荐列表能凑成的总页数(Hutool 工具:totalCount / pageSize 向上取整)
redisPages = PageUtil.totalPage(values.length, pagesize);
}
//5、推荐数据不足或不存在时,走时间倒序分页兜底
if(list.isEmpty()) {
//页码换算:传入页码减去推荐列表占掉的页数,得到 Mongo 里的真实页码
list = videoApi.queryVideoList(page - redisPages, pagesize);
}
//6、提取视频列表中所有发布者的用户 id(Hutool 反射取字段,自动去重由 findByIds 内部处理)
List<Long> userIds = CollUtil.getFieldValues(list, "userId", Long.class);
//7、一次性批查所有用户资料,避免循环内逐条查(N+1)
Map<Long, UserInfo> map = userInfoApi.findByIds(userIds, null);
//8、逐条组装 VideoVo
List<VideoVo> vos = new ArrayList<>();
for (Video video : list) {
UserInfo info = map.get(video.getUserId());
//虚拟造数可能存在 userId 在用户表中无记录的情况,跳过防止空指针
if(info != null) {
VideoVo vo = VideoVo.init(info, video);
vos.add(vo);
}
}
return new PageResult(page, pagesize, 0L, vos);
}
}
三个细节值得停留一下:
Constants.VIDEOS_RECOMMEND的值是"VIDEOS_RECOMMEND_",和后面互动标记用的FOCUS_USER_KEY = "FOCUS_USER_KEY_"一样,都定义在 mf-commons 的Constants里,Redis key 统一处管理。PageUtil来自 Hutool 的cn.hutool.core.util.PageUtil,totalPage(total, size)做向上取整,10 条推荐、每页 10 条时返回 1 页。- 第 5 步的
page - redisPages在讲义源码里留着一行//page=1 ?的疑问注释,当推荐页数比请求页码还大时这里会算出 0 或负数,skip收到负数会抛IllegalArgumentException。实际场景里推荐池条数有限(通常只有一页多),这个边界很少触发,但造数测试时如果 vid 塞得太多就会撞上。
「排除已看过」的说明
需求分析里提过「已看过的视频不再出现在推荐列表」这类想法。实测工程代码,queryVideoList 里没有任何排除逻辑,推荐去重完全交给推荐系统写 Redis 之前处理,列表链路只管照单查库。后续引入 SpringCache 缓存的也是「列表结果」而非「已看记录」。如果你要在自己项目里加「排除已看」,正确的挂点是第 5 步之前(对 vid 列表过滤),而不是查完再删。
Dubbo API:接口定义与 Mongo 实现
VideoApi 接口
package com.mf.dubbo.api;
import com.mf.model.mongo.Video;
import java.util.List;
public interface VideoApi {
//保存小视频,返回 objectId 字符串
String save(Video video);
//根据 vid 批量查询视频(推荐列表用)
List<Video> findMovementsByVids(List<Long> vids);
//按时间倒序分页查询视频(无推荐时兜底)
List<Video> queryVideoList(int page, Integer pagesize);
}
VideoApiImpl 实现
package com.mf.dubbo.api;
import com.mf.dubbo.utils.IdWorker;
import com.mf.model.mongo.Video;
import org.apache.dubbo.config.annotation.DubboService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Sort;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import java.util.List;
@DubboService
public class VideoApiImpl implements VideoApi {
@Autowired
private MongoTemplate mongoTemplate;
@Autowired
private IdWorker idWorker;
@Override
public String save(Video video) {
//1、设置属性:vid 用 IdWorker 生成,created 记录毫秒时间戳
video.setVid(idWorker.getNextId("video"));
video.setCreated(System.currentTimeMillis());
//2、保存到 video 集合
mongoTemplate.save(video);
//3、返回 objectId 字符串
return video.getId().toHexString();
}
/**
* 根据 vid 批量查询视频列表
*/
@Override
public List<Video> findMovementsByVids(List<Long> vids) {
Query query = Query.query(Criteria.where("vid").in(vids));
return mongoTemplate.find(query, Video.class);
}
/**
* 分页查询视频列表,按 created 倒序(最新发布的在前)
*/
@Override
public List<Video> queryVideoList(int page, Integer pagesize) {
Query query = new Query().limit(pagesize).skip((page - 1) * pagesize)
.with(Sort.by(Sort.Order.desc("created")));
return mongoTemplate.find(query, Video.class);
}
}
queryVideoList 的写法是 Spring Data MongoDB 分页的固定套路:new Query() 不带任何 Criteria 条件表示全集合查询,skip((page-1) * pagesize) 跳过前面页的数据,limit(pagesize) 截取本页,with(Sort...) 挂排序。注意排序依据是 created(发布时间戳),不是 _id,两者时序基本一致,但显式按 created 排序语义更清楚。
这里有个隐患先记下:video 集合默认没有为 created 建索引,数据量上来后 sort + skip + limit 会在内存里排序。数据量小(教学库几百条)时无所谓,生产上应给 created 加索引,深分页则要换成「基于游标」的分页方式。
VideoVo:两个字段表的拼接
VideoVo 把 Mongo 实体 Video 和 MySQL 侧的 UserInfo 揉成一个对象。组装靠两次 BeanUtils.copyProperties,同名属性自动拷贝,不同名的手动补。Video 实体只有视频自身的字段,列表页要展示的却远不止这些,VideoVo 多出来的部分分两类:
| 字段 | 来源 | 说明 |
|---|---|---|
avatar / nickname | 用户资料 UserInfo | 头像、昵称 |
cover | Video.picUrl 改名 | 封面图 |
signature | Video.text 改名 | 发布时配的文字 |
likeCount / commentCount | Video 原生计数 | 直接透传 |
hasLiked / hasFocus | Redis 互动状态 | 本章默认 0,后续互动章节填充 |
用户资料是批查的,不是一条视频查一次,先把本页所有视频的 userId 提出来,一次 findByIds 拿回 Map,循环里 O(1) 取用:
//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);
}
}
这也是跨存储组装的标准姿势:Mongo 存视频、用户资料在另一套存储,靠一次批量查询 + Map 关联,把 N+1 次远程调用压成 1 次。空资料的视频直接跳过(info != null 判断),而不是让整个请求失败。
完整 VO 定义与组装逻辑如下:
package com.mf.model.vo;
import com.mf.model.domain.UserInfo;
import com.mf.model.mongo.Video;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.beans.BeanUtils;
import java.io.Serializable;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class VideoVo implements Serializable {
private Long userId;
private String avatar; //头像(来自 UserInfo)
private String nickname; //昵称(来自 UserInfo)
private String id; //视频 objectId 字符串
private String cover; //封面(对应 Video.picUrl)
private String videoUrl; //视频 URL
private String signature; //发布视频时输入的文字(对应 Video.text)
private Integer likeCount; //点赞数量
private Integer hasLiked; //当前用户是否已赞(1是,0否)
private Integer hasFocus; //当前用户是否已关注作者(1是,0否)
private Integer commentCount; //评论数量
public static VideoVo init(UserInfo userInfo, Video item) {
VideoVo vo = new VideoVo();
//copy 用户属性:avatar、nickname 同名拷贝
BeanUtils.copyProperties(userInfo, vo); //source, target
//copy 视频属性:videoUrl、likeCount、commentCount 同名拷贝
BeanUtils.copyProperties(item, vo);
//不同名属性手动补
vo.setCover(item.getPicUrl());
vo.setId(item.getId().toHexString());
vo.setSignature(item.getText());
//互动状态默认 0,本章未实现判存
vo.setHasFocus(0);
vo.setHasLiked(0);
return vo;
}
}
字段来源对照:
| VideoVo 字段 | 来源 | 拷贝方式 |
|---|---|---|
userId | Video.userId(同名,两次 copy 中后一次覆盖) | 自动 |
avatar / nickname | UserInfo | 自动(同名) |
id | Video.id(ObjectId → 十六进制字符串) | 手动 |
cover | Video.picUrl | 手动(改名) |
videoUrl | Video.videoUrl | 自动(同名) |
signature | Video.text | 手动(改名) |
likeCount / commentCount | Video 同名字段 | 自动 |
hasLiked / hasFocus | 本章硬编码 0 | 手动置 0 |
hasLiked / hasFocus 这里要如实说:工程代码里没有做 Redis 判存,init 里直接置 0。也就是说此刻列表上所有视频都显示「未点赞、未关注」,哪怕你真的赞过。完成这两状态的思路与圈子互动完全一致,从 Redis 的 LIKE_KEY_ / FOCUS_USER_KEY_ 标记里 isMember 判定,再 setHasLiked(1)。这是留给后续互动功能补的口子,字段先在 VO 里占位,保证接口契约稳定,客户端不用等后端加字段。
关注列表与推荐列表的实现差异
工程只实现了推荐列表优先 + 时间倒序兜底这一条链路,没有独立的「关注列表」接口。两者的实现差异值得对比着理解,因为分页思路完全不同:
| 维度 | 推荐列表(本章实现) | 关注列表(思路) |
|---|---|---|
| 数据源 | Redis VIDEOS_RECOMMEND_ + userId 存 vid 串 | Redis FOCUS_USER_ + userId 集合存关注者 id |
| 查询方式 | 按 vid in 查询(推荐池本来就只有几十条) | 先查关注者 id,再按 userId in 查视频 |
| 分页位置 | 在 Redis 列表上 skip/limit 截取 vid | 两种:视频少则 in 后内存分页;多则 Mongo query 里带 userId in + skip/limit |
| 页码换算 | 需要 page - redisPages 混合页码换算 | 不需要,单一数据源 |
| 排序 | 推荐系统给的顺序(vid 串顺序即优先级) | created 倒序 |
关注列表的落地大致是这样(思路代码,非工程实读内容):
//思路:Service 里先取关注集合,再让 VideoApi 支持按 userId 列表分页查
Set<String> focusIds = redisTemplate.opsForSet().members("FOCUS_USER_" + UserHolder.getUserId());
List<Long> userIds = focusIds.stream().map(Long::valueOf).collect(Collectors.toList());
//VideoApi 增加一个按发布者分页的方法:
//Query query = new Query(Criteria.where("userId").in(userIds))
// .limit(pagesize).skip((page - 1) * pagesize)
// .with(Sort.by(Sort.Order.desc("created")));
推荐列表用 vid 串而不是 Set,正是因为推荐结果有顺序语义,List 截取天然保序,Set 的 members 不保证顺序。
性能问题:SpringCache 是明确要补的一刀
把上面的流程串一遍,一次列表请求至少包含:1 次 Redis GET、1 次 Mongo 查询、1 次用户资料批查、外加 VO 组装。前两项随 page/pagesize 变化,但结果集对同一用户同一页是稳定的,用户来回滑动时,同样的查询在反复执行。
把 queryVideoList 拆开看,每次用户下拉刷新都会完整执行三段:
- Mongo 分页查询:
findMovementsByVids或sort + skip + limit,其中兜底分支的全集合排序在无索引时是内存排序; - 批查用户资料:
userInfoApi.findByIds走 Dubbo + MySQL,一次in查询,开销可控但每次刷新都发生; - 组装开销:反射取字段 + 逐条
BeanUtils.copyProperties。
关键在于这三段的结果对同一个 userId_page_pagesize 组合是不变的,视频列表不会因为多刷一次就变。也就是说每次刷新都在重复计算一份相同答案,这正是缓存的教科书场景。工程最终版的代码里已经加上了 @Cacheable 注解(实读 SmallVideosService):
//查询视频列表
@Cacheable(
value = "videos",
key = "T(com.mf.server.interceptor.UserHolder).getUserId()+'_'+#page+'_'+#pagesize")
public PageResult queryVideoList(Integer page, Integer pagesize) { ... }
本章后续用 SpringCache 解决:方法上加 @Cacheable(就是前面 Service 代码里那行注解),结果按 userId_page_pagesize 缓存进 Redis,第二次同参请求直接命中,三段开销归零。这里只点出「为什么要缓存」:列表查询高频、结果可复用、失效时机明确(发布视频时清空)。SpringCache 的注解模型、缓存对齐与失效策略,属于本章 SpringCache 系列的内容。缓存粒度、过期策略、发布新视频后如何 @CacheEvict 失效,这些属于本章 SpringCache 系列的内容,那里展开。
先在这里留个思考题:@Cacheable 的 key 为什么要拼上 userId?,因为推荐列表是按用户隔离的(VIDEOS_RECOMMEND_ + userId),不拼 userId 的话,A 用户的推荐页会串给 B 用户。
整条链路的时序
需求分析版时序图(参与者命名略有不同,描述同一条链路):
原型线框图
小视频列表页(双列瀑布流)与全屏播放页,文案取原型原文:
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ 推荐 │ │ [更多] @小美 [关注] │
│ │ │ ────────────────────── │
│ ┌─────────┐ ┌─────────┐ │ │ (全屏视频播放区) │
│ │ 封面图 │ │ 封面图 │ │ │ │
│ │ │ │ │ │ │ ♡ 10 │
│ └─────────┘ └─────────┘ │ │ ✎ 15 │
│ 1024程序员节 1024程序员节 │ │ │
│ │ │ ┌─────────────────────┐ │
│ ┌─────────┐ ┌─────────┐ │ │ │ 说点什么 │ │
│ │ 封面图 │ │ 封面图 │ │ │ └─────────────────────┘ │
│ │ │ │ │ │ │ 小视频标题不超过20字 │
│ └─────────┘ └─────────┘ │ │ 点击更多出弹框 拉黑、举报 │
│ 1024程序员节 1024程序员节 │ │ 点击评论,弹出评论框,显示 │
│ │ │ 评论内容及评论信息。 │
│ ┌─────────────────────┐ │ │ │
│ │ + (上传 / 录制) │ │ │ [交友] [圈子] [小视频] [消息] [我的] │
│ └─────────────────────┘ │ │ │
└─────────────────────────────┘ └─────────────────────────────┘
列表页(一屏一图) 播放页(一屏一图)
要点:
- 顶部 Tab 文案「推荐」,对应这里的推荐优先逻辑;
- 双列瀑布流,每个卡片 = 封面图 + 标题文案(「1024程序员节」,即 VideoVo 的
cover+signature)+ 互动计数; - 卡片标题有约束:「小视频标题不超过20字」,发布端要校验,列表端按此宽度截断展示;
- 右下角悬浮「+」按钮,展开「上传 / 录制」,即发布视频入口(属发布篇,不在这里展开);
- 播放页右上角「更多」出弹框(拉黑、举报);作者信息旁是「关注」按钮,状态即
hasFocus; - 评论入口点击弹出评论面板(「共15条评论」、「说点什么」),属后续视频评论篇。
API 速览
接口文档(EasyYapi)约定如下:
| 项 | 值 |
|---|---|
| 请求路径 | /smallVideos |
| 请求方式 | GET |
| 请求参数 | page(页码,默认 1)、pagesize(每页条数,默认 10) |
| 响应结构 | PageResult:pages/pagesize/counts/items |
| items 元素 | VideoVo |
请求与响应示例:
// GET /smallVideos?page=1&pagesize=5
{
"pages": 8,
"pagesize": 5,
"counts": 36,
"items": [
{
"userId": 106,
"avatar": "https://mf-oss.oss-cn-qingdao.aliyuncs.com/images/106.jpg",
"nickname": "小美",
"id": "61a5f1e0a3b4c2d8e6f0a1b2",
"cover": "https://oss.example.com/cover/1.jpg",
"videoUrl": "http://192.168.136.160:8888/group1/M00/00/01/video_1.mp4",
"signature": "1024程序员节",
"likeCount": 10,
"hasLiked": 0,
"hasFocus": 0,
"commentCount": 15
}
]
}
异常与边界
| 场景 | 现象 | 处理 |
|---|---|---|
| 无任何视频 | 推荐区、兜底区都为空 | 返回空 items 的 PageResult,前端展示占位,不报错 |
| 推荐串为空串/null | redisValue 判空后直接走兜底 | StringUtils.isEmpty 拦截,等价于无推荐 |
| 推荐的视频已从 video 集合删除 | vid in 查询结果少于预期甚至为空 | 结果为空则整体回落兜底区;部分缺失则该页数量变少 |
| 关注的人没发过视频 | 关注列表无内容 | 本模块视频列表不区分关注视图,hasFocus 只标记状态;关注维度内容不会在本接口为空 |
| 分页越界(page 很大) | skip 超出集合总量 | Mongo 返回空列表,按空页处理 |
| 视频发布人是虚拟/不存在用户 | UserInfo 查不到,组装时空指针 | 循环里 info != null 跳过;根治靠清理脏数据(讲义实测:测试环境 video 集合有虚拟用户数据导致 NPE,删除后恢复) |
| 互动状态判断开销 | 每条视频查一次 Redis 判点赞/关注 | 本章未启用(默认 0);互动章节实现时需批量化,避免循环单查 |
| 分页无稳定排序感知 | 客户端连续翻页,偶见视频重复或丢失 | sort + skip 本身稳定,但如果没有排序(漏写 Sort.by),Mongo 不保证返回顺序,skip 的意义就没了。本章代码按 created 倒序,别删那一行 |
| 互动状态 key 拼错 | hasLiked 永远显示未赞 | 判存时 key 拼写与写入时不一致(如 LIKE_KEY_ 漏了下划线),isMember 永远 false。key 全部走 Constants 常量,禁止手写字符串 |
| 批查变 N+1 | 列表接口一次请求发出 10 次 Dubbo + 10 条 SQL | 循环里逐条 userInfoApi.findById(single)。正确写法是先提取 userIds 再一次 findByIds |
counts 口径不一致 | 点赞数与详情页对不上 | Video.likeCount 是发布侧冗余计数,互动时必须同步 inc 更新,否则列表数与互动详情两套口径 |
| 用户表脏数据空指针 | 列表接口偶发 500,日志 NPE | 造数时 video.userId 在 user_info 表无对应记录,init 里 copyProperties(userInfo) 抛 NPE。本章用 if(info != null) 跳过,或清理脏数据 |
| 视频文件删除后引用失效 | 列表封面 404 | picUrl/videoUrl 存的是 FastDFS/OSS 地址,文件删除后 Mongo 文档还在。删除视频要「先删文件、再删文档」或标记删除 |
| 页码换算出现非正数 | skip 抛 IllegalArgumentException | 推荐列表页数大于请求页码时 page - redisPages <= 0。测试造数 vid 过多时容易触发,兜底分支前对换算结果做 Math.max(1, ...) |
最值得展开的是空指针和互动状态两行。空指针问题在讲义里被明确记录为「运行测试时极易出现」,根因是 MongoDB 这类文档库不维护跨集合引用约束,video.userId 指向一个不存在的用户资料毫无报错,直到组装 VO 时才炸。这决定了列表接口必须把「引用失效」当作常态防御,而不是异常。互动状态的开销则是下一篇之前的伏笔:一页 10 条视频,hasLiked、hasFocus 各判一次就是 20 次 Redis 往返,若逐条查会显著拖慢接口。批量化(一次 mget/pipeline)或干脆连 VO 一起缓存,是两个方向的解法,后者由 SpringCache 承担。
验证要点
按课程的操作路径验证:
- 造数:用 Postman 走发布视频流程,向
video集合插入若干条不同用户、不同时间的视频;手工向 Redis 写入VIDEOS_RECOMMEND_{userId}(逗号分隔 vid,条数设为非整页数,如 23 条配 pagesize=10,专门考验页码换算)。 - 列表核对:
GET /smallVideos?page=1&pagesize=10,核对第 1~3 页返回的是推荐 vid 对应的视频(可用 signature 打标记区分);第 4 页起应切换为created倒序的全量视频。 - 边界核对:把推荐串清空后请求,确认直接走兜底;请求一个超出总量的 page,确认返回空列表不报错;删除一条推荐 vid 对应的视频文档,确认该页条数变少但不 500。
- 差异确认:同一用户同一页连续请求两次,结果一致(为下一篇验证 SpringCache 命中做基线)。
按下面的步骤可以在本地把整条链路跑通并逐字段核对。
第一步:mongosh 造数。往 video 集合插两条测试视频(userId 用库里真实存在的用户 id):
use mf
db.video.insertMany([
{
vid: NumberLong(9001),
created: NumberLong(1727654400001),
userId: NumberLong(106),
text: "测试视频一",
picUrl: "https://oss.example.com/cover1.jpg",
videoUrl: "http://192.168.136.160:8888/group1/M00/00/00/video1.mp4",
likeCount: 3, commentCount: 1, loveCount: 0
},
{
vid: NumberLong(9002),
created: NumberLong(1727654400002),
userId: NumberLong(1),
text: "测试视频二",
picUrl: "https://oss.example.com/cover2.jpg",
videoUrl: "http://192.168.136.160:8888/group1/M00/00/00/video2.mp4",
likeCount: 0, commentCount: 0, loveCount: 0
}
])
第二步:预置推荐列表(也可跳过,直接测时间倒序兜底分支):
redis-cli set "VIDEOS_RECOMMEND_106" "9001,9002"
第三步:调接口(token 换成自己登录后拿到的):
curl -s "http://localhost:18080/smallVideos?page=1&pagesize=5" \
-H "Authorization: <token>" | python3 -m json.tool
第四步:逐字段核对 VideoVo。响应里的每条视频应满足:
id为 24 位十六进制字符串(ObjectId 的toHexString);cover等于造数时的picUrl,videoUrl原样带出;signature等于text(「测试视频一」),证明手动映射生效;nickname/avatar是 userId 对应用户的资料,证明批查生效;likeCount= 3(视频一)、hasLiked= 0、hasFocus= 0(本章未判存,恒为 0,符合预期)。
第五步:验证分页与推荐优先。造 12 条视频、pagesize=10:
- 无 Redis 推荐时,第 1 页 10 条按
created倒序,第 2 页剩 2 条; - 写入 15 个 vid 的推荐串后,第 1、2 页来自推荐池(vid 顺序),第 3 页起
page - redisPages换算后走 Mongo 兜底,数据恰好无缝衔接; - 把某条 video 的
userId改成 999999(用户表不存在的 id),该条从列表消失且不报错,验证空指针防护。
常见误解澄清
| 误解 | 实际情况 |
|---|---|
推荐列表用 Redis Set 存,查询时 SMEMBERS 取 id | 实读为 String 值存逗号分隔 vid 串,split(",") 后在应用内存里分页;FOCUS_USER_ 才是关注关系的前缀,两者不是一回事 |
| 视频模块有独立的「关注列表」接口(先查关注关系再查这些人的视频) | 实读只有一个 /smallVideos 接口:推荐优先 + 时间倒序兜底。关注关系在本模块只体现为 hasFocus 字段,不构成查询条件 |
| 列表会排除自己发的视频和已看过的视频 | 实读 queryVideoList 查全量 video 集合,不排除任何人,也没有观看历史过滤 |
hasLiked/hasFocus 在这里就已实现 | 本章 VideoVo.init 统一置 0 占位,互动状态在后续点赞/关注章节填充 |
| 兜底查询按热度或推荐分排序 | 只按 created 倒序,无任何加权 |
| 推荐区翻完后页码从 1 重新算 | 传入页码需减去推荐区总页数(page - redisPages),否则内容跳接错位 |
findMovementsByVids 是动态接口 | 名字是复制自圈子动态的残留,功能是按 vid 批查视频 |
| 视频列表数据存在 MySQL 的 video 表里 | 视频存 MongoDB 的 video 集合,用户资料才在 MySQL user_info 表,一次列表请求跨两个存储 |
hasLiked 会返回真实的点赞状态 | 本章恒为 0,判存逻辑在后续互动章节才补上 |
| 推荐列表是从数据库实时算出来的 | 是推荐系统提前算好 vid 串写进 Redis 的,列表接口只做截取和查库 |
page - redisPages 是笔误 | 是有意的页码换算:推荐页占掉的页数要从总页码里减掉 |
findMovementsByVids 名字里的 movements 是复制粘贴忘了改 | 确实是,这个方法名沿用了动态(movement)模块的命名,方法功能与名字不完全匹配,工程里普遍这么叫,读代码时按注释理解即可 |
PageResult 的 total 是真实总数 | 本章传的是 0L,占位值;客户端实际按「下拉还有没有数据」加载更多,不依赖 total |
官方文档
- Spring Data MongoDB - Query, Criteria 查询
- Spring Data MongoDB - Sorting 与分页
- Hutool - PageUtil 分页工具
- Spring Cache - @Cacheable 声明式缓存
总结
- 列表方案是「推荐优先、数据库兜底」:Redis
VIDEOS_RECOMMEND_存 vid 串承担排序与削峰,Mongovideo集合按created倒序兜底; - 分页跨两个数据源,靠
redisPages做页码平移,代价是推荐数据刷新时页码错位; - 列表链路四层:Controller 收参 → Service 编排 → Dubbo API → MongoTemplate,与动态模块同构,写熟一条其余照搬。复杂点全在 Redis vid 串的截取和
page - redisPages页码换算上; - VO 组装是跨存储批查的标准动作:一次
findByIds+ Map 关联,info != null防引用失效。组装VideoVo靠两次copyProperties+ 三处手动映射,hasLiked/hasFocus本章占位置 0,判存留给互动章节; - 每次刷新重复执行 Mongo 分页 + 批查用户 + 组装三段开销,结果却是确定的,这是引出 SpringCache 的直接动机,
@Cacheable注解已在本章后续就位; - 测试阶段最容易翻车的是用户表脏数据导致的空指针,
if(info != null)那行别省; - 关注列表与推荐列表分页思路完全不同,本章只实现了推荐优先链路,关注维度不构成查询条件;
- 下一篇进入同章的存储部分:FastDFS:引入与内部结构已经讲过这套分布式文件系统的架构,如果还没读,建议从那里接着看,列表里的
videoUrl到底是怎么来的,答案在那个章节。
1441



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



