跳到主要内容

好友关注Feed流推送

在社交化的应用中,好友关注和 Feed 流功能是提升用户体验和增加用户互动的关键特性。本文将对黑马点评项目中好友关注和 Feed 流的相关功能进行深入剖析和优化。

好友关注

关系表设计

为了实现一个用户可以关注多个用户,且一个用户可以被多个用户关注的多对多关系,创建了tb_follow表,其中包含id(主键)、user_id(用户 id)、follow_user_id(关联的用户 id)和create_time(创建时间)等字段。

create table tb_follow
(
id bigint auto_increment comment '主键'
primary key,
user_id bigint unsigned not null comment '用户id',
follow_user_id bigint unsigned not null comment '关联的用户id',
create_time timestamp default CURRENT_TIMESTAMP not null comment '创建时间'
)
collate = utf8mb4_general_ci
row_format = COMPACT;

代码实现

控制器,控制器提供了两个接口,follow接口用于处理关注和取关操作,isFollow接口用于判断当前用户是否关注了指定用户。:

@RestController
@RequestMapping("/follow")
public class FollowController {

@Resource
private IFollowService followService;

@PutMapping("/{id}/{isFollow}")
public Result follow(@PathVariable("id") Long followUserId, @PathVariable("isFollow") Boolean isFollow) {
return followService.follow(followUserId, isFollow);
}

@PutMapping("/or/not/{id}")
public Result isFollow(@PathVariable("id") Long followUserId) {
return followService.isFollow(followUserId);
}
}

业务逻辑:在follow方法中,根据isFollow参数判断是进行关注还是取关操作。如果是关注,创建Follow对象并保存到数据库,同时将关注用户的 id 添加到 Redis 的 set 集合中;如果是取关,通过查询条件删除数据库中的关注记录,并从 Redis 的 set 集合中移除相应的 id。isFollow方法通过查询数据库判断当前用户是否关注了指定用户。

    @Override
public Result follow(Long followUserId, Boolean isFollow) {
Long userId = UserHolder.getUser().getId();
//判断是 关注 还是 取关
if (isFollow) {
//关注,添加关注记录
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
this.save(follow);
} else {
//取关,删除关注记录
QueryWrapper<Follow> followQueryWrapper = new QueryWrapper<>();
followQueryWrapper.eq("user_id", userId).eq("follow_user_id", followUserId);
this.remove(followQueryWrapper);
}
return Result.ok();
}

@Override
public Result isFollow(Long followUserId) {
//1.查询是否关注
Long userId = UserHolder.getUser().getId();
Integer count = this.query().eq("user_id", userId).eq("follow_user_id", followUserId).count();
return Result.ok(count > 0);
}

共同关注

需求分析 利用 Redis 中恰当的数据结构,实现展示当前用户与博主的共同关注的功能。

实现思路 使用 Redis 的 set 集合来存储用户的关注关系。因为 set 集合具有交集、并集和补集等操作的 API,所以可以将两人关注的人分别放入两个 set 集合中,然后通过集合的交集操作获取共同关注的用户。

改造当前关注用户的逻辑:

    public Result follow(Long followUserId, Boolean isFollow) {
Long userId = UserHolder.getUser().getId();
// 判断是关注还是取关
String key = "follow:" + userId;
if (isFollow) {
// 关注,添加关注记录
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
boolean result = this.save(follow);
if (result) {
// 放入redis
stringRedisTemplate.opsForSet().add(key, followUserId.toString());
}
} else {
// 取关,删除关注记录
QueryWrapper<Follow> followQueryWrapper = new QueryWrapper<>();
followQueryWrapper.eq("user_id", userId).eq("follow_user_id", followUserId);
boolean result = this.remove(followQueryWrapper);
if (result) {
stringRedisTemplate.opsForSet().remove(key, followUserId.toString());
}
}
return Result.ok();
}

在关注和取关操作时,同时更新 Redis 中的 set 集合,确保数据的一致性。

查询共同关注的博主的 id:

    @Override
public Result followCommons(Long id) {
Long userId = UserHolder.getUser().getId();
String key = "follow:" + userId;
String key2 = "follow:" + id;
Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key, key2);
if (intersect == null || intersect.isEmpty()) {
return Result.ok(Collections.emptyList());
}
List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList());
List<UserDTO> users = userService.listByIds(ids)
.stream()
.map(user -> {
return BeanUtil.copyProperties(user, UserDTO.class);
})
.collect(Collectors.toList());
return Result.ok(users);
}

在业务逻辑中,通过 Redis 的opsForSet().intersect方法获取两个用户关注集合的交集,然后将交集中的用户 id 转换为Long类型,再根据这些 id 查询用户信息,并将用户信息转换为UserDTO对象后返回。

Feed流

概念介绍

Feed 流是指当用户关注了其他用户后,该用户发布的动态会被推送给关注者,以提供一种持续的、沉浸式的信息体验,用户可以通过无限下拉刷新来获取新的信息。

常见模式

  • Timeline 模式:不进行内容筛选,按照内容发布时间简单排序,常用于好友或关注关系的信息流展示,如朋友圈。
    • 优点:信息全面,不会有缺失,实现相对简单。
    • 缺点:信息噪音较多,用户不一定对所有推送内容感兴趣,导致内容获取效率低。
  • 智能排序模式:利用智能算法屏蔽违规和用户不感兴趣的内容,推送用户感兴趣的信息,以吸引用户。
    • 优点:能够投喂用户感兴趣的信息,提高用户粘度,容易使用户沉迷。
    • 缺点:如果算法不精准,可能会起到反作用,推送的内容不符合用户预期。

本次针对好友的操作,采用的是 Timeline 模式,只需要获取关注用户的信息并按照时间排序即可。三种实现方案:

  • 拉模式:优点是比较节约空间,因为用户在读取信息时不会重复读取,并且读取完后可以清理收件箱。缺点是比较延迟,当用户读取数据时才从关注的人那里拉取数据,如果用户关注了大量用户,会拉取海量内容,对服务器压力巨大。

1653809450816.png

  • 推模式:优点是时效快,不用临时拉取数据。缺点是内存压力大,例如一个大 V 发布信息,很多人关注他,就会向粉丝发送很多份数据。

1653809875208.png

  • 推拉结合模式:结合了拉模式和推模式的优点,既能保证一定的时效性,又能减少内存压力。

1653812346852.png

推送粉丝收件箱

需求分析

  • 修改新增探店笔记的业务,在保存博客到数据库的同时,将博客推送到粉丝的收件箱。
  • 收件箱要满足根据时间戳排序的要求,且必须使用 Redis 的数据结构实现。
  • 查询收件箱数据时,要能够实现分页查询。

1653813047671.png

翻页:

1653813462834.png

实现思路 Feed 流中的数据不断更新,数据的角标也会随之变化,因此不能采用传统的分页模式。可以采用 sortedSet 来实现滚动分页,记录每次操作的最后一条数据的时间戳,然后从这个位置开始读取数据

发布博客时进行推送的业务逻辑:

    @Override
public Result saveBlog(Blog blog) {
// 获取登录用户
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
// 保存探店博文
boolean result = this.save(blog);
if (!result) {
return Result.fail("新增博客失败");
}
// 查询所有粉丝
List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list();
for (Follow follow : follows) {
// 粉丝id
Long userId = follow.getUserId();
String key = "feed:" + userId;
stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis());
}
// 返回id
return Result.ok(blog.getId());
}

在保存博客到数据库后,查询所有关注当前用户的粉丝,然后将博客的 id 和发布时间戳添加到粉丝的收件箱(Redis 的 sortedSet 中)。

分页查询收件箱

需求分析 在个人主页的 “关注” 卡片中,需要查询并展示推送的博客信息。每次查询完成后,要分析出查询出数据的最小时间戳,作为下一次查询的条件;同时,需要找到与上一次查询相同的查询个数作为偏移量,下次查询时跳过这些已查询过的数据,获取新的数据。因此,请求参数中需要携带lastId(上一次查询的最小时间戳)和offset(偏移量)这两个参数,且这两个参数第一次由前端指定,以后的查询根据后台结果作为条件再次传递到后台。

1653819821591.png

定义滚动返回值实体类:

    @Data
public class ScrollResult {
private List<?> list;
private Long minTime;
private Integer offset;
}

控制器接收lastIdoffset两个参数,用于查询收件箱中的博客信息:

    @GetMapping("/of/follow")
public Result queryBlogOfFollow(
@RequestParam("lastId") Long max,
@RequestParam(value = "offset", defaultValue = "0") Integer offset) {
return blogService.queryBlogOfFollow(max, offset);
}

在业务逻辑中,根据用户 id 从 Redis 的 sortedSet 中查询收件箱中的博客信息。然后,解析出博客 id、最小时间戳和偏移量,并根据博客 id 查询博客详情。最后,将查询结果封装到ScrollResult对象中返回。:

    @Override
public Result queryBlogOfFollow(Long max, Integer offset) {
Long userId = UserHolder.getUser().getId();
// 查询收件箱
String key = FEED_KEY + userId;
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
.reverseRangeByScoreWithScores(key, 0, max, offset, 2);
if (typedTuples == null || typedTuples.isEmpty()) {
return Result.ok();
}
// 解析数据:blogId、minTime、offset
List<Long> ids = new ArrayList<>(typedTuples.size());
long minTime = 0;
int os = 1;
for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
// 获取id
ids.add(Long.valueOf(tuple.getValue()));
// 获取时间
long time = tuple.getScore().longValue();
if (time == minTime) {
os++;
} else {
minTime = time;
os = 1;
}
}
os = minTime == max? os : os + offset;
// 根据id查询博文
List<Blog> blogs = this.query().in("id", ids)
.last("ORDER BY FIELD(id," + StringUtil.join(ids, ",") + ")")
.list();
for (Blog blog : blogs) {
this.queryBlogUser(blog);
this.isBlogLiked(blog);
}

// 封装返回数据
ScrollResult scrollResult = new ScrollResult();
scrollResult.setOffset(os);
scrollResult.setList(blogs);
scrollResult.setMinTime(minTime);
return Result.ok(scrollResult);
}