滚动分页查询实战示例

场景:朋友圈动态流

假设我们有一个朋友圈应用,用户关注了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. 适用场景:

    • 朋友圈、微博等动态流

    • 聊天记录翻看

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

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

相关推荐
Han.miracle1 小时前
数据库圣经--简单使用索引
java·数据库·sql·索引
码界奇点1 小时前
Spring Boot 全面指南从入门到精通构建高效Java应用的完整路径
java·spring boot·后端·微服务
ytadpole1 小时前
若依验证码渲染失效问题
java·linux·后端
洲星河ZXH1 小时前
Java,其他类
java·开发语言
雨中飘荡的记忆1 小时前
Drools规则引擎实战指南
java
曹牧1 小时前
Java:@SuppressWarnings
java·开发语言
她说..1 小时前
Spring Boot中读取配置文件的5种方式汇总
java·spring boot·后端·spring·springboot
ChrisitineTX1 小时前
双 11 预演:系统吞吐量跌至 0!一次由 Log4j 锁竞争引发的线程“集体猝死”
java·log4j