Skip to content
DAILY QUOTE

“ ”

Feed流实现方案

当用户关注了博主,并且博主发布了动态时,我们需要将这些动态数据推送给用户。这种需求通常被称为Feed流,也可以理解为关注推送,直译为“投喂”。目的时为用户提供沉浸式体验,通过无限下拉刷新来获取最新动态消息。

对于传统模式的内容解锁:我们需要用户去搜索引擎或是其他方式解锁想要看的内容

对于新型Feed流效果:不需要用户自己去检索消息,而是系统分析用户想要什么,然后直接把内容推送给用户,从而使用户更加节约时间,不用主动寻找。

Feed流产品实现有两种常见模式:

Timeline:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈。

  • 优点:信息全面,不会有缺失。并且实现相对简单。
  • 缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低。 智能排序:利用只能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户。例如抖音、快手。
  • 优点:头为用户感兴趣信息,用户粘度高,容易沉迷
  • 缺点:如果的算法不准确,可能起到反作用

我们针对好友的操作采用的就是Timeline方式,只需要拿到我们关注用户的信息,然后按照时间排序即可,因此采用Timeline的模式。该模式实现方案有三种:

  • 拉模式
  • 推模式
  • 推拉结合

拉模式

也叫做读扩散。在拉模式中,终端用户或应用程序主动发送请求来获取最新的数据流。它是一种按需获取数据的方式,用户可以在需要时发出请求来获取新数据。在Feed流中,数据提供方将数据发布到实时数据源中,而终端用户或应用程序通过订阅或请求来获取新数据。

例如,当张三和李四和王五发了消息后,都会保存在自己的邮箱中,假设赵六要读取信息,那么他会从读取他自己的收件箱,此时系统会从他关注的人群中,把他关注人的信息全部都进行拉取,然后在进行排序

优点:节约空间,因为赵六在读信息时,并没有重复读取,而且读取完之后可以把他的收件箱进行清除。

缺点:延迟较高,当用户读取数据时才去关注的人里边去读取数据,假设用户关注了大量的用户,那么此时就会拉取海量的内容,对服务器压力巨大。

推模式

也叫做写扩散。在推模式中,数据提供方主动将最新的数据推送给终端用户或应用程序。数据提供方会实时地将数据推送到终端用户或应用程序,而无需等待请求。

推模式是没有写邮箱的,当张三写了一个内容,此时会主动的把张三写的内容发送到他的粉丝收件箱中去,假设此时李四再来读取,就不用再去临时拉取了

优点:时效快,不用临时拉取

缺点:内存压力大,假设一个大V写信息,很多人关注他, 就会写很多份数据到粉丝那边去

推拉结合模式

也叫做读写混合,兼具推和拉两种模式的优点。在推拉结合模式中,数据提供方会主动将最新的数据推送给终端用户或应用程序,同时也支持用户通过拉取的方式来获取数据。这样可以实现实时的数据更新,并且用户也具有按需获取数据的能力。

推拉结合是一种这种方案。 对于发件人:

  • 普通人:采用写扩散的方式,直接把数据写入到他的粉丝中去,因为普通人粉丝关注量小,这样做没有压力。
  • 大V:将数据先写入到一份邮箱去,然后再写一份到活跃粉丝邮箱里。 对于收件人:
  • 活跃粉丝:大V和普通人发的都会直接写入到自己收件箱。
  • 普通粉丝:上线不频繁,等上线时,再去发件箱拉取数据

推送到粉丝邮件箱

需求:

  • 增加新增探店笔记业务,在保存blog到数据库的同时,同送到粉丝收件箱
  • 收件箱满足可以根据时间戳排序,必须用Redis的数据结构实现
  • 查询收件箱数据时,可以实现分页查询

Feed流中的分页的问题:Feed流数据会不断更新,可能随时发生变化,所以数据的角标也在变化,因此不能采用传统分页模式

假设此时是t1时刻,page=1,size=5,我们拿到的就是10~6这几条数据,假设t2时刻又新发布了一条数据11,在t3时刻,我们读取第二页,传入参数page=2,size=5,南无此时读取到的第二页实际上是从6开始,查询6~2,那我们就读到了重复的数据6,所以feed流的分页不能采取传统方式来做。

Feed流的滚动分页:因为我们存的数据是有序的,可以用翻页游标IastId记录每次操作的最后一条,然后从这个位置开始去读取数据,这样查询不依赖角标,因此不会收到数据角标变化带来的影响。

从t1时刻开始,读取第一页数据,拿到10~6,然后记录下当前最后一次拿取的记录,IastId就是6,t2时刻发布新数据11放到最前面,不会影响我们之前记录的6,t3时刻再来查询第二页,第二页起始位置从下一个5开始湖区哦v,就拿到了5~1的记录

之前分析过,虽然List和SortedSet都能支持排序,但List结构依赖角标查询,因此不支持滚动分页。而SortedSet会按照score值排序,数据排序完会有一个排名,如果按排名查询,那和角标查询没有区别,而SortedSet还支持按score值的范围进行查询,因此我们可以采用SortedSet来做,按时间戳从大到小降序排列后进行范围查询,查询时每次记录最小的时间戳,下次查询时找比这个时间戳次小的,从这里开始,从而实现滚动分页。

  • 保存笔记,并推送给所有粉丝
java
@Resource
private IFollowService followService;

/**
 * 发布探店笔记并推送给粉丝。
 *
 * 作用:
 * 1.获取登录用户;
 * 2.保存探店笔记;
 * 3.查询笔记作者的所有粉丝select*fromtb_followwherefollow;
 * 4.推送笔记id给所有粉丝;
 * 5.获取粉丝id;
 *
 * @param blog当前博客对象
 * @return处理结果
 */
@Override
public Result saveBlog(Blog blog) {
    //1.获取登录用户
    UserDTO user = UserHolder.getUser();
    blog.setUserId(user.getId());
    //2.保存探店笔记
    boolean isSuccess=save(blog);
    if(!isSuccess){
        return Result.fail("新增笔记失败");
    }
    //3.查询笔记作者的所有粉丝select * from tb_follow where follow_user_id=?
    List<Follow> follows=followService.query().eq("follower_id",user.getId()).list();
    //4.推送笔记id给所有粉丝
    for (Follow follow:follows){
        //4.1.获取粉丝id
        Long id=follow.getId();
        //4.2.推送
        String key="feed:"+user.getId();
        stringRedisTemplate.opsForZSet().add(key,blog.getId().toString(),System.currentTimeMillis());
    }
    //5.返回id
    return Result.ok(blog.getId());
}
  • 测试功能 发布笔记后,粉丝邮箱出现推送笔记

实现滚动分页查询

需求:在个人主页的“关注”卡片中,查询并展示推送的Blog信息

ZRANGE是按照角标从小到大排序:

ZREVRANGE是按照角标从大到小排序

ZREVRANGEBYSCORE是按照分数从大到小排序

参数:

  • max:分数最大值
  • min:分数最小值
  • offset:偏移量
  • count:查的数量

滚动查询:每次记住上一次查询的最小值,将上一次的最小值作为下一次查询的最大值

bash
ZREVRANGEBYSCORE z1 1000 0 WITHSCORES LIMIT 0 3

#从z1里查score在0~1000之间的数据
#按score从大到小排
#从第0条开始
#查3条

bash
ZREVRANGEBYSCORE z1 6 0 WITHSCORES LIMIT 1 3

#查score<=6的数据
#跳过1条
#查3条

第一次查询最小score为6并且有两个score为6的数据,所以第二次查询offset应该为2,表示跳过这两条数据

在使用ZREVRANGEBYSCORE key max min LIMIT offset count实现滚动分页时:

  • min表示查询分数下限,一般固定为0或一个足够小的值,用来保证能查到更早的数据;count表示每次查询的数量,由前端或后端分页大小决定,通常固定不变。
  • 第一次查询时,max取当前时间戳,offset0,表示从最新数据开始查询。
  • 之后每次查询时,max取上一次查询结果中的最小分数,也就是上一页最后一批数据的最小时间戳;offset用来跳过已经查询过的、并且分数等于当前max的元素,避免因为多个元素分数相同而出现重复数据。
  • 如果上一页结果中最小分数为minTime,并且有n个元素的分数等于minTime,那么下一次查询时:
bash
max = minTime
offset = n
  • ScrollResult:封装滚动分页查询结果
java
@Data
public class ScrollResult {
    private List<?> list;
    private Long minTime;
    private Integer offset;
}
  • BlogServiceImpl
java
/**
 * 滚动分页查询关注用户的探店笔记。
 *
 * 作用:
 * 1.获取当前用户;
 * 2.查询收件箱ZREVRANGEBYSCOREkeyMaxMinLIMIToffsetc;
 * 3.非空判断;
 * 4.解析数据:blogId、minTime(时间戳)、offset;
 * 5.获取id;
 *
 * @param max本次查询的最大时间戳
 * @param offset偏移量
 * @return处理结果
 */
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
    //1.获取当前用户
    Long userId=UserHolder.getUser().getId();
    //2.查询收件箱
    String key="feed:"+userId;
    Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet().rangeByScoreWithScores(key, 0, max, offset, 2);
    //3.非空判断
    if(typedTuples==null||typedTuples.isEmpty()){
        return Result.ok();
    }
    //4.解析数据:blogId、minTime(时间戳)、offset
    List<Long> ids=new ArrayList<>(typedTuples.size());
    long minTime=0;
    int os=1;
    for(ZSetOperations.TypedTuple<String> tuple:typedTuples){
        //4.1.获取id
        ids.add(Long.valueOf(tuple.getValue()));
        //4.2.获取分数(时间戳)
        long time=tuple.getScore().longValue();
        if(time==minTime){
            os++;
        }else{
            minTime=time;
            os=1;
        }
    }
    //5.根据id查询blog
    String idStr = StrUtil.join(",",ids);
    List<Blog> blogs=query().in("id",ids).last("ORDER BY FIELD(id,"+idStr+")").list();

    for(Blog blog:blogs){
        queryBlogUser(blog);
        isBlogLiked(blog);
    }

    //6.封装并返回
    ScrollResult r=new ScrollResult();
    r.setList(blogs);
    r.setOffset(os);
    r.setMinTime(minTime);
    return Result.ok(r);
}
  • 测试功能 用户发布笔记后,粉丝成功查看笔记,上刷重新加载页面获取最新时间戳,下拉获取旧数据