概述
小视频的发布和列表查询都做完之后,还剩最后一块拼图:关注视频作者。当前工程快照里也只有常量和字段占位、没有实现,这里把需求、存储设计、接口契约和参考实现一次讲透,把这个功能做扎实。
纲要
- 业务场景:关注是视频生态的「订阅」关系
- 关注与好友(双向)的本质区别(对照表)
- 存储设计(这里核心)
- 当前工程实现现状:只有
FOCUS_USER_KEY常量 +VideoVo.hasFocus占位 - Redis Set 存关注关系:SADD / SREM / SISMEMBER / SMEMBERS
- 未落库的现状与生产双写方案(MongoDB focus_user + Redis 缓存)
- 接口设计:关注 / 取消关注(路径参数 uid,操作人取自 token)
- 代码实现:Controller + Service 参考实现,视频列表回填 hasFocus
- 流程时序图、原型线框图(关注按钮两态)
- 异常与边界、验证要点、常见误解澄清
业务场景:关注是视频生态的「订阅」关系
在推荐系统里,关注是有实际业务含义的:关注了某个作者,他发布的视频会拿到更高的推荐权重。主流短视频平台基本都是这个逻辑,你关注的人,内容优先喂给你。当前工程的小视频模块也采用了这套规则,所以「关注」不只是个按钮状态,它是推荐系统的输入信号。
业务动作只有两个:
- 关注:把「我关注了作者 X」这条关系存起来;
- 取消关注:把这条关系删掉。
但有一个必须先掰清楚的概念:关注是单向关系。我关注你,你不必关注我;而好友是双向关系,A 加 B 为好友,系统里要写两条记录(A 的好友列表里有 B,B 的好友列表里有 A),参考实现中的即时通讯好友模块就是这么双写的。关注没有这个对称性,一个 Set 就够了。
| 对比项 | 关注(当前模块) | 好友(参考实现) |
|---|---|---|
| 方向 | 单向:我关注你,与你无关 | 双向:互为好友 |
| 对称性 | 非对称,A 关注 B ≠ B 关注 A | 对称,一条关系两端共有 |
| 建立方式 | 单方发起即生效,无需对方同意 | 单方发起 + 双方同意(或直接双写) |
| 解除方式 | 随时单方取消,对方无感知 | 删除好友,两端同时解除 |
| 存储 | 一个 Set:FOCUS_USER_KEY_ + 我的id,成员为被关注者 | 双写两条记录(各自的好友集合) |
| 业务用途 | 推荐加权 + 视频卡片「已关注」标记 | 即时通讯、互相查看动态 |
还有一个容易被忽略的联动需求:从视频列表点进来时,作者名片上如果没关注就展示「关注」,已关注则展示「取消关注」。这意味着查询视频列表时必须顺带判存关注关系,给每张视频卡片回填一个 hasFocus 标记,关注不是孤立功能,它挂在视频列表的返回结构上。
存储设计:先看工程实读的现状
动手设计之前,先把当前工程快照翻一遍,确认目前实现进展。实测结论(以 08-小视频方案/01-代码/mf 实读为准):
Constants.java里定义了关注关系的 Redis key 前缀:
package com.mf.commons.utils;
//常量定义
public class Constants {
// ...省略其他常量...
//关注用户的key
public static final String FOCUS_USER_KEY = "FOCUS_USER_KEY_";
}
VideoVo里已有标记字段,但init()方法把它写死为 0,当前快照里视频列表的hasFocus恒为假值:
private Integer hasFocus; //是否关注 (1是,0否)
public static VideoVo init(UserInfo userInfo, Video item) {
// ...省略属性拷贝...
vo.setHasFocus(0);
vo.setHasLiked(0);
return vo;
}
SmallVideosController只有saveVideos和queryVideoList两个方法,SmallVideosService里没有任何关注/取消关注方法,也没有FocusUser实体或落库实现。
也就是说,这一部分是真正需要动手实现的内容:需求在原始需求文档里、表结构在参考材料里、代码要自己写。原始需求文档给出的需求口径有三条:
- 关系数据要保存到 MongoDB,表名
focus_user(原始需求文档误写作「falseuser」),核心字段只有两个:userId(操作人)和followUserId(被关注者); - 接口是 POST + 路径参数 uid,关注和取消关注各一个;
- 内部要用 Redis 存一份,关注时写入、取消时删除。
原始需求文档里有一句「Redis 我们使用的是哈希的数据结构」。结合工程实读要辨证地看:同类需求里「用户喜欢」用的是 USER_LIKE_SET_ 前缀的 Set,后续关注列表查询的口径也是 SMEMBERS 取 id 列表,Hash 结构反而对不上这些用法,这里按 Redis Set 设计更合理,「哈希」大概率是表述混用。后面所有实现都以 Set 为准。
工程涉及文件的树状结构:
mf
├── mf-app-server
│ └── src/main/java/com/mf/server
│ ├── controller
│ │ └── SmallVideosController.java // 新增关注/取消关注入口
│ ├── service
│ │ └── SmallVideosService.java // 新增关注/取消关注/判存逻辑
│ └── interceptor
│ └── UserHolder.java // 操作人从 token 解析而来
├── mf-dubbo
│ ├── mf-dubbo-interface
│ │ └── com/mf/dubbo/api
│ │ ├── VideoApi.java // 已有:视频查询
│ │ └── FocusUserApi.java // 生产方案新增:关注关系落库
│ └── mf-dubbo-mongo
│ └── com/mf/dubbo/api
│ └── VideoApiImpl.java
├── mf-model
│ └── src/main/java/com/mf/model
│ ├── mongo
│ │ └── FocusUser.java // 生产方案新增:focus_user 集合实体
│ └── vo
│ └── VideoVo.java // 已有:hasFocus 标记字段
└── mf-commons
└── src/main/java/com/mf/commons/utils
└── Constants.java // 已有:FOCUS_USER_KEY 前缀
为什么关注关系适合 Redis Set
| 需求 | Set 的对应能力 |
|---|---|
| 视频列表要判存「我是否关注了该作者」 | SISMEMBER O(1) 判存,列表几十条视频逐个判断毫无压力 |
| 重复关注不能产生脏数据 | Set 元素天然去重,重复 SADD 是幂等的 |
| 取消关注 | SREM 直接移除,不存在也不报错,天然幂等 |
| 关注列表查询 | SMEMBERS 一次取回全部被关注者 id |
| 单个用户的关注量级 | 个人关注几百上千已是极限,全量放内存毫无负担 |
key 的设计是 FOCUS_USER_KEY_ + 操作人 id,成员是被关注者 id。比如用户 106 关注了 107 和 108:
FOCUS_USER_KEY_106 → Set{ 107, 108 }
整个关注关系按「人」分片存储:查「我关注了谁」只碰我的 key,判存只碰我的 key,互不干扰。
未落库的现状与生产双写方案
按原始需求文档,关系本应同时写 MongoDB 的 focus_user 集合。当前工程快照里没有落库实现,Redis 成了唯一存储,这是参考实现的简化,但生产上不能这么干:Redis 一旦清空或重启丢数据,所有关注关系蒸发,推荐系统直接失明。
生产方案是经典的数据库 + 缓存双写:MongoDB focus_user 做持久层(可按 userId 查历史、做数据分析),Redis Set 做判存缓存。以 MongoDB 为准,Redis 可随时重建。
package com.mf.model.mongo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.bson.types.ObjectId;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.CompoundIndex;
import org.springframework.data.mongodb.core.mapping.Document;
import java.io.Serializable;
//关注用户表:生产方案持久层实体(当前工程快照中缺失,此处按原始需求文档补全)
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "focus_user")
@CompoundIndex(def = "{'userId':1, 'followUserId':1}", unique = true) //一人对一人仅一条
public class FocusUser implements Serializable {
@Id
private ObjectId id;
private Long userId; //操作人(发起关注的人)
private Long followUserId; //被关注的用户
}
写路径的时序:先落库,再同步缓存,任一步失败以库为准回滚缓存。
接口设计
按原始需求文档,两个接口都是 POST + 路径参数 uid,操作人从 token 解析(UserHolder.getUserId()),绝不由前端传入,否则任何人都能替别人关注。
| 项目 | 关注 | 取消关注 |
|---|---|---|
| 请求路径 | POST /smallVideos/{uid}/userFocus | POST /smallVideos/{uid}/userUnFocus |
| 路径参数 | uid:被关注用户 id | uid:要取消关注的用户 id |
| 操作人 | token 解析,UserHolder.getUserId() | 同左 |
| 响应 | 无响应体,200 即成功 | 同左 |
{
"follow": {
"method": "POST",
"path": "/smallVideos/107/userFocus",
"pathParam": { "uid": "被关注的用户ID" },
"operator": "从token解析,前端不传",
"response": { "status": 200, "body": null }
},
"unfollow": {
"method": "POST",
"path": "/smallVideos/107/userUnFocus",
"pathParam": { "uid": "要取消关注的用户ID" },
"operator": "从token解析,前端不传",
"response": { "status": 200, "body": null }
}
}
代码实现
Controller:接收两个请求
package com.mf.server.controller;
import com.mf.server.service.SmallVideosService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/smallVideos")
public class SmallVideosController {
@Autowired
private SmallVideosService videosService;
/**
* 关注视频作者
* 路径参数 uid:被关注的用户ID
*/
@PostMapping("/{uid}/userFocus")
public ResponseEntity followUser(@PathVariable("uid") Long uid) {
videosService.followUser(uid);
return ResponseEntity.ok(null);
}
/**
* 取消关注
* 路径参数 uid:要取消关注的用户ID
*/
@PostMapping("/{uid}/userUnFocus")
public ResponseEntity unFollowUser(@PathVariable("uid") Long uid) {
videosService.unFollowUser(uid);
return ResponseEntity.ok(null);
}
}
Service:SADD 写入、SREM 移除
package com.mf.server.service;
import com.mf.commons.utils.Constants;
import com.mf.server.exception.BusinessException;
import com.mf.server.interceptor.UserHolder;
import com.mf.model.vo.ErrorResult;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
@Service
public class SmallVideosService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 关注视频作者
* @param followUserId 被关注的用户ID
*/
public void followUser(Long followUserId) {
Long userId = UserHolder.getUserId();
//边界:不能关注自己
if (userId.equals(followUserId)) {
throw new BusinessException(ErrorResult.error());
}
//生产方案:先落库MongoDB,再同步Redis;此处按参考实现直写Redis
String key = Constants.FOCUS_USER_KEY + userId;
redisTemplate.opsForSet().add(key, followUserId.toString());
}
/**
* 取消关注
* @param followUserId 要取消关注的用户ID
*/
public void unFollowUser(Long followUserId) {
Long userId = UserHolder.getUserId();
String key = Constants.FOCUS_USER_KEY + userId;
//SREM移除不存在的成员不报错,重复取消天然幂等
redisTemplate.opsForSet().remove(key, followUserId.toString());
}
}
实现就两个动作:关注 = SADD,取消 = SREM。Set 的去重和幂等特性把重复点击、重复取消这些边界全部消化掉了,Service 层不用写任何「查一下再决定」的逻辑。
视频列表回填 hasFocus:SISMEMBER 判存
这是需求中的联动部分,VideoVo.init() 里写死的 hasFocus=0 要在列表查询时按真实关注关系覆盖。改造 queryVideoList 的构建循环:
//在SmallVideosService.queryVideoList的构建循环中回填关注标记
for (Video video : list) {
UserInfo info = map.get(video.getUserId());
if (info != null) {
VideoVo vo = VideoVo.init(info, video);
//判存:我是否关注了该视频的作者
String focusKey = Constants.FOCUS_USER_KEY + UserHolder.getUserId();
Boolean isFocus = redisTemplate.opsForSet()
.isMember(focusKey, video.getUserId().toString());
vo.setHasFocus(Boolean.TRUE.equals(isFocus) ? 1 : 0);
vos.add(vo);
}
}
每条视频做一次 SISMEMBER,单页 10 条也就是 10 次 O(1) 操作,完全可接受。要省网络往返,也可以先用 SMEMBERS 一次性取回我的关注集合,再在内存里 contains 判断,单页数据量大时后者更划算。
关注列表:SMEMBERS 取 id → video in 查询
「我的关注」列表页的查询口径(后续文章展开):SMEMBERS 取出我关注的全部用户 id,再拿 id 列表去 video 集合做 in 查询,把关注作者的视频一次性捞出。这正是 Set 存储设计的红利,关注列表查询不需要扫库,一个 SMEMBERS 就拿到全部被关注者。
流程时序图
原型线框图
视频页作者栏的关注按钮有两个状态,一屏一图分开画。
屏 1:未关注态
┌─────────────────────────────┐
│ ← 小视频 │
│ ┌─────────────────────────┐ │
│ │ │ │
│ │ (视频播放区) │ │
│ │ │ │
│ └─────────────────────────┘ │
│ ┌────┐ │
│ │头像│ 作者昵称 │
│ └────┘ ┌──────────┐ │
│ │ + 关注 │ │
│ └──────────┘ │
│ 视频签名文字…… │
│ ♡点赞 💬评论 ↗分享 │
└─────────────────────────────┘
要点:
- 作者栏右侧固定展示「+ 关注」按钮,未关注态为高亮可点击;
-点击后调用POST /smallVideos/{uid}/userFocus,成功后按钮就地切换为屏 2 的「取消关注」,无需刷新页面;; - 按钮 uid 取当前视频卡片的
userId,操作人由 token 决定。
屏 2:已关注态
┌─────────────────────────────┐
│ ← 小视频 │
│ ┌─────────────────────────┐ │
│ │ │ │
│ │ (视频播放区) │ │
│ │ │ │
│ └─────────────────────────┘ │
│ ┌────┐ │
│ │头像│ 作者昵称 │
│ └────┘ ┌────────────┐ │
│ │ 取消关注 │ │
│ └────────────┘ │
│ 视频签名文字…… │
│ ♡点赞 💬评论 ↗分享 │
└─────────────────────────────┘
```实现要点:点:
- 已关注态按钮置灰或描边样式,文案变为「取消关注」;
- 按钮态由视频列表返回的 `hasFocus` 驱动(1 → 屏 2,0 → 屏 1),客户端不自行记忆;
- 点击调 `POST /smallVideos/{uid}/userUnFocus` 后回切屏 1;
- 上下滑切换视频时,每张卡片独立按各自的 `hasFocus` 渲染。
## API 速览
| 接口 | 方法 | 路径 | 关键参数 | Redis 操作 | 说明 |
|---|---|---|---|---|---|
| 关注作者 | POST | `/smallVideos/{uid}/userFocus` | uid(路径) | SADD | Set 去重,重复关注幂等 |
| 取消关注 | POST | `/smallVideos/{uid}/userUnFocus` | uid(路径) | SREM | 成员不存在不报错,幂等 |
| 判存(列表内) | GET | `/smallVideos` | page / pagesize | SISMEMBER | 回填 `VideoVo.hasFocus` | 关注列表(后续) | - | 后续文章 | - | SMEMBERS + video in 查询 | 取关注 id → 查其视频 | |
## 异常与边界
| 边界场景 | 现状/处理 | 说明 |
|---|---|---|
| 关注自己 | Service 显式拦截,抛业务异常 | `userId.equals(followUserId)` 判断,Set 技术上允许但业务上无意义 |
| 重复关注 | Set 天然幂等,无脏数据 | 第二次 SADD 是空操作,无需先查后插 |
| 取消不存在的关注 | SREM 幂等,返回 200 | 重复点击取消、取消从未关注的人都不报错 |
| Redis 与数据库不一致 当前工程快照未落库落库,Redis 是唯一存储 | 数据不可恢复是硬伤;生产按「先写 MongoDB focus_user,再同步 Redis Set」双写,以库为准,缓存可重建 |
| uid 对应用户不存在 | 建议先查 UserInfo 再写 | 当前实现未校验,关注一个已注销用户会留下悬挂关系,可由列表查询侧的判空兜底 |
| 未登录/伪造 uid | 操作人取自 token | 前端只传被关注者 uid,操作人不可伪造 |
## 验证要点
手工验证按这条链走,每步都能在 redis-cli 里看到确凿证据:
```bash
# 1. 用 106 的 token 关注 107
curl -X POST -H "Authorization: Bearer <token_106>" http://localhost:8080/smallVideos/107/userFocus
# 2. redis-cli 确认 Set 写入
SMEMBERS FOCUS_USER_KEY_106 # → "107"
SISMEMBER FOCUS_USER_KEY_106 107 # → 1
# 3. 拉视频列表,107 发布的视频 hasFocus 应为 1
curl -H "Authorization: Bearer <token_106>" "http://localhost:8080/smallVideos?page=1&pagesize=10"
# 4. 取消关注
curl -X POST -H "Authorization: Bearer <token_106>" http://localhost:8080/smallVideos/107/userUnFocus
# 5. 复核
SISMEMBER FOCUS_USER_KEY_106 107 # → 0,列表刷新后 hasFocus=0
重复点关注接口几次再 SCARD FOCUS_USER_KEY_106,计数不变即验证了幂等。
常见误解澄清
| 误解 | 事实 |
|---|---|
| 关注和好友是一回事,都要双写 | 关注是单向关系,只需在关注者的 Set 里记一条;好友才需要两端各写一条 |
原始需求文档说 Redis 用「哈希」,那就该用 Hashsh 参考实现中:同类「用户喜欢」用 Set(USER_LIKE_SET_),关注列表查询用 SMEMBERS,按 Set 设计才自洽,「哈希」是口头混称混称 | |
| hasFocus 是数据库里的字段 | 不是,它是 VideoVo 的展示字段,查询列表时由 Redis 判存动态回填,init() 里默认 0 |
| 重复关注要先查询再决定插不插 | Set 元素去重,直接 SADD 即可,先查后插反而多余 |
| 关注数据只在 Redis 就够了 当前工程快照确实如此,但生产必须落 MongoDB 并以库为准,Redis 只做判存缓存缓存 | |
| 被关注者会收到通知 | 本模块没有消息通知设计,关注是无感知的单向动作 |
总结
- 关注是单向的「订阅」关系,与好友的双向双写形成鲜明对照:一个 Set 记一条方向即可。
- 存储选 Redis Set 的理由就三条:SISMEMBER 判存 O(1)、元素去重天然幂等、SMEMBERS 一次取关注列表。
当前工程快照的现状要心里有数:参考实现里只有FOCUS_USER_KEY常量和hasFocus占位,关注/取消是待完成的实现内容;原始需求文档要求的 MongoDBfocus_user落库在生产上不可省,正确姿势是库 + 缓存双写、以库为准。准。 - 视频列表的
hasFocus回填是关注的联动需求,卡片按钮两态完全由服务端数据驱动。
后续文章将进入通用缓存 SpringCache:问题引入,视频列表查询里已经出现了@Cacheable的身影,而关注判存每次都要打 Redis,方法级缓存的通用方案是接下来要解决的问题,SpringCache 系列从这里开篇。篇。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/Tyro_java/article/details/170355325




