太棒了!我们在上一节成功把探店笔记推送到了粉丝的 Redis ZSet 收件箱里。现在,我们要解决 Feed 流架构中最容易出 Bug,也是秋招面试中 Feed 流架构必问的经典压轴题:如何正确地读取这些动态数据?
你可能会直觉地认为,用 MySQL 里常用的 LIMIT offset, size 或者 Redis 的 ZREVRANGE key start stop 查出来不就行了吗?但这在时刻变化的 Feed 流中,是一个巨大的灾难。
📚 实战篇 09. 好友关注 - Feed 流滚动分页查询思路 (原理篇)
一、 灾难重现:为什么不能用传统的分页?
传统的分页(也叫绝对角标分页)是基于**数据的下标(Index)**来查询的。
例如:一页查 5 条数据。
- 第 1 页:角标 0 ~ 4
- 第 2 页:角标 5 ~ 9
💥 致命缺陷:动态列表导致的数据重复/漏读Bug
假设你的收件箱里现在有 10 条动态,按时间倒序排好了(标号 10 是最新,1 是最老):
[10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
-
第 1 次请求: 你在 App 首页请求第 1 页,拿到了前 5 条:
[10, 9, 8, 7, 6]。此时你正在慢慢往下划屏幕看这几条动态。 -
突发情况(数据新增): 就在你看的时候,又有 2 个你关注的博主发了新动态(标号 12, 11)。此时 Redis 里的真实数据变成了:
[12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1] -
第 2 次请求: 你划到了底部,触发了加载第 2 页(请求角标 5 ~ 9 的数据)。
-
灾难发生: Redis 按照最新的列表去截取角标 5~9,拿到的数据变成了:
[7, 6, 5, 4, 3]。
你看出了问题吗? 动态 7 和 6 在第一页你已经看过了!现在第二页又出现了一次!这就是著名的**“Feed 流重复阅读 Bug”**。
二、 破局核心:滚动分页 (Scroll Pagination)
为了解决这个问题,我们必须抛弃“基于下标”的分页,改用**“基于游标(上一条数据的值)”的分页。这就是滚动分页**。
核心思想:
我不管前面新增了多少条数据,我只记住我上一次拿到的最后一条数据的值(时间戳) 。下一次查询时,我直接告诉 Redis:“给我查时间戳小于这个值的数据”。
- 第 1 次请求: 查最新的 5 条。记住最后一条的时间戳,假设是 。
- 第 2 次请求: 告诉 Redis,从时间戳 的地方开始,再往下查 5 条。
这样一来,无论顶部新增了多少条新数据,都不会影响我向下查找的参照物,完美避开了重复读取!
三、 基于 Redis ZSet 的落地方案
在 Redis 中,利用 ZSet 实现滚动分页,我们需要用到一个稍微复杂的命令:
ZREVRANGEBYSCORE key max min [WITHSCORES] [LIMIT offset count]
ZREVRANGEBYSCORE:按照 score(时间戳)从大到小(倒序)范围查询。max:本次查询允许的最大时间戳(即上一次查询的最后一条数据的时间戳)。min:本次查询允许的最小时间戳(一般填0,代表查到底)。offset:偏移量。这是本节最难理解的一个参数,稍后重点讲。count:本次查询要查几条数据(即 pageSize)。
四、 极端边界陷阱:相同时间戳引发的惨案(核心考点)
上面的思路看似完美,但在极端并发下,会隐藏一个天坑。
假设有两篇(甚至多篇)笔记,是在同一毫秒发布的,它们在 ZSet 中的 Score(时间戳)完全一样!
假设 Redis 里的时间戳排序如下(为方便阅读,用简短数字代表时间戳):
[10, 10, 9, 8, 8, 8, 7, 6]
- 第 1 次查询 (count = 3): 我们查到了
[10, 10, 9]。记下最后一条数据的时间戳:9。 - 第 2 次查询准备: 下一次查询,我们的
max参数填 9。 - 推演执行: 告诉 Redis
ZREVRANGEBYSCORE ... max=9 min=0 LIMIT offset=0 count=3。 - Bug 出现: Redis 会找到所有 的数据:
[9, 8, 8, 8, 7, 6],然后偏移 0 条,取前 3 条。结果拿到了[9, 8, 8]。
看到了吗?数据 9 又被重复读取了! 因为 max=9 是包含等于情况的。
🌟 终极解决方案:巧妙计算 offset
这里的 LIMIT offset 参数,在 ZREVRANGEBYSCORE 命令中的真正含义是:在满足 的所有元素中,跳过前面几个元素。
-
如何决定跳过几个?
规律就是:在上次查询返回的结果集中,最后那个时间戳出现了几次,下次查询的
offset就填几! -
正确推演:
- 第 1 次查到
[10, 10, 9]。最后的时间戳9在本次结果中出现了 1 次。 - 第 2 次查询参数:
max = 9,offset = 1。 - Redis 查找 的集合
[9, 8, 8, 8, 7, 6],跳过 1 个(刚好跳过了那个看过的 9) ,取 3 条。正确拿到:[8, 8, 8]! - 第 3 次查询准备:上次查到
[8, 8, 8],最后的时间戳8在本次结果中出现了 3 次。所以下次查询的参数就是:max = 8,offset = 3。完美!
- 第 1 次查到
五、 前后端交互参数设计
理清了上面极其绕的逻辑,我们就可以设计接口参数了。前端和后端需要互相传递这几个关键数据,才能让“滚动”接力下去:
1. 前端发给后端(请求参数):
-
lastId(即本次查询的max值):- 第一次请求时,前端传当前系统时间戳。
- 后续请求时,传后端上次返回给它的最小时间戳。
-
offset:- 第一次请求时,传
0。 - 后续请求时,传后端上次返回给它的偏移量。
- 第一次请求时,传
2. 后端返回给前端(响应结构体 ScrollResult):
List<Blog> list:本次查出来的探店笔记列表。minTime:本次查出来的列表中,最小的一个时间戳(用于前端下次当做lastId传回来)。offset:这个最小时间戳在本次列表中出现的次数(用于前端下次当做offset传回来)。
学习总结
“滚动分页”是解决信息流不断刷新时数据错位问题的唯一正解。
它考验的不仅仅是 Redis 命令的熟练度,更是极其缜密的边界逻辑推演能力(尤其是处理时间戳完全相同时的 offset 计算)。如果你能在后续的秋招面试中把 offset 的动态计算逻辑跟面试官讲清楚,绝对是一个巨大的加分项!