资讯动态

Redis Feed流分页稳定方案:ZSET游标与缓存边界实战

发布时间:2026/10/9 8:17:59 来源:尧图企业网站定制
做信息流或者说 feed 流开发的朋友应该都有过这种经历需求听上去很简单——“下拉加载更多上拉刷新”但真正用 Redis 实现分页的时候各种重复、乱序、丢数据的问题就开始往外冒。尤其当用户量上来、新内容还在不断写入时传统的分页思路在 feed 流这个场景下几乎处处碰壁。我上周刚处理完一个线上问题用户连续刷新三次第一次看到的第五六条内容第二次刷新后居然又原封不动出现在第一条第三次直接跳过了好几条没看过的动态。排查到最后根因就是 Redis 分页实现时对动态数据源的处理考虑不足。这篇文章不聊概念直接聊怎么在 Redis 上把 feed 流分页做稳。我会从 Redis 在 feed 流里的职责边界开始逐步拆解为什么常规分页会翻车、ZSET 游标分页的正确打开方式、时间戳排序里的坑以及缓存边界和数据兜底策略。适合正在做信息流后端、或者准备用 Redis 做 timeline 存储的工程师参考。1. 先把场景聊透Redis 在 feed 流里到底管哪一段很多人一上来就开始写 ZREVRANGEBYSCORE但连自己面临的问题域都没理清楚。feed 流分页之所以难不只是 Redis 命令选型的问题而是数据模型、写入频率和读取方式共同决定的。1.1 feed 流数据模型的两条路推模式与拉模式feed 流最常见的两种实现模型是推push和拉pull大部分产品还会用推拉结合。推模式里你关注的作者发了新内容系统会把这个内容写入你每个粉丝的收件箱——这个收件箱在 Redis 里通常就是一个 ZSETmember 是 feedIdscore 是发布时间。拉模式则是你发起请求时去你关注的作者列表里逐个拉取对方的最新内容再合并排序。分页问题的复杂度和模型直接挂钩。推模式的好处是读取极快一条 ZREVRANGEBYSCORE 就能把最新内容捞出来但代价是写入放大拉模式反之读取时涉及多个作者的归并分页必须在归并之前或归并之后做逻辑上复杂得多。Real-world 里绝大多数业务用的是推模式或推拉结合本文讨论的分页方案也主要基于“收件箱已经存在 Redis”这个前提。如果你连收件箱都没建那第一步就不是分页而是先把 feed 写入模型定下来。这里我默认读者已经搞定了“feed 如何进 Redis”重点放在“进去之后怎么翻”。1.2 缓存与存储的边界Redis 不该承担全量持久化在开始设计分页前必须先给 Redis 画一条职责边界。Redis 是一个高性能缓存/内存数据源它可以做 timeline 的快速读取层但让它承担全量历史数据的持久化存储是很多人后期成本爆炸的根源。我见过一个团队把用户过去三年的全部 feed 都放在一个 ZSET 里单个 key 的 member 数量到了几百万一个 ZREVRANGEBYSCORE 命令都能耗时几十毫秒而且因为内存占用过高频繁触发淘汰策略最老的 feed 被不明不白地踢掉。用户翻到两年半以前的动态时分页结果直接断档。所以务实的方案是Redis 只保留“用户近期可翻看的 feed 窗口”比如最近 500 条或者最近 7 天。超过这个范围要么直接返回“没有更多了”要么 fallback 到 Cassandra/MySQL 之类的历史存储。分页方案里窗口之外的逻辑可以单独设计不要和 Redis 内的分页混在一起。否则设计出来的分页永远要考虑“key 里数据不全”这个幽灵怎么优化都会有边界漏洞。1.3 分页问题会在哪个阶段突然暴雷多数团队不是一开始就遇到分页问题的。早期用户量小、内容写入不频繁用最朴素的 pageNum/pageSize 也能跑。问题一般在两个节点突然爆发一是用户关注列表变大收件箱写入频率上升新数据开始“顶掉”分页位置二是 UGC 内容突然出现一波热点一个作者短时间内连发多条前面几条刚被用户翻过后面新的又插进来。一旦这两件事同时发生你就知道为什么传统分页在 feed 流里活不下去因为 feed 流的分页查询面对的不是静态数据集而是一个不断被 append 的动态队列。接口每次访问时数据集都在变化偏移量自然失去意义。下面我详细拆这个原因。2. 为什么传统分页在 Redis feed 流里必坑偏移量的死穴老生常谈的“limit offset 深分页慢”在 Redis 里其实不是最主要的问题——Redis 的 ZRANGE 命令本身就支持按索引范围取值取深分页也不是不能做真正致命的是 offset 和动态数据的冲突。2.1 传统分页的隐含假设是“静态快照”MySQL 的分页mybatis 类框架自带的分页插件同理隐含了一个假设查询的数据集在一段时间内是稳定的。你执行 LIMIT 10 OFFSET 20拿到第 21 到第 30 条等你请求第二页的时候你期望第一页的 10 条还在原来的位置第二页接着往后取。这个假设在 feed 流里不成立。用户刷 feed 的几秒内关注的人可能刚好发布了三条新内容。新内容按 score 排序后插入到 ZSET 头部所有已有元素的位置整体向后移动三位。此时你用同一个 offset 再取下一页取到的是被顶掉三位之后的内容——上一页已经看过的、或者中间漏掉的内容就混进来了。这里有个关键点页面上用户感知的是“重复”和“漏条”但在代码层面本质是数据集版本不一致。每次分页请求相当于一个快照但 Redis 里根本没有“快照”的概念ZSET 永远是当前时刻的最新状态。2.2 ZRANGE 索引分页在低并发下表现的迷惑性这里必须承认ZRANGE 按索引做分页并不是完全不能用。如果你的数据写入频率极低比如每隔几小时才有新内容那么索引分页几乎不感知偏移量变化。我接手过一个企业内部公告系统每天发布两三条公告用 Redis List 加 LRANGE 做分页跑了一年都没出问题。但用户向的 feed 流则不同——头部不断有新数据插入尾部不断有旧数据被淘汰或过期删除。ZRANGE 索引分页遇到的系统性问题不只是“慢”而是偏移量被动态数据攻击。头部插入会让偏移量失效尾部删除会让总长度不稳定还会出现“上一页的最后一条变成下一页的第一条”这种经典的重复问题。所以判断自己能不能用 index/offset 分页不是看数据量而是看写入频率。一个静态数据集哪怕一百万元素用 ZRANGE 分页也完全可行一个每秒写入几百条的数据集哪怕只有几百个元素offset 分页也照样翻车。一句话feed 流的核心特征是“随时有增量”只要增量存在就必须放弃“位置”这个概念改用“锚点”的概念。2.3 翻车现场的三种典型症状结合我排过的故障offset/索引分页在 feed 流里翻车通常有下面三种表现。第一种是重复条目。用户向下翻页看到几条刚刚在第一页出现过的内容。原因是刷新动作或并发请求导致写入了新数据ZSET 头部前插旧内容整体后移第二次请求的 offset 指向了旧内容的中间部分。第二种是跳读。比重复更隐蔽。因为两次请求之间恰好插入的数据量不超过一页所以 offset 仍然能“大概对准”但会跳过一两条新插入的内容——用户永远看不到这几条除非下拉到很后面再往前回溯。这个最气人因为用户明明是新数据却因为分页机制被静默丢弃。第三种是分页越翻越乱。随着翻页次数增加每次请求相隔越久期间插入的数据越多offset 偏离真实位置越严重。最后端到端的顺序几乎不可预期甚至出现用户看到的内容在时间上忽前忽后、逻辑奇乱的体验。这三种情况我都线上遇到过排除掉数据写入 bug 之后发现根因全部是同一个把“位置”当作稳定的分页依据。修复方式只有一个——换游标。3. 用 ZSET 游标做分页锚点语义替代位置语义游标分页的原理不复杂不告诉 Redis “给我第 N 页”而是告诉它“给我从某个元素之后的数据”。这个元素就是你上一页的锚点也就是游标。3.1 为什么要用 ZSET 而不是 List实现游标分页存储结构首选 ZSET。很多初学者会用 List 存 timelineLPUSH 新内容LRANGE 分页看起来简单直接。但 List 只支持按位置访问不支持按成员定位想实现“从某条 feed 之后开始取”非常别扭——你得知道那条 feed 在 List 里的 index而这个 index 又会被新写入的数据持续改动等于又回到了 offset 的老路。ZSET 的核心优势是每个 member 都有一个 score你可以直接以 “score member” 作为锚点使用 ZREVRANGEBYSCORE 或 ZRANGEBYSCORE 按范围取值。新数据的插入只会改变顺序不会改变锚点本身的有效性。这就是语义上的根本差异位置是相对的锚点是绝对的。3.2 游标设计的关键公式score 加 member 双条件定位实践中最容易踩的坑是只拿 score 当游标。比如上一页最后一条的 score 是 1680000000000下一页就用 score 1680000000000 去取。这个方案在“每个人发布时间的毫秒数都唯一”时是安全的但现实中同一毫秒发布多条内容完全可能。一旦两条 feed 具有相同 score且恰好横跨分页边界score 单一条件会导致漏数据或者重复。正确姿势是双条件score 小于上一页最后一条的 score如果 score 相等则 member 的字典序严格小于最后一条的 member。这样即使 score 重复也能精确锚定到具体那一条既不会漏也不会重。命令上ZREVRANGEBYSCORE 需要配合 ZRANGEBYSCORE 的边界处理或者直接使用 ZRANGE ZSET key 0 (lastScore 这种带排他括号的语法。Lettuce/Jedis 的命令签名略有差异建议先把 Redis 命令行的 behavior 摸清楚再写代码。3.3 完整实现Java RedisTemplate 的 ZSET 游标分页这里给一份可以用在生产环境的 Java 实现。假设 key 为feed:timeline:{userId}member 为 feedIdscore 为发布时间。代码用 RedisTemplate 操作 ZSETpublic class FeedTimelineService { Autowired private StringRedisTemplate redisTemplate; private static final int PAGE_SIZE 20; public ListFeedVO pageCursor(Long userId, Long lastScore, String lastFeedId, int pageSize) { String key feed:timeline: userId; pageSize pageSize 0 ? PAGE_SIZE : Math.min(pageSize, 50); // 首次请求直接从最大 score 开始取 if (lastScore null || lastFeedId null) { SetZSetOperations.TypedTupleString firstPage redisTemplate.opsForZSet() .reverseRangeByScoreWithScores(key, 0, Long.MAX_VALUE, 0, pageSize); return convertTuples(firstPage, true); } // 非首次请求双条件游标定位 SetZSetOperations.TypedTupleString nextPage redisTemplate.opsForZSet() .reverseRangeByScoreWithScores(key, 0, lastScore, 0, pageSize 1); // 注意这里需要手动剔除 score lastScore 且 member lastFeedId 的元素 ListZSetOperations.TypedTupleString filtered new ArrayList(); for (ZSetOperations.TypedTupleString tuple : nextPage) { if (tuple.getScore().longValue() lastScore tuple.getValue().compareTo(lastFeedId) 0) { continue; } filtered.add(tuple); if (filtered.size() pageSize) { break; } } return convertTuples(filtered, false); } private ListFeedVO convertTuples(SetZSetOperations.TypedTupleString tuples, boolean isFirstPage) { // 转换为业务对象并记录最后一个元素的 score 和 feedId 作为下次游标 } }需要说明的是reverseRangeByScoreWithScores 的语义是包含边界的score 落在 0 到 lastScore 之间所以必须对等于 lastScore 的成员做二次过滤。如果不想在代码里手动过滤也可以用 Redis 6.2 之后对 ZREVRANGEBYSCORE 扩展的更丰富边界语法(表示开区间但为了兼容性我建议还是代码层过滤最稳。3.4 双向滑动场景下的游标处理feed 流不只有向下的“加载更多”还有上拉时的“刷新最新”。下拉加载更多用反向游标取更小的 score上拉刷新则需要取最新数据——也就是 score 大于当前游标的那些内容。很多人刷新时会重新拉第一页直接把旧列表清空这会导致两个问题一是用户可能正在看 A 文章刷新后被强制跳回顶部体验断裂二是如果新旧数据合并首页内容大量重复。推荐的刷新策略是保留当前列表把“大于当前第一条 feed score 的新内容”插到列表头部而不是全量重拉。实现方式就是 ZREVRANGEBYSCORE key inf (currentTopScore请注意开区间语义。这个操作只增量获取新增部分前端做数组头部插入既避免重复又保住阅读位置。4. 排序稳定性时间戳背后的暗坑与相对时间设计ZSET 的 score 常用时间戳表示很多系统的第一版都直接用System.currentTimeMillis()看起来天经地义。但实际跑起来会出现一个不被重视的稳定性问题——服务器时钟回拨。4.1 多实例时钟回拨导致的排序瞬间错乱分布式部署下不同应用服务器各自的本地时钟可能不一致甚至同一台机器会在 NTP 同步时出现毫秒级回拨。用户 A 发了一条 feed写入收件箱时取得的是应用服务器本地时间如果这台机器恰好比另一台慢了 200ms这条 feed 的 score 就可能比它实际更晚时刻的内容还小导致排序错乱。有人会说“用 Redis 的 TIME 命令获取时间作为 score 来源”。这个思路方向对但要注意TIME 命令返回的是 Redis 服务器时间如果所有应用都去请求同一台 Redis 获取时间能规避多实例时钟差异不过凭空增加了 RTT如果只取一台主节点时间写入数据都经过主节点问题不大但注意这是“读取时统一时钟源”的逻辑不是“写入时由 Redis 动态分配 ID”。实战中更稳妥的替换方案是用单调递增序列替代裸时间戳做 score。比如用 Redis INCR 维护一个全局的feed:seq每产生一条新 feed 就 INCR 一次然后 score seq。这样可以保证严格有序、不回拨代价是 seq 本身只能表达先后顺序不能表达具体时间。如果你还要按时间维度做范围查询那 seq 也可以换成“毫秒时间戳 序号”组合即高位存秒级时间戳低位存该秒内的自增序号让 score 既稳定又可排序。4.2 同一秒内多条 feedscore 撞车怎么办前面说过 score 相同时必须用 member 作为第二排序键这里再展开一下这类碰撞的常态化。在高峰期内同一毫秒发布多条内容其实很常见不能指望时间戳天然唯一。ZSET 的排序规则是score 不同时按 score 排score 相同时按 member 的字典序排。所以如果相同 score 的 member 能构造出合适的字典序排序其实也不会乱。但问题在于member 的业务含义通常是 feedId而 feedId 的生成策略很可能不是时间有序的。比如 feedId 是 UUID 或者自增主键转字符串后者相对可控前者则完全随机。当相同 score 的一批 feed 顺序取决于随机 UUID 的字典序那用户看到同一秒钟发布的几条动态时顺序就不可控了。稳妥做法是member 不要直接用业务 ID而是用{中心化序列号}:{feedId}这种组合字符串。中心化序列号用 Redis INCR 或雪花算法生成能保证同批次创建时的顺序尽可能接近业务真实顺序。这样即使 score 相同member 字典序也大致等于创建顺序不会随机乱跳。4.3 时间窗口截断只缓存近期数据时的边界实现前面我建议 Redis 只存近 7 天或近 500 条。用 ZSET 实现这个窗口有两种方式一是懒惰淘汰即每次写入时顺手 ZREMRANGEBYSCORE 去掉太老的成员二是定时任务定期清理。前者更平滑不占用单独任务资源但在写入量非常大时有额外开销后者实现简单但清理间隔期内 Redis 数据会缓慢膨胀。窗口截断对分页的影响在于如果用户持续往下翻翻到窗口边界之后ZREVRANGEBYSCORE 取到的结果集会越来越少最后为空。此时业务上就应该判断“没有更多了”不要再去请求更早的数据。这里建议代码里加上一个显式的边界判断当返回结果不足 pageSize 时直接认为分页结束避免无意义的全表扫描和无效请求。4.4 时间维度与后续业务的扩展性虽然有各种方案替代裸时间戳但很多后续业务比如“查看我某天发布的动态”仍然需要时间字段。所以不要把 score 构建成一个完全无业务含义的黑盒建议在 feed 的业务实体中单独维护一个 publishedAt 字段。score 用于排序publishedAt 用于展示和筛滤两者职责分离。这个设计能让你后续做聚合、统计和报表时不必再解构 ZSET score。5. 缓存边界与数据兜底分页遇到未命中怎么办游标分页解决了动态数据集的分页稳定性但它有一个前提你要分页的数据必须在 Redis 里。到了这一步缓存未命中的问题就浮出水面了。5.1 feed 数据没进 Redis穿透分页的三种来源feed 流里 Redis 通常只缓存了一部分内容。这里最容易出的错是分页查询命中了 ZSET但 member 对应的 feed 详细内容没在缓存里。ZSET 只存了 feedId 列表真正的 feed 消息体存在另一个缓存 key 里比如feed:detail:{feedId}。刷新之间新写入的 feed 可能还没构建详情缓存旧 feed 的详情缓存又因过期而被清理。这时分页结果集会返回一批“有 ID 无详情”的条目前端展示就会裂开。这个问题的本质是分页逻辑依赖两个缓存层次收件箱索引 feed 详情两者的生命周期和写入时序必须对齐。常见解法有三种第一种是惰性回填分页返回前发现某个 feedId 的详情不存在立即从数据库加载并回填缓存保证请求响应完整第二种是批量预热用户刷新最新页时把头部若干个 feedId 的详情提前构建进缓存第三种是DB 兜底查询直接查数据库补全适用于回填成本高或冷数据场景。5.2 布隆过滤器与空值缓存在深分页防穿透中的作用如果 Redis 只存了最近 500 条用户一直往下翻到了第 501 条时无论怎么查都不会命中 Redis。此时如果直接放行到数据库最坏情况是每个 feedId 都打一次 DB分页深度越深、并发越高数据库压力线性爆表。这里有两个可以叠加的防穿透手段。一是空值缓存业务上知道 Redis 下标分页窗口之外的内容属于“历史数据”可以直接返回空列表并设置一段短时间的标记缓存避免前端因为某种异常循环狂翻导致每次都穿透。二是布隆过滤器针对“用户请求的 feedId 是否存在”做前置判断不存在直接短路不需要访问任何存储。这两种方案互补前者挡的是逻辑上的边界请求后者挡的是随机、恶意的 feedId 穿透。5.3 DB 兜底的游标语义要保持一致真正需要查历史数据时DB 里的分页不能再用 offset 了——否则你在 Redis 里精心设计的游标语义到了 DB 又退化回 offset 语义同样会重蹈覆辙。DB 层也应该用“WHERE created_at lastTime OR (created_at lastTime AND id lastId) ORDER BY created_at DESC, id DESC LIMIT pageSize”这种游标写法。这里要特别提醒Redis ZSET 的双条件游标和 DB 的游标查询之间分页参数必须保持同构。也就是说从 Redis 翻到 DB 时传给 DB 的下一次游标应该是 Redis 最后一条 feed 的 publishedAt 和 feedId而不是重新生成一个新的分页条件。否则两套分页体系各自为政用户跨过缓存边界时会遇到一次明显的顺序跳变。5.4 一致性写入顺序与缓存顺序之间的小时间窗最后说一个大家都在做但常常没做干净的事feed 写入缓存和收件箱 ZSET 写入之间的顺序。如果一个 feed 先写了详情缓存、再写收件箱索引那么在极端情况下索引 ZSET 里还没有这个 feedId但详情已经可被其他接口查询。反之如果先写索引再写详情那么分页可能返回一个详情尚未就绪的 feedId触发穿透。我的做法是把两个写操作放进同一个事务边界或者在逻辑上保证“先详情后索引”并在分页读取时对详情缺失做兜底回填。Redis 本身不支持跨 key 的原生事务一致性MULTI/EXEC 能保证原子性但不保证可读性所以要靠应用层的顺序设计去减小交换窗。做这一步不是为了彻底消灭竞态而是把出错概率压到业务可接受的范围。6. 生产环境中的真实踩坑记录游标健壮性与客户端联动方案聊到最后分享一下我自己线上压测和故障排查中踩过的一些具体坑。这些细节不在官方文档的示例里但往往决定方案能否真正落地。6.1 游标参数的防御NaN、负数和超大值ZSET 的 score 是 double 类型而时间戳范围通常在 1e12 的量级double 完全够用但游标参数如果来自客户端请求就可能被恶意或意外地传成奇怪的值。比如score NaN某些驱动对这个值处理不严谨会抛异常或产生未定义行为。score -1直接查到了 timeline 的最小值边界可能一条都查不到空列表被误判为“没有更多”。score Long.MAX_VALUE如果业务将来有更大时间戳边界会失效。我建议在入口处做统一校验score 必须在合法时间范围内比如大于 0 且小于当前时间 24h 的容差member 不能为空。不要相信任何客户端的游标参数的合法性否则一个异常游标就可能导致查询风暴。6.2 多客户端时间基准不一致A 端和 B 端的游标互不兼容如果同一个用户同时在手机 App 和网页端访问 feed两个端各自维护游标。App 端返回的游标可能在网页端被重新解析——如果两端的时间基准不同比如一个用服务器返回的时间戳一个用本地拼接的时间戳则会出现游标不兼容。经验是游标参数必须完全由服务端生成、服务端解析客户端只做透传不参与任何加工。尤其是“时间戳”这种字段绝对不要让客户端在返回游标时重新加个new Date()之类的本地时间。我见过一个事故就是前端为了做埋点给游标里附带了本地毫秒数后端解析游标时优先用了这段本地时间导致很多用户的分页顺序直接紊乱。6.3 并发写入对单次分页请求的影响你可能觉得“游标稳定了就不用担心并发”但实际上并发写入还是会影响同一时刻发出的多个分页请求用户快速滑动时前端可能同时发出两个请求一个刷新、一个加载更多。此时 Redis 里可能同时发生写入和读取。ZREVRANGEBYSCORE 是单命令原子性没有问题但两个请求获取到的结果集是不同时刻的快照前端合并顺序时可能出现新旧穿插。我的处理方式是前端对同一方向的请求做串行化加载更多进行中时忽略新的加载更多请求服务端对游标做递增校验——不允许用旧游标去查询“更新的数据”。这样即使客户端多发了请求服务端也能拦截住逻辑上已经过期的操作。6.4 最终方案选型参考一张表说清适用场景做一个简化版对比方便读者结合实际选型。方案典型命令适用场景不适用场景风险点ZRANGE 索引分页ZRANGE key start stop静态或低频写入高写入 feed 流偏移量被新数据顶掉List LRANGELRANGE key start stop公告、秒杀库存、简单队列活跃用户 timeline无法按成员定位游标ZSET 游标分页ZREVRANGEBYSCORE / ZRANGEBYSCORE高写入 feed 流标准方案跨页深度过大且无窗口截断双条件游标实现复杂度高DB 游标分页WHERE created_at ? AND id ?历史数据兜底查询Redis 内索引完整时需与 Redis 游标保持同构真实的高并发 feed 流基本就是“Redis 游标 DB 游标兜底”两条路中间用一个缓存窗口衔接。窗口内的数据用 ZSET 游标读窗口外的历史数据走 DB 的游标 query二者在语义上保持一致用户是无感的。7. 从分页到整个 feed 链路的思考缓存、更新与兜底的一次性设计文章快收尾了但我觉得有必要再多说一层分页从来不是独立的它和 feed 流的写入、缓存更新、DB 持久化是同一个系统的多个切面。如果你只是在某个角落修补分页逻辑没有把整个链路摆在同一张设计图上整改完这个月下个月还会在别的地方冒出新问题。我现在的习惯是每做一个 feed 流项目先画一张数据流图用文字描述也行把“业务写入 → 收件箱更新 → 详情缓存构建 → 分页读取 → 历史数据兜底”这条链路完整走一遍。分页只是这条链路上的一个读者视角。只要写入端、缓存端和读取端的顺序能对齐分页自然稳定。具体来说我会关注三个夹缝第一收件箱 ZSET 的写入延迟和投递失败补偿第二详情缓存的生命周期不能比收件箱索引短太多第三用户删 feed 或取消关注时的索引清理逻辑——这些和分页一样都是 feed 流稳定性的关键环节。这是我做了几年信息流后端之后最核心的心得不要孤立地去修分页而是把分页当作整个 feed 链路健康度的一个探针。分页一旦出现重复、跳条、漏读往往不是因为分页代码本身写错了而是链路里有某个环节没对齐。把链路理顺分页只是水到渠成。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑