ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

【黑马点评 | 第十一篇】关注 Feed 流实现

【黑马点评 | 第十一篇】关注 Feed 流实现 前言关注 Feed 流解决的是一个连续推送问题用户发布探店笔记后关注他的粉丝可以在自己的关注页面看到这篇笔记并且通过不断下拉获取更早或更新的内容。黑马点评使用 Redis 保存粉丝收件箱发布时写入查询时按发布时间倒序读取。这条链路包含两个关键点发布笔记时如何把消息送到粉丝收件箱以及 Feed 流不断插入新数据后如何稳定分页。后者决定了用户下拉时会不会出现重复数据或漏数据。一、Feed 流的两种常见排序方式Feed 流可以按关注关系推送也可以通过算法筛选内容。Timeline 模式只按照内容产生的时间排序不对内容做复杂筛选。朋友圈、关注列表常用这种方式。它的信息比较完整实现也直接但用户可能会看到较多不感兴趣的内容。智能排序会根据用户行为、内容质量和风险规则筛选信息再决定展示顺序。它能提高内容匹配度但依赖算法效果系统复杂度也更高。当前功能是“关注的人发布笔记后推送给粉丝”因此采用 Timeline 模式重点放在关注关系和发布时间排序上。二、Timeline 的推送模式Timeline 通常有拉模式、推模式和推拉结合模式。1. 拉模式拉模式也叫读扩散。作者发布内容时只保存自己的发件箱粉丝打开 Feed 流后再从所有关注用户的发件箱中读取内容并合并排序。这种方式写入压力较小也不会为每个粉丝复制一份消息但读取时需要访问多个发件箱。关注人数越多读取和排序的成本越高消息到达用户页面也会有额外延迟。2. 推模式推模式也叫写扩散。作者发布笔记后系统查出所有粉丝把笔记 ID 写入每个粉丝自己的收件箱。粉丝读取时只需要访问自己的收件箱查询路径短消息能够及时出现。代价是发布操作的写入次数会随着粉丝数增长粉丝特别多的账号会带来较大的写压力。3. 推拉结合模式推拉结合模式会根据账号规模选择策略。普通用户的粉丝数量较少可以直接推送到粉丝收件箱粉丝很多的账号保留自己的发件箱只给活跃粉丝推送其他粉丝读取时再拉取。本项目实现的是关注 Feed 流的基础版本使用推模式即可满足需求发布笔记时查出粉丝将笔记 ID 写入每个粉丝的 Redis 收件箱。三、为什么不能直接使用传统分页传统分页依赖页码。例如第一次查询第一页返回第 11、10、9 条数据在用户查询下一页之前如果有一条新笔记插入并排在最前面第二页按页码计算时可能从第 8 条开始导致原来的第 9 条被重复读取或者某条数据被跳过。Feed 流的内容会持续增加页码对应的数据位置会不断变化。因此查询下一页时不能只依赖“第几页”需要记录上一页的边界。滚动分页使用两个边界信息上一次查询结果中的最小时间戳作为下一次查询的最大时间边界上一次结果中与这个最小时间戳相同的元素数量作为下一次查询的偏移量。这样即使中间有新笔记写入下一次查询仍然从上一次的时间位置继续向后读取。四、使用 Redis Sorted Set 保存收件箱Redis 的 Sorted Set 同时保存成员和分数适合表示“笔记 ID 发布时间”的关系keyfeed:{userId}每个用户拥有自己的收件箱member笔记 IDscore发布笔记时的毫秒时间戳。读取时使用按分数倒序查询的命令zrevrangebyscore key max min withscores limit offset count其中max是本次查询允许的最大时间戳min通常设置为 0offset用于跳过同一时间戳下已经读取的成员count表示本次读取的数量。项目中每次读取 2 条数据因此count设置为 2。五、发布笔记时推送到粉丝收件箱新增笔记的请求由 BlogController 接收业务处理在 BlogServiceImpl 的 saveBlog 方法中完成PostMappingpublicResultsaveBlog(RequestBodyBlogblog){returnblogService.saveBlog(blog);}Service 先补充当前登录用户再保存笔记。保存成功后根据follow_user_id查询关注当前用户的记录Follow.userId就是粉丝 ID。随后将笔记 ID 写入每个粉丝的 ZSetOverridepublicResultsaveBlog(Blogblog){// 1.获取登录用户UserDTOuserUserHolder.getUser();blog.setUserId(user.getId());// 2.保存探店博文booleanisSuccesssave(blog);if(!isSuccess){returnResult.fail(保存失败);}// 3.查询笔记作者的所有粉丝ListFollowfollowsfollowService.query().eq(follow_user_id,user.getId()).list();// 4.推送笔记 id 给所有粉丝for(Followfollow:follows){Longuseridfollow.getUserId();Stringkeyfeed:userid;stringRedisTemplate.opsForZSet().add(key,blog.getId().toString(),System.currentTimeMillis());}// 5.返回笔记 idreturnResult.ok(blog.getId());}保存笔记时数据库中的笔记 ID 由持久化操作生成只有保存成功后才能推送。ZSet 的 score 使用当前毫秒时间戳因此同一个粉丝的收件箱可以按发布时间排序。收件箱 key 的格式是feed:{userId}例如feed:1001。当前保存逻辑直接使用feed:前缀拼接粉丝 ID项目中的 RedisConstants.FEED_KEY 也定义了相同的前缀。收件箱只保存笔记 ID不保存完整 Blog 对象这样可以避免重复缓存大量内容查询时再根据 ID 从数据库读取完整笔记。六、使用滚动分页查询关注 Feed 流Controller 暴露关注 Feed 流接口GetMapping(/of/follow)publicResultqueryBlogofFollow(RequestParam(latId)Longmax,RequestParam(valueoffset,defaultValue0)Integeroffset){returnblogService.queryBlogofFollow(max,offset);}max表示本次查询的最大时间戳offset表示相同时间戳数据的跳过数量。第一次请求时前端传入当前时间戳和 0后续请求使用上一次结果中的minTime和offset。Service 使用 Redis 的reverseRangeByScoreWithScores查询收件箱OverridepublicResultqueryBlogofFollow(Longmax,Integeroffset){LonguserIdUserHolder.getUser().getId();StringkeyRedisConstants.FEED_KEYuserId;SetZSetOperations.TypedTupleStringtypedTuplesstringRedisTemplate.opsForZSet().reverseRangeByScoreWithScores(key,0,max,offset,2);if(typedTuplesnull||typedTuples.isEmpty()){returnResult.ok(Collections.emptyList());}ListLongidsnewArrayList(typedTuples.size());longminTime0;intos1;for(ZSetOperations.TypedTupleStringtuple:typedTuples){ids.add(Long.valueOf(tuple.getValue()));longtimetuple.getScore().longValue();if(timeminTime){os;}else{minTimetime;os1;}}StringidStrStrUtil.join(,,ids);ListBlogblogsquery().in(id,ids).last(ORDER BY FIELD(id,idStr)).list();for(Blogblog:blogs){queryBlogbyuser(blog);isBlogLiked(blog);}ScrollResultresultnewScrollResult();result.setList(blogs);result.setOffset(os);result.setMinTime(minTime);returnResult.ok(result);}1. 查询 Redis 收件箱reverseRangeByScoreWithScores(key, 0, max, offset, 2)会按照 score 从大到小返回数据并且保留每条数据的 score。这里的 score 就是笔记发布时间返回结果中既有笔记 ID也有对应时间戳。2. 计算下一次查询的边界遍历结果时minTime记录本页最小时间戳。os用来统计结果末尾有多少条数据共享这个时间戳。例如本页最后两条数据的 score 都是 1700000000000那么下一页仍然要从这个 score 开始查询并跳过已经读取的两条数据。只保存最小时间戳而不保存 offset会导致相同时间戳的数据重复出现。3. 按 Redis 顺序查询数据库Redis 返回的是有序的笔记 ID 集合MySQL 使用in查询时不保证按照传入 ID 的顺序返回。因此代码通过ORDER BY FIELD(id, ...)恢复 Redis 的排序结果避免页面上的时间顺序被数据库默认顺序打乱。查询完成后代码继续补充笔记作者的昵称和头像并判断当前用户是否已经点赞最后将笔记列表、最小时间戳和偏移量封装到 ScrollResultDatapublicclassScrollResult{privateList?list;privateLongminTime;privateIntegeroffset;}前端下一次请求需要携带minTime和offset这样 Feed 流就能沿着上一次结果的末尾继续读取。七、总结关注 Feed 流的核心链路是发布笔记后查询粉丝将笔记 ID 和发布时间写入每个粉丝的 Redis ZSet查询时按时间戳倒序读取再用最小时间戳和重复时间戳数量完成滚动分页。这个方案把“消息推送”和“稳定分页”拆成了两个明确问题ZSet 负责收件箱排序滚动分页负责应对新内容持续插入。理解这两个边界就能在朋友圈、订阅列表、消息流等类似场景中复用同样的设计。
返回列表