场景:朋友圈动态流
假设我们有一个朋友圈应用,用户关注了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)
返回给客户端
第二次请求(滚动加载)
客户端使用第一次返回的 minTime 和 offset:
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) > ...
用户的浏览体验:
-
用户已看完第一页:1005, 1004, 1003
-
用户继续滚动,加载第二页:1002, 1001, 1000
-
新发布的1010不会影响已浏览的内容,因为它插入在1004之前
-
用户下次刷新时,会从最新开始重新加载,看到新内容
关键理解点:
-
为什么用时间戳作为score?
-
时间戳天然有序,支持范围查询
-
可以精确到毫秒,基本不会重复
-
-
offset的作用是什么?
-
处理同一时间戳的多条数据
-
记录在同一时间点已经取了多少条
-
-
与传统分页的区别:
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 // 基于最后一条的时间戳查询,不受新数据插入影响 -
适用场景:
-
朋友圈、微博等动态流
-
聊天记录翻看
-
任何需要"不断加载更多"且数据实时更新的场景
-
通过这个完整示例,你应该能清楚理解滚动分页的工作原理和实际应用了!