day43 用户关注、共同关注与关注推送Feed流

day43 用户关注、共同关注与关注推送Feed流

一、今日功能概述

今天围绕黑马点评的社交功能模块,实现了三个核心功能:

  1. 关注与取消关注:用户之间的多对多关注关系,基于数据库表 tb_follow 实现增删查
  2. 共同关注:查看当前用户与目标用户共同关注的博主,使用 Redis Set 的 SINTER 命令求交集
  3. 关注推送(Feed 流):博主发布探店笔记后,主动推送到粉丝的收件箱,使用 Redis ZSet 存储,配合滚动分页查询

涉及的核心数据表与 Redis 结构:

类型 名称 作用
MySQL 表 tb_follow 存储用户关注关系(user_id、follow_user_id)
MySQL 表 tb_blog 存储探店笔记
Redis Set follows:{userId} 存储用户的关注列表,用于共同关注
Redis ZSet feed:{userId} 用户的收件箱,score 为时间戳,value 为博客 id

二、关注与取消关注

2.1 需求与数据模型

用户可以关注其他用户(博主),也可以取消关注。关注关系是多对多的:一个用户可以关注多个人,一个人也可以被多个用户关注。

tb_follow 表结构:

1
2
3
4
5
6
7
8
@TableName("tb_follow")
public class Follow implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id; // 主键
private Long userId; // 用户id(粉丝)
private Long followUserId; // 关联的用户id(被关注者)
private LocalDateTime createTime;
}

2.2 接口设计

FollowController 提供三个接口:

接口 方法 说明
PUT /follow/{id}/{isFollow} follow() 关注或取消关注
GET /follow/or/not/{id} isFollow() 判断当前用户是否关注了某人
GET /follow/common/{id} followCommons() 查询共同关注

2.3 关注与取消关注的实现

关注操作同时写数据库和 Redis Set,取关则同时删除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Override
public Result follow(Long followUserId, Boolean isFollow) {
Long userId = UserHolder.getUser().getId();
String key = "follows:" + userId;

if (isFollow) {
// 关注:新增数据库记录
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
boolean isSuccess = save(follow);
if (isSuccess) {
// 同步写入 Redis Set
stringRedisTemplate.opsForSet().add(key, followUserId.toString());
}
} else {
// 取关:删除数据库记录
boolean isSuccess = remove(new QueryWrapper<Follow>()
.eq("user_id", userId).eq("follow_user_id", followUserId));
if (isSuccess) {
// 同步从 Redis Set 中移除
stringRedisTemplate.opsForSet().remove(key, followUserId.toString());
}
}
return Result.ok();
}

2.4 判断是否关注

查询数据库中是否存在对应记录即可:

1
2
3
4
5
6
7
8
9
@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);
}

注意:这里判断是否关注走的是数据库查询,而不是 Redis。因为 isFollow 通常在查看他人主页时调用,频率不算特别高;而关注列表写入 Redis 主要是为了共同关注的交集计算。


三、共同关注

3.1 需求与实现思路

进入某个博主的主页时,可以查看”我和 TA 共同关注的人”,即求当前登录用户和目标用户的关注列表的交集。

如果纯用数据库实现,需要两次查询各自的关注列表,然后在内存中求交集,效率较低。使用 Redis Set 的 SINTER 命令可以直接在 Redis 中完成交集运算,非常高效。

3.2 核心代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Override
public Result followCommons(Long id) {
// 1. 获取当前用户和目标用户的关注集合 key
Long userId = UserHolder.getUser().getId();
String key = "follows:" + userId;
String key2 = "follows:" + id;

// 2. 求两个 Set 的交集
Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key, key2);
if (intersect == null || intersect.isEmpty()) {
return Result.ok(Collections.emptyList());
}

// 3. 解析 id 集合
List<Long> ids = intersect.stream()
.map(Long::valueOf)
.collect(Collectors.toList());

// 4. 查询用户信息并返回
List<UserDTO> users = userService.listByIds(ids)
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
return Result.ok(users);
}

3.3 Redis Set 交集相关命令

命令 作用
SINTER key1 key2 返回两个集合的交集
SINTERSTORE dest key1 key2 将交集结果存入 dest 集合
SUNION key1 key2 返回并集
SDIFF key1 key2 返回差集(key1 有但 key2 没有)

Java 中对应的方法:opsForSet().intersect(key, otherKey)

3.4 关于方案的思考

这个功能使用 Redis Set 更多是为了学习 Redis 的使用场景,从业务角度看并非最优方案:

  1. 不是热点功能:共同关注的查询频率远低于点赞、浏览等操作,性能压力不大
  2. 全量缓存不现实:如果所有用户的关注列表都常驻 Redis,数据量会很大,且大部分是冷数据

四、关注推送 — Feed 流

4.1 什么是 Feed 流

Feed 流(Feed Stream)就是”关注的人发的动态”,典型场景如微博首页、朋友圈、抖音推荐。用户打开 App 时,能看到自己关注的博主发布的内容,按时间排序展示。

Feed 流的排序模式主要有两种:

  • 时间排序:按发布时间倒序排列,简单直接,适合关注数量不多的场景
  • 智能推荐:根据算法推荐可能感兴趣的内容,实现复杂

本次实现采用时间排序。

4.2 三种实现方案对比

按时间排序的 Feed 流,根据数据流向分为三种实现方案:

方案 原理 优点 缺点 适用场景
拉模式(读扩散) 博主发文存入发件箱,粉丝查看时拉取所有关注的发件箱并合并排序 节省空间,博主发文只需写一份 粉丝读取慢,需要遍历所有关注的人的发件箱 大博主(粉丝极多)、普通用户关注数少
推模式(写扩散) 博主发文时,直接写入所有粉丝的收件箱,粉丝查看时直接读自己的收件箱 粉丝读取速度极快,只需查自己的收件箱 博主发文开销大,大 V 有几百万粉丝时写放大严重;存储空间大 小博主、普通用户(粉丝量不大)
推拉结合 小博主推模式;大博主只存发件箱,活跃粉丝推,非活跃粉丝拉 兼顾性能与成本,适应性强 实现复杂,需要区分用户等级和活跃度 大型社交平台(如微博)

本项目用户量较小,采用推模式实现,代码简单且读取性能好。

4.3 推模式的实现

数据结构选择

收件箱使用 Redis 的 Sorted Set(ZSet),原因:

  1. 需要按时间排序 → ZSet 的 score 可以存时间戳,天然有序
  2. 需要去重 → ZSet 的 member 唯一,同一篇博客不会重复出现
  3. 需要分页查询 → ZSet 支持按 score 范围查询,适合滚动分页

Key 设计:feed:{userId},value 是博客 id,score 是发布时间戳。

发博时推送

博主发布探店笔记时,查询所有粉丝,将博客 id 写入每个粉丝的收件箱:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Override
public Result saveBlog(Blog blog) {
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());

// 1. 保存探店博文到数据库
boolean isSuccess = save(blog);
if (!isSuccess) {
return Result.fail("保存失败");
}

// 2. 查询所有粉丝
List<Follow> follows = followService.query()
.select("user_id")
.eq("follow_user_id", user.getId())
.list();

// 3. 推送到每个粉丝的收件箱
follows.forEach(follow -> {
Long userId = follow.getUserId();
String key = FEED_KEY + userId;
stringRedisTemplate.opsForZSet()
.add(key, blog.getId().toString(), System.currentTimeMillis());
});

return Result.ok(blog.getId());
}

这里的粉丝查询是同步的,粉丝量多时会影响发博接口的响应时间。实际生产中可以用消息队列异步推送。


五、滚动分页

5.1 为什么不用传统分页

Feed 流是持续有新内容插入的场景,如果用传统的页码分页(pageNum + pageSize),会有问题:

1
2
3
第一次查询:第 1 页,取最新的 10 条
(期间有 3 条新内容发布,插入到最前面)
第二次查询:第 2 页,从第 11 条开始取 → 原本的第 8、9、10 条又出现了(因为被新内容挤到第 11、12、13 位了)

结果就是内容重复,用户体验很差。

5.2 滚动分页原理

滚动分页(Scroll Pagination)用时间戳 + offset 代替页码,每次以上次查询的最早一条的时间戳作为下次查询的上界:

1
2
3
4
5
6
7
第一次查询:max = 当前时间戳, offset = 0
↓
返回 10 条,其中最早的时间是 T5,且有 2 条时间都是 T5
↓
第二次查询:max = T5, offset = 2(跳过上次已经查过的 T5 的那 2 条)
↓
从 T5 之前的内容开始查,不会重复

为什么需要 offset?因为 ZSet 的 ZREVRANGEBYSCORE 是闭区间(min 和 max 都包含),如果上次查询的最小值是 T5,这次以 T5 为 max 再查,T5 对应的那些元素又会被查出来,所以需要用 offset 跳过已经返回过的数量。

5.3 接口设计

1
2
3
4
5
6
@GetMapping("/of/follow")
public Result quereBlogByFollow(
@RequestParam("lastId") Long max,
@RequestParam(value = "offset", defaultValue = "0") Integer offset) {
return blogService.quereBlogByFollow(max, offset);
}

前端传参:

  • lastId(即 max):本次查询的时间戳上界;首次查询传当前时间戳
  • offset:偏移量,跳过多少条与最小值时间相同的内容;首次传 0

5.4 核心实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@Override
public Result quereBlogByFollow(Long max, Integer offset) {
// 1. 获取当前用户
Long userId = UserHolder.getUser().getId();
String key = FEED_KEY + userId;

// 2. 从 ZSet 中按 score 倒序查询,范围 [0, max],从 offset 开始取 3 条
Set<ZSetOperations.TypedTuple<String>> typedTuples =
stringRedisTemplate.opsForZSet()
.reverseRangeByScoreWithScores(key, 0, max, offset, 3);
if (typedTuples == null || typedTuples.isEmpty()) {
return Result.ok();
}

// 3. 解析数据:收集 blogId,计算 minTime 和新的 offset
long minTime = 0L;
int os = 1; // 下次查询的 offset
List<Long> ids = new ArrayList<>();
for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples) {
ids.add(Long.valueOf(typedTuple.getValue()));
long time = typedTuple.getScore().longValue();
if (time == minTime) {
os++; // 与当前最小值时间相同,offset 加一
} else {
minTime = time; // 更新最小值
os = 1;
}
}

// 4. 根据 blogId 列表查询博客详情,保持顺序
String idsStr = StrUtil.join(",", ids);
List<Blog> blogList = query()
.in("id", ids)
.last("order by FIELD(id, " + idsStr + ")")
.list();

// 5. 填充作者信息和点赞状态
blogList.forEach(blog -> {
queueBlogUser(blog);
setIsLike(blog);
});

// 6. 封装返回结果:列表 + 最小时间 + 下次偏移量
ScrollResult scrollResult = new ScrollResult(blogList, minTime, os);
return Result.ok(scrollResult);
}

返回结果封装在 ScrollResult 中:

1
2
3
4
5
6
7
@Data
@AllArgsConstructor
public class ScrollResult {
private List<?> list; // 数据列表
private Long minTime; // 本次查询的最小时间戳,作为下次的 max
private Integer offset; // 下次查询的偏移量
}

5.5 关键 API 说明

reverseRangeByScoreWithScores(key, min, max, offset, count)

对应 Redis 命令:ZREVRANGEBYSCORE key max min LIMIT offset count

  • min / max:score 的范围,注意参数顺序与命令一致(max 在前,min 在后,因为是倒序)
  • offset / count:分页偏移和数量,类似 SQL 的 LIMIT offset, count
  • 返回 Set<TypedTuple<String>>,每个元素包含 value 和 score

注意:Java 方法名中的 reverseRangeByScoreWithScores 参数顺序是 (key, min, max, offset, count),与 Redis 命令 ZREVRANGEBYSCORE key max min 的 max/min 顺序不同,使用时注意不要搞反。


六、关于 Feed 流的扩展思考

6.1 推模式的问题

当前推模式实现简单,但存在一些潜在问题:

  • 大 V 发博慢:如果一个博主有 100 万粉丝,发一次文要写 100 万次 Redis,耗时很长
  • 数据冗余:每篇博客在每个粉丝的收件箱都存一份,存储空间大
  • 新粉丝看不到历史:新关注的用户,之前的博客不会出现在 TA 的收件箱里

6.2 改进方向

问题 优化方案
大 V 发博慢 用消息队列异步推送,或对大 V 改用拉模式
新粉丝看不到历史 关注时将博主最近的 N 条博客补推到粉丝收件箱
存储空间大 设置收件箱的 TTL,比如只保留最近一个月的内容;冷数据归档
数据一致性 数据库和 Redis 双写需要保证一致性,可用消息队列或分布式事务

今日总结

模块 核心要点
关注与取消关注 tb_follow 表存储多对多关系,关注/取关同时更新数据库和 Redis Set
共同关注 Redis Set 的 SINTER 命令求交集,高效计算共同关注的用户
Feed 流模式 拉模式(读扩散)、推模式(写扩散)、推拉结合;本项目采用推模式
推模式实现 发博时查询所有粉丝,将博客 id 推入每个粉丝的 ZSet 收件箱,score 为时间戳
滚动分页 解决传统页码分页的重复问题;使用 lastTime + offset,ZSet 按 score 范围查询
ScrollResult 封装列表数据、最小时间戳和下次偏移量,返回给前端
ZREVRANGEBYSCORE 按 score 倒序范围查询,支持 LIMIT offset count,min 和 max 都是闭区间