一、 为什么传统的“页码分页”不行?

在做后台管理系统时,我们习惯用 page = 1, size = 10 这种方式。但在 Feed 流(动态列表)场景下,它是致命的。

1.1 场景模拟

假设你的数据库里按时间倒序排有 5 条动态:[5, 4, 3, 2, 1]。

  1. 用户刷新第 1 页:查出 [5, 4]。
  2. 此时,有一个新动态 6 发布了。列表变成了 [6, 5, 4, 3, 2, 1]。
  3. 用户下拉加载第 2 页:数据库执行 LIMIT 2, 2(跳过前 2 个,取 2 个)。
  4. 结果:跳过了 6, 5,取出了 [4, 3]。

问题出现了:用户在第 1 页看过了 4,在第 2 页又看到了 4! 如果是新删除了数据,用户可能会漏看数据。

1.2 解决方案:滚动分页 (Scroll Pagination)

我们不再使用“第几页”这个概念,而是使用**“游标 (Cursor)”**。 每次查询都告诉后端:“我上一页看到的最后一条数据是谁,你从它后面接着给我找。”


二、 核心技术选型:Redis ZSet

为了支撑高并发的读写,Feed 流通常使用 Redis 的 Sorted Set (ZSet) 存储。

  • Key: feed:用户ID
  • Value: blogId (动态ID)
  • Score: timestamp (发布时间戳)

利用 ZSet 的天然排序特性,我们可以实现按时间倒序排列。而 Redis 提供的以下命令是实现滚动分页的基石:

Bash

# 从大到小查询,指定分数范围,并限制数量
ZREVRANGEBYSCORE key max min LIMIT offset count
  • max: 上一次查询的最后一条动态的时间戳。
  • min: 0。
  • offset: 偏移量(核心难点,下文详解)。
  • count: 每次查几条。

三、 难点攻克:时间戳一样怎么办?

如果仅仅记录“最后一条的时间戳”作为下一次的 max,会有一个边界 Bug: 如果恰好有 3 条动态是同一毫秒发布的,它们的 Score 一样。

  • 上一页查到了第 1 条(时间戳 1000)。
  • 下一页从 1000 开始查,如果不做偏移,会把刚才那条又查一遍。
  • 如果直接 offset = 1,那万一有 5 条都是 1000 呢?直接跳过可能会漏数据。

最终策略: 我们需要记录两个参数传递给前端:

  1. minTime:当前查询结果中,最后一条数据的 Score(时间戳)。
  2. offset:当前查询结果中,最后这个 Score 出现了几次。

四、 代码实现 (Java + RedisTemplate)

4.1 Controller 层参数定义

前端请求时,如果是第一次刷新,max 传当前时间,offset 传 0。之后每次请求,都把上一次后端返回的 minTime 和 offset 带回来。

4.2 Service 层核心逻辑 (硬核部分)

这段代码完美解决了分数重复时的偏移量计算问题。

Java

@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
    // 1. 获取当前用户
    Long userId = UserHolder.getUser().getId();
    
    // 2. 查询收件箱:按分数倒序获取 ID 和 Score
    // 对应 Redis 命令:ZREVRANGEBYSCORE key Max Min LIMIT offset count
    String key = FEED_KEY + userId;
    Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
        .reverseRangeByScoreWithScores(key, 0, max, offset, 2); // 这里为了演示设为 2,实际可设为 10
    
    // 3. 非空判断
    if (typedTuples == null || typedTuples.isEmpty()) {
        return Result.ok();
    }
    
    // 4. 解析数据:核心算法 —— 线性扫描 + 状态重置
    List<Long> ids = new ArrayList<>(typedTuples.size());
    long minTime = 0; 
    int os = 1; // 这里的 os 代表下一次查询的 offset
    
    for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
        // 4.1 获取 id
        ids.add(Long.valueOf(tuple.getValue()));
        
        // 4.2 获取分数(时间戳)
        long time = tuple.getScore().longValue();
        
        // 4.3 计算 offset 的核心逻辑
        if(time == minTime){
            // 如果当前元素的时间 == 最小时间,说明发生了重复,计数器 +1
            os++;
        }else{
            // 如果遇到了新的时间,说明上一组已经结束,重置 minTime 和 os
            minTime = time;
            os = 1;
        }
    }
    
    // 特殊处理:如果查询结果里的所有数据时间戳都一样(或者延续了上一次的重复),
    // 这里的 os 需要累加给前端传来的 offset,否则游标会原地踏步。
    // 注意:这里的逻辑需根据你的实际业务场景微调,通常 minTime == max 时才需要累加
    
    // 5. 根据 ID 批量查询数据库 (注意 MySQL 的 IN 查询无序问题)
    String idStr = StrUtil.join(",", ids);
    List<Blog> blogs = query().in("id", ids)
            .last("ORDER BY FIELD(id," + idStr + ")") // 必须手动指定顺序!!
            .list();

    // ... (此处省略填充用户信息和点赞状态的代码) ...

    // 6. 封装并返回 ScrollResult
    ScrollResult r = new ScrollResult();
    r.setList(blogs);
    r.setOffset(os); // 告诉前端:下次来的时候,跳过 os 个
    r.setMinTime(minTime); // 告诉前端:下次从 minTime 开始找

    return Result.ok(r);
}

五、 算法深度解析

在解析数据的 for 循环中,我们用到了一种类似 Run-Length Encoding (游程统计) 的算法思维。

  1. 目标:我们需要知道列表中最后一个时间戳,在列表末尾连续出现了几次。
  2. 过程:
    • 遍历数据,minTime 始终更新为当前遍历到的时间(因为它是有序的,越往后越小)。
    • 如果 time 没变,os 累加。
    • 如果 time 变了(遇到了新时间),os 重置为 1。
  3. 结果:循环结束时,os 恰好就是最后那组相同分数的元素个数。这就完美计算出了下一次 Redis 查询需要的 OFFSET。

六、 总结与避坑指南

  1. ZSet 的 Score 精度陷阱:Redis 的 Score 是 Double 类型,如果使用雪花算法 ID 直接作为分数,可能会发生精度丢失。最佳实践是使用 System.currentTimeMillis() 作为分数,如果需要更细粒度,可以将 ID 拼接到分数的小数位,或者使用上述的 offset 逻辑来处理重复。
  2. MySQL 的顺序问题:从 Redis 拿到的 IDs 是有序的,但 SELECT * FROM table WHERE id IN (...) 返回的结果通常是按主键 ID 排序的,会导致顺序错乱。一定要配合 ORDER BY FIELD 使用。
  3. 推拉模式选择:本文实现的是推模式 (Push)。如果是千万级粉丝的大 V,建议结合拉模式 (Pull) 混合使用,避免写扩散导致 Redis 瞬间过载。

更多推荐