第八章 - 好友关注模块
本章核心目标:实现好友关注/取关、共同关注、关注推送(Feed 流)三大功能
8.1 关注和取关(p085)
8.1.1 需求分析
关注是单向操作,无需对方同意即可关注或取关。
两个接口:
| 接口 | 方法 | 路径 | 参数 | 说明 |
|---|---|---|---|---|
| 关注/取关 | PUT | /follow/{id}/{isFollow} |
id=被关注用户ID, isFollow=true/false | true=关注, false=取关 |
| 查询是否关注 | GET | /follow/or/not/{id} |
id=被关注用户ID | 返回 true/false |
页面交互逻辑: - 页面加载时调用”是否关注”接口 → 决定显示”关注”还是”已关注” - 点击按钮调用关注/取关接口 → 切换按钮状态
8.1.2 数据库设计
中间表 tb_follow: 记录用户之间的关注关系(多对多)
| 字段 | 类型 | 说明 |
|---|---|---|
id |
bigint (自增) | 主键 |
user_id |
bigint | 关注者(当前用户) |
follow_user_id |
bigint | 被关注者 |
create_time |
datetime | 关注时间 |
⚠️ 注意: 主键需手动改为自增长,否则开发时需手动指定 ID
为什么取关用删除而非标记字段? - 如果用布尔值标记关注状态,取关后数据仍保留在表中 - 大量取关会产生大量无用数据,浪费空间 - 采用删除方案:关注=INSERT,取关=DELETE
8.1.3 代码实现
关注/取关接口(Controller):
@PutMapping("/follow/{id}/{isFollow}")
public Result follow(@PathVariable("id") Long followUserId,
@PathVariable("isFollow") Boolean isFollow) {
return followService.follow(followUserId, isFollow);
}关注/取关业务(Service):
@Override
public Result follow(Long followUserId, Boolean isFollow) {
// 1. 获取当前登录用户
Long userId = UserHolder.getUser().getId();
if (isFollow) {
// 2. 关注 → 新增数据
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
boolean isSuccess = save(follow);
} else {
// 3. 取关 → 删除数据
boolean isSuccess = remove(new QueryWrapper<Follow>()
.eq("user_id", userId)
.eq("follow_user_id", followUserId));
}
return Result.ok();
}查询是否关注接口:
@Override
public Result isFollow(Long followUserId) {
Long userId = UserHolder.getUser().getId();
// 查询关注关系是否存在
Integer count = query().eq("user_id", userId)
.eq("follow_user_id", followUserId)
.count();
return Result.ok(count > 0);
}💡 优化提示: 查询是否存在时用
count()而非one(),避免查出完整数据,更高效
8.2 共同关注
8.2.1 需求分析
查看自己与目标用户之间共同关注的人(即两个关注列表的交集)。
实现思路: 将用户关注列表存入 Redis Set 集合,利用 Set 的交集(SINTER)功能求共同关注。
8.2.2 Redis Set 交集命令
# 添加关注关系
SADD follows:1 2 # 用户1 关注了 用户2
SADD follows:1 3 # 用户1 关注了 用户3
SADD follows:2 3 # 用户2 关注了 用户3
# 求交集 → 共同关注
SINTER follows:1 follows:2
# 结果: {3} → 用户1 和 用户2 共同关注了用户38.2.3 改造关注接口(同步写入 Redis)
在原有的关注/取关业务中,同时维护 Redis 中的关注集合:
@Override
public Result follow(Long followUserId, Boolean isFollow) {
Long userId = UserHolder.getUser().getId();
String key = FOLLOW_KEY + userId;
if (isFollow) {
// 关注:写入数据库
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
boolean isSuccess = save(follow);
// 同时写入 Redis Set
if (isSuccess) {
stringRedisTemplate.opsForSet().add(key, followUserId.toString());
}
} else {
// 取关:删除数据库
boolean isSuccess = remove(new QueryWrapper<Follow>()
.eq("user_id", userId)
.eq("follow_user_id", followUserId));
// 同时从 Redis Set 移除
if (isSuccess) {
stringRedisTemplate.opsForSet().remove(key, followUserId.toString());
}
}
return Result.ok();
}⚠️ 关键: 数据库操作成功后才写入 Redis,保证数据一致性
8.2.4 共同关注接口实现
@Override
public Result followCommons(Long id) {
// 1. 获取当前用户 ID
Long userId = UserHolder.getUser().getId();
String key1 = FOLLOW_KEY + userId;
String key2 = FOLLOW_KEY + id;
// 2. 求两个 Set 的交集
Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key1, key2);
if (intersect == null || intersect.isEmpty()) {
return Result.ok(Collections.emptyList());
}
// 3. 解析用户 ID,查询用户信息
List<Long> ids = intersect.stream()
.map(Long::valueOf)
.collect(Collectors.toList());
List<UserDTO> userDTOS = userService.listByIds(ids)
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
return Result.ok(userDTOS);
}8.2.5 补充:进入用户主页的接口
共同关注功能需要进入目标用户主页,需实现两个辅助接口:
| 接口 | 路径 | 说明 |
|---|---|---|
| 查询用户信息 | /user/{id} |
根据 ID 查询用户 |
| 查询用户笔记 | /blog/of/user |
分页查询指定用户的探店笔记 |
这两个接口主要是基本 CRUD,可直接使用提供的代码
8.3 关注推送(Feed 流)
8.3.1 Feed 流概念
Feed 流 = 持续为用户推送沉浸式内容的模式,通过无限下拉刷新获取新信息。
两种模式对比:
| 模式 | 特点 | 典型应用 | 优点 | 缺点 |
|---|---|---|---|---|
| Timeline | 按发布时间排序,不做内容筛选 | 微信朋友圈 | 实现简单,信息全面 | 噪音多,用户不一定感兴趣 |
| 智能排序 | 算法筛选,推送用户感兴趣的内容 | 抖音、快手 | 用户粘性高,易沉浸 | 算法不精准会适得其反 |
本项目采用 Timeline 模式: 展示关注用户的探店笔记,按时间排序,不过滤。
8.3.2 三种实现方案对比
方案一:拉模式(读扩散)
每个博主有发件箱 → 粉丝读取时拉取所有关注人的发件箱 → 合并排序| 优点 | 缺点 |
|---|---|
| 节省内存(收件箱可清理) | 每次读取都要拉取+排序,延迟高 |
| 数据只存一份 | 关注人多时性能差 |
方案二:推模式(写扩散)
博主发消息 → 直接推送到每个粉丝的收件箱 → 粉丝读取时直接读| 优点 | 缺点 |
|---|---|
| 读取延迟极低 | 内存占用高(每个粉丝一份数据) |
| 实现简单 | 大V发消息会写入海量数据 |
方案三:推拉结合
普通人发消息 → 推模式(粉丝少)
大V发消息 → 推给活跃粉丝 + 普通粉丝读时拉取发件箱| 优点 | 缺点 |
|---|---|
| 兼具两种模式优点 | 实现复杂 |
| 兼顾活跃用户和普通用户 | 需要区分粉丝类型 |
本项目选择推模式: 用户量不大 + 没有大V + 实现简单 + 延迟低
选型建议: - 用户量 < 千万 + 无大V → 推模式 - 用户量 > 千万 + 有大V → 推拉结合 - 拉模式不建议单独使用
8.3.3 收件箱数据结构选型
候选: List vs Sorted Set,两者都支持排序和分页
关键差异——滚动分页支持:
| 数据结构 | 分页方式 | 数据变化时 | 是否适合 |
|---|---|---|---|
| List | 按角标 | 角标变化导致数据混乱 | ❌ |
| Sorted Set | 按 score 值范围 | 不受影响 | ✅ |
🎯 结论: Feed 流场景中数据持续变化,必须用 Sorted Set,按时间戳作为 score
8.3.4 推送实现(发布笔记时推送给粉丝)
改造发布笔记的业务,保存成功后推送到粉丝收件箱:
@Override
public Result saveBlog(Blog blog) {
// 1. 获取登录用户
blog.setUserId(UserHolder.getUser().getId());
// 2. 保存探店笔记到数据库
boolean isSuccess = save(blog);
if (!isSuccess) {
return Result.fail("新增笔记失败!");
}
// 3. 查询笔记作者的所有粉丝
List<Follow> follows = followService.query()
.eq("follow_user_id", blog.getUserId())
.list();
// 4. 推送笔记 ID 到每个粉丝的收件箱(Sorted Set)
for (Follow follow : follows) {
Long fanId = follow.getUserId();
String key = FEED_KEY + fanId;
stringRedisTemplate.opsForZSet()
.add(key, blog.getId().toString(), System.currentTimeMillis());
}
return Result.ok(blog.getId());
}💡 优化: 推送到收件箱时只存笔记 ID(score=时间戳),不存完整内容,节省内存
8.3.5 滚动分页查询原理
为什么不能用传统分页?
时间轴 T1: [10,9,8,7,6,5,4,3,2,1] (score 从大到小)
第1页(size=5): 返回 [10,9,8,7,6]
时间轴 T2: 新数据 [11] 插入 → [11,10,9,8,7,6,5,4,3,2,1]
原来脚标为 4 的元素 "6" 现在脚标变成了 5
第2页: page=2, size=5 → 脚标 5~9 → 返回 [6,5,4,3,2] ← 出现重复!滚动分页原理: 不依赖角标,而是记住上一次查询的最小 score,下次从这里继续
第1页: max=∞, offset=0, count=3 → [10,9,8]
第2页: max=8, offset=1, count=3 → [7,6,5] ← 不受新数据影响8.3.6 滚动分页参数详解
Sorted Set 滚动分页命令:
ZREVRANGEBYSCORE key max min WITHSCORES LIMIT offset count| 参数 | 第一次查询 | 后续查询 |
|---|---|---|
max |
当前时间戳(最大值) | 上次查询的最小 score |
min |
0(固定) | 0(固定) |
offset |
0 | 上次查询中与最小 score 相同元素的个数 |
count |
固定值(如 2) | 固定值 |
⚠️ 关键细节:offset 的计算 - offset 等于上一次查询结果中,与最小 score 值相同的元素个数 - 当存在相同 score 时,必须跳过所有同值元素,否则会重复
示例:
数据: [m8(8), m7(6), m6(6), m5(5), m4(4), m3(3)]
第1页: max=1000, offset=0, count=3 → [m8, m7, m6] minScore=6
第2页: max=6, offset=2, count=3 → [m5, m4, m3]
↑ 6有两个元素(m7,m6),所以 offset=28.3.7 滚动分页代码实现
返回值对象 ScrollResult:
@Data
public class ScrollResult<T> {
private List<T> list; // 当前页数据
private Long minTime; // 最小时间戳(下次查询的 max)
private Integer offset; // 偏移量(下次查询的 offset)
}查询收件箱接口:
@GetMapping("/of/follow")
public Result queryBlogOfFollow(
@RequestParam("lastId") Long max,
@RequestParam(value = "offset", defaultValue = "0") Integer offset) {
return blogService.queryBlogOfFollow(max, offset);
}💡
lastId是上次查询的最小时间戳,作为本次的 max;第一次查询由前端传当前时间,offset 默认 0
Service 实现:
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
// 1. 获取当前用户
Long userId = UserHolder.getUser().getId();
// 2. 查询收件箱(Sorted Set 滚动分页)
String key = FEED_KEY + userId;
Set<ZSetOperations.TypedTuple<String>> tuples = stringRedisTemplate.opsForZSet()
.reverseRangeByScoreWithScores(key, 0, max, offset, 2);
if (tuples == null || tuples.isEmpty()) {
return Result.ok();
}
// 3. 解析数据:blogId 集合 + minTime + offset
List<Long> ids = new ArrayList<>(tuples.size());
long minTime = 0;
int os = 1;
for (ZSetOperations.TypedTuple<String> tuple : tuples) {
ids.add(Long.valueOf(tuple.getValue()));
long time = tuple.getScore().longValue();
if (time == minTime) {
os++; // 相同 score 计数 +1
} else {
minTime = time;
os = 1; // 遇到新的更小值,重置
}
}
// 4. 根据 ID 批量查询 Blog(保证顺序)
String idStr = StrUtil.join(",", ids);
List<Blog> blogs = query()
.in("id", ids)
.last("ORDER BY FIELD(id," + idStr + ")")
.list();
// 5. 为每个 Blog 补充用户信息和点赞状态
for (Blog blog : blogs) {
queryBlogUser(blog);
isBlogLiked(blog);
}
// 6. 封装返回结果
ScrollResult<Blog> result = new ScrollResult<>();
result.setList(blogs);
result.setMinTime(minTime);
result.setOffset(os);
return Result.ok(result);
}⚠️ 排序保序问题: 使用
listByIds()不保证返回顺序,需用ORDER BY FIELD强制按传入 ID 顺序返回
📌 核心知识点总结
Redis 数据结构应用
| 功能 | 数据结构 | Key 设计 | 命令 |
|---|---|---|---|
| 共同关注 | Set | follows:{userId} |
SADD / SINTER |
| 收件箱 | Sorted Set | feed:{userId} |
ZADD / ZREVRANGEBYSCORE |
三种 Feed 流方案选型
| 场景 | 推荐方案 |
|---|---|
| 用户少 + 无大V | 推模式(本项目) |
| 用户多 + 有大V | 推拉结合 |
| 任意场景 | ❌ 不推荐单独拉模式 |
滚动分页四要素
┌──────────────────────────────────────────────┐
│ max → 上次查询的最小 score │
│ min → 固定为 0(时间戳最小值) │
│ offset → 上次结果中与最小值相同元素的个数 │
│ count → 固定值(每页条数) │
└──────────────────────────────────────────────┘⚠️ 注意事项
- 关注关系存双份: 同时写数据库(持久化)和 Redis(查询性能),数据库成功后才写 Redis
- count() vs one(): 判断是否存在时用
count()比one()更高效,避免查出完整数据 - Sorted Set 不可用 List 替代: Feed 流数据持续变化,List 的角标分页会导致数据重复或丢失
- offset 重置逻辑: 每次遍历遇到新的最小值时,offset 重置为 1;遇到相同值时 offset++
- 批量查询顺序:
listByIds()不保证顺序,需用ORDER BY FIELD手动排序 - 收件箱只存 ID: 推送时只存笔记 ID + 时间戳,查询时再关联完整数据,节省内存