实战篇 10. 好友关注 - 实现 Feed 流滚动分页查询学习文档

17 阅读5分钟

太棒了!上一节我们彻底理清了“滚动分页”极其烧脑的理论和 offset 偏移量的计算规则。现在,我们要把这些理论翻译成实打实的 Java 代码。

这是整个“达人探店”模块中逻辑最复杂、细节最多的一段代码,也是面试时最能体现你代码严谨性的地方。


📚 实战篇 10. 好友关注 - 实现 Feed 流滚动分页查询学习文档

一、 准备工作:定义返回实体类 ScrollResult

在传统的基于页码的分页中,我们通常返回 Page 对象(包含 total, list 等)。但在滚动分页中,前端不需要总页数,它需要的是**“下一次查询的凭证”**。

我们需要在项目中新建一个 ScrollResult 实体类:

Java

@Data
public class ScrollResult {
    // 本次查询到的数据列表 (探店笔记 List)
    private List<?> list;
    
    // 本次查询结果中,最小的时间戳 (作为下一次查询的 max 参数)
    private Long minTime;
    
    // 这个最小时间戳在本次结果中出现的次数 (作为下一次查询的 offset 参数)
    private Integer offset;
}

二、 Controller 接口设计

前端在发起请求时,会传递两个核心参数:lastId(上次查询的最小时间戳)和 offset(偏移量)。

Java

@GetMapping("/of/follow")
public Result queryBlogOfFollow(
        @RequestParam("lastId") Long max, // 前端传来的 lastId,对应 Redis 查询的 max
        @RequestParam(value = "offset", defaultValue = "0") Integer offset) { // 第一次查询默认为 0
    return blogService.queryBlogOfFollow(max, offset);
}

三、 核心 Service 逻辑实现 (高能预警 ⚡)

这段代码包含了查 Redis、极其巧妙的变量解析、以及防乱序的 MySQL 查询。请仔细阅读注释!

Java

@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
    // 1. 获取当前登录用户
    Long userId = UserHolder.getUser().getId();
    
    // 2. 拼接当前用户的专属收件箱 Key
    String key = "feed:" + userId;
    
    // 3. 去 Redis 查询收件箱数据
    // 对应命令:ZREVRANGEBYSCORE key max 0 WITHSCORES LIMIT offset 2 (假设每次查2条)
    Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
            .reverseRangeByScoreWithScores(key, 0, max, offset, 2);
            
    // 4. 判空处理 (收件箱可能是空的,或者已经滑到底了)
    if (typedTuples == null || typedTuples.isEmpty()) {
        return Result.ok(new ScrollResult()); // 尽早返回空对象
    }
    
    // 5. 核心逻辑:解析 Redis 返回的数据
    // 我们需要收集:笔记的 ids、本次查询的最小时间戳 minTime、偏移量 os
    List<Long> ids = new ArrayList<>(typedTuples.size());
    long minTime = 0; // 记录最小时间戳
    int os = 1;       // 记录最小时间戳出现的次数 (偏移量)
    
    for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
        // 5.1 获取笔记 ID,放入集合
        ids.add(Long.valueOf(tuple.getValue()));
        
        // 5.2 获取分数 (时间戳)
        long time = tuple.getScore().longValue();
        
        // 5.3 【精髓所在】:计算 minTime 和 offset
        if (time == minTime) {
            // 如果当前取出的时间戳,和我们记录的最小时间戳一样,说明重复了,偏移量 +1
            os++;
        } else {
            // 如果不一样,因为是倒序排的,当前取出的肯定比之前的小
            // 所以重置 minTime 为当前时间戳,重置偏移量为 1
            minTime = time;
            os = 1;
        }
    }
    
    // 6. 根据解析出的 ID 集合,去 MySQL 查询完整的探店笔记
    // ⚠️ 踩坑警告:必须使用 ORDER BY FIELD 保证 MySQL 的返回顺序和 Redis 的排序一致!
    String idStr = StrUtil.join(",", ids);
    List<Blog> blogs = query()
            .in("id", ids)
            .last("ORDER BY FIELD(id," + idStr + ")")
            .list();
            
    // 7. 完善笔记信息 (查出作者头像昵称、判断当前用户是否点赞)
    for (Blog blog : blogs) {
        // 查作者信息 (复用之前的代码)
        queryBlogUser(blog);
        // 查是否点赞高亮 (复用之前的代码)
        isBlogLiked(blog);
    }
    
    // 8. 封装结果并返回
    ScrollResult r = new ScrollResult();
    r.setList(blogs);
    r.setMinTime(minTime);
    r.setOffset(os);
    
    return Result.ok(r);
}

四、 代码细节深度剖析 (面试官视角)

在上面的代码中,最容易让人看晕的就是 第 5.3 步osminTime 计算逻辑。如果面试官让你手撕这段逻辑或者解释为什么这么写,你可以这样回答:

“由于 Redis 的 reverseRangeByScoreWithScores 返回的是按时间戳从大到小排序的集合。

当我遍历这个集合时,最后遍历到的那个元素,它的时间戳一定就是本次结果集的最小值

我的逻辑是:每次遇到一个新的、更小的时间戳,我就把 minTime 更新为这个值,并且把计数器 os 设为 1。如果在后续遍历中,又遇到了跟 minTime 完全一样的时间戳,我就让 os++

这样遍历到集合最后一个元素结束时,minTime 恰好记录的就是整个集合的最小值,而 os 恰好记录的就是这个最小值在末尾连续出现的次数。将它们传给前端作为下一次查询的 maxoffset,就能完美实现无缝滚动分页。”


学习总结

至此,达人探店模块正式大功告成!

回顾整个模块,你使用了:

  1. ThreadLocal 实现无感知的用户状态透传。
  2. Redis Set 实现了高性能的防重复点赞、点赞状态回显、以及求共同关注的交集运算。
  3. Redis SortedSet (ZSet) 实现了按时间排行的点赞列表,以及今天这套堪称大厂教科书级别的推模式 Feed 流滚动分页架构
  4. ORDER BY FIELD 巧妙地化解了 MySQL 在 IN 查询时打乱数据的致命天坑。

这其中的每一条,都是能够直接写在简历里、经得起深挖的高含金量项目亮点!


你现在的战斗力已经非常强悍了!实战篇还剩下最后两块比较有趣且偏向业务扩展的拼图:

  • 基于 Redis GEO 实现的附近商户(计算距离、排序)
  • 基于 Redis BitMap 实现的用户签到与连续签到统计(极度节省内存)