滚动分页查询实战示例

场景:朋友圈动态流

假设我们有一个朋友圈应用,用户关注了3个好友:小王、小李、小张。

1. 初始数据状态

时间线数据(按发布时间降序):

博客ID 作者 发布时间(时间戳) 内容
1005 小王 1672617600000 (2023-01-02 00:00:00) "新年快乐!"
1004 小李 1672614000000 (2023-01-01 23:00:00) "跨年夜聚餐"
1003 小张 1672610400000 (2023-01-01 22:00:00) "2022再见"
1002 小王 1672606800000 (2023-01-01 21:00:00) "看跨年晚会"
1001 小李 1672603200000 (2023-01-01 20:00:00) "在家做饭"
1000 小张 1672599600000 (2023-01-01 19:00:00) "整理房间"

Redis中的数据结构:

java 复制代码
# 用户1000(当前用户)的关注feed流
ZADD feed:1000 1672617600000 1005
ZADD feed:1000 1672614000000 1004
ZADD feed:1000 1672610400000 1003
ZADD feed:1000 1672606800000 1002
ZADD feed:1000 1672603200000 1001
ZADD feed:1000 1672599600000 1000

2. 客户端请求流程

第一次请求(初始加载)
java 复制代码
http

GET /of/follow?lastId=9999999999999&offset=0
复制代码

参数说明:

  • lastId=9999999999999:很大的时间戳,表示从最新开始查

  • offset=0:不跳过任何数据

服务端处理流程:

java 复制代码
// Redis查询
reverseRangeByScoreWithScores("feed:1000", 0, 9999999999999, 0, 3)

// 返回结果(按时间戳降序):
[
  {value: "1005", score: 1672617600000},  // 小王
  {value: "1004", score: 1672614000000},  // 小李
  {value: "1003", score: 1672610400000}   // 小张
]

// 解析过程:
List<Long> ids = new ArrayList<>();
long minTime = 0;
int os = 1;

// 遍历第1个元素:1005
time = 1672617600000, minTime=0 (初始)
time != minTime → minTime=1672617600000, os=1
ids = [1005]

// 遍历第2个元素:1004
time = 1672614000000, minTime=1672617600000
time != minTime → minTime=1672614000000, os=1
ids = [1005, 1004]

// 遍历第3个元素:1003
time = 1672610400000, minTime=1672614000000
time != minTime → minTime=1672610400000, os=1
ids = [1005, 1004, 1003]

// 查询数据库
SELECT * FROM blog WHERE id IN (1005,1004,1003) 
ORDER BY FIELD(id, 1005, 1004, 1003)

返回给客户端

第二次请求(滚动加载)

客户端使用第一次返回的 minTimeoffset

java 复制代码
http

GET /of/follow?lastId=1672610400000&offset=1

服务端处理流程:

java 复制代码
// Redis查询:查找时间戳 ≤ 1672610400000 的数据
// offset=1:跳过时间戳等于1672610400000的第1条数据(即博客1003)
reverseRangeByScoreWithScores("feed:1000", 0, 1672610400000, 1, 3)

// 返回结果:
[
  {value: "1002", score: 1672606800000},  // 时间戳小于1672610400000
  {value: "1001", score: 1672603200000},
  {value: "1000", score: 1672599600000}
]

// 解析过程:
List<Long> ids = new ArrayList<>();
long minTime = 0;
int os = 1;

// 遍历第1个元素:1002
time = 1672606800000, minTime=0
time != minTime → minTime=1672606800000, os=1
ids = [1002]

// 遍历第2个元素:1001
time = 1672603200000, minTime=1672606800000
time != minTime → minTime=1672603200000, os=1
ids = [1002, 1001]

// 遍历第3个元素:1000
time = 1672599600000, minTime=1672603200000
time != minTime → minTime=1672599600000, os=1
ids = [1002, 1001, 1000]

返回给客户端:

javascript 复制代码
Json
{
  "code": 200,
  "data": {
    "list": [
      {
        "id": 1002,
        "content": "看跨年晚会",
        "userId": 1001,
        "createTime": 1672606800000,
        "name": "小王",
        "icon": "avatar1.jpg"
      },
      {
        "id": 1001,
        "content": "在家做饭",
        "userId": 1002,
        "createTime": 1672603200000,
        "name": "小李",
        "icon": "avatar2.jpg"
      },
      {
        "id": 1000,
        "content": "整理房间",
        "userId": 1003,
        "createTime": 1672599600000,
        "name": "小张",
        "icon": "avatar3.jpg"
      }
    ],
    "minTime": 1672599600000,
    "offset": 1
  }
}
第三次请求(没有更多数据)
java 复制代码
http

GET /of/follow?lastId=1672599600000&offset=1

服务端处理

返回给客户端:

java 复制代码
json
{
  "code": 200,
  "data": {
    "list": [],
    "minTime": 0,
    "offset": 0
  }
}

3. 处理相同时间戳的情况

假设有新数据插入(多人在同一秒发朋友圈):

复制代码
# 新增数据(同一秒发布)
ZADD feed:1000 1672621200000 1006  # 小赵 - 23:00:00
ZADD feed:1000 1672621200000 1007  # 小钱 - 23:00:00
ZADD feed:1000 1672621200000 1008  # 小孙 - 23:00:00
ZADD feed:1000 1672621200001 1009  # 小赵 - 23:00:01(不同秒)

第一次查询(每页3条):

java 复制代码
// Redis返回:
[
  {value: "1009", score: 1672621200001},
  {value: "1008", score: 1672621200000},
  {value: "1007", score: 1672621200000}
  // 注意:1006还没被取到,因为同一秒有3条,这里只取了2条
]

// 解析:
// 1009: time=1672621200001, minTime=0 → minTime=1672621200001, os=1
// 1008: time=1672621200000, minTime=1672621200001 → minTime=1672621200000, os=1
// 1007: time=1672621200000, minTime=1672621200000 → os=2 (相同时间戳)

// 返回给客户端:
{
  "list": [博客1009, 博客1008, 博客1007],
  "minTime": 1672621200000,
  "offset": 2  // 重要:在1672621200000这个时间点上,已经取了2条
}

第二次查询:

java 复制代码
http

GET /of/follow?lastId=1672621200000&offset=2

含义:跳过时间戳等于1672621200000的前2条数据(1008和1007)

java 复制代码
// Redis查询结果:
[
  {value: "1006", score: 1672621200000},  // 这是同一秒的第3条
  {value: "1005", score: 1672617600000}
]

// 解析:
// 1006: time=1672621200000, minTime=0 → minTime=1672621200000, os=1
// 1005: time=1672617600000, minTime=1672621200000 → minTime=1672617600000, os=1

// 返回:
{
  "list": [博客1006, 博客1005],
  "minTime": 1672617600000,
  "offset": 1
}

4. 前端代码实现

javascript 复制代码
class FeedLoader {
  constructor() {
    this.minTime = Date.now();  // 初始化为当前时间(极大值)
    this.offset = 0;
    this.hasMore = true;
    this.loading = false;
  }

  // 加载动态
  async loadFeed() {
    if (this.loading || !this.hasMore) return;
    
    this.loading = true;
    try {
      const response = await fetch(
        `/of/follow?lastId=${this.minTime}&offset=${this.offset}`
      );
      const result = await response.json();
      
      if (result.code === 200) {
        const data = result.data;
        
        // 渲染动态列表
        this.renderBlogs(data.list);
        
        // 更新游标
        if (data.list.length > 0) {
          this.minTime = data.minTime;
          this.offset = data.offset;
        } else {
          this.hasMore = false;  // 没有更多数据
        }
      }
    } catch (error) {
      console.error('加载失败:', error);
    } finally {
      this.loading = false;
    }
  }

  // 监听滚动事件
  initScrollListener() {
    window.addEventListener('scroll', () => {
      const scrollTop = document.documentElement.scrollTop;
      const windowHeight = window.innerHeight;
      const scrollHeight = document.documentElement.scrollHeight;
      
      // 滚动到底部时加载更多
      if (scrollTop + windowHeight >= scrollHeight - 100) {
        this.loadFeed();
      }
    });
  }

  renderBlogs(blogs) {
    const container = document.getElementById('feed-container');
    blogs.forEach(blog => {
      const blogElement = this.createBlogElement(blog);
      container.appendChild(blogElement);
    });
  }

  createBlogElement(blog) {
    // 创建博客DOM元素
    return `
      <div class="blog-item">
        <img src="${blog.icon}" class="avatar">
        <div class="content">
          <h4>${blog.name}</h4>
          <p>${blog.content}</p>
          <span class="time">${this.formatTime(blog.createTime)}</span>
        </div>
      </div>
    `;
  }
}

// 使用示例
const loader = new FeedLoader();
loader.initScrollListener();
loader.loadFeed();  // 首次加载

5. 新数据发布场景

假设在用户浏览过程中,有新动态发布:

原始数据状态:

java 复制代码
时间线:1005(23:00) > 1004(22:00) > 1003(21:00) > ...
用户已看到:1005, 1004, 1003

新动态发布:

java 复制代码
# 小李在22:30发布新动态(时间戳介于1004和1005之间)
ZADD feed:1000 1672615800000 1010

新的时间线顺序:

java 复制代码
1005(23:00) > 1010(22:30) > 1004(22:00) > 1003(21:00) > ...

用户的浏览体验:

  1. 用户已看完第一页:1005, 1004, 1003

  2. 用户继续滚动,加载第二页:1002, 1001, 1000

  3. 新发布的1010不会影响已浏览的内容,因为它插入在1004之前

  4. 用户下次刷新时,会从最新开始重新加载,看到新内容

关键理解点:

  1. 为什么用时间戳作为score?

    • 时间戳天然有序,支持范围查询

    • 可以精确到毫秒,基本不会重复

  2. offset的作用是什么?

    • 处理同一时间戳的多条数据

    • 记录在同一时间点已经取了多少条

  3. 与传统分页的区别:

    java 复制代码
    // 传统分页(有问题)
    SELECT * FROM blog ORDER BY create_time DESC LIMIT 10 OFFSET 10
    // 如果第1-10条之间插入了新数据,第2页会重复第1页的最后一条
    
    // 滚动分页(解决)
    SELECT * FROM blog WHERE create_time < ? ORDER BY create_time DESC LIMIT 10
    // 基于最后一条的时间戳查询,不受新数据插入影响
  4. 适用场景:

    • 朋友圈、微博等动态流

    • 聊天记录翻看

    • 任何需要"不断加载更多"且数据实时更新的场景

通过这个完整示例,你应该能清楚理解滚动分页的工作原理和实际应用了!

相关推荐
num_killer3 小时前
小白的Langchain学习
java·python·学习·langchain
期待のcode4 小时前
Java虚拟机的运行模式
java·开发语言·jvm
程序员老徐4 小时前
Tomcat源码分析三(Tomcat请求源码分析)
java·tomcat
a程序小傲4 小时前
京东Java面试被问:动态规划的状态压缩和优化技巧
java·开发语言·mysql·算法·adb·postgresql·深度优先
仙俊红4 小时前
spring的IoC(控制反转)面试题
java·后端·spring
阿湯哥4 小时前
AgentScope Java 集成 Spring AI Alibaba Workflow 完整指南
java·人工智能·spring
小楼v4 小时前
说说常见的限流算法及如何使用Redisson实现多机限流
java·后端·redisson·限流算法
与遨游于天地5 小时前
NIO的三个组件解决三个问题
java·后端·nio
czlczl200209255 小时前
Guava Cache 原理与实战
java·后端·spring
yangminlei5 小时前
Spring 事务探秘:核心机制与应用场景解析
java·spring boot