多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Redis分页查询实战:List、Sorted Set与游标设计全解析

Redis分页查询实战:List、Sorted Set与游标设计全解析 第一次被问到“Redis 怎么做分页查询”的时候我就能猜到提问的人之前主要在用关系型数据库。Redis 没有 SQL更没有SELECT ... LIMIT OFFSET它开放的是一系列原子操作命令。但这不意味着 Redis 不适合做分页而是要把思路从“让它查出来”转成“用什么命令把需要的区间取出来”。本篇文章我会结合真实业务场景把 List、Sorted Set、Set 等数据类型的分页姿势、游标设计、代码示范和排错经验一次说清楚适合正在做 feed 流、排行榜、订单列表分页缓存或者准备系统梳理 Redis 命令的开发者阅读。1. 思路先行Redis 没有 SQL分页是数据模型设计的问题1.1 先回答最基础的问题为什么 Redis 不能直接“分页查出来”很多人会用“MySQL 分页就是 LIMIT 20 OFFSET 100”的逻辑去套 Redis然后发现怎么都实现得别扭。原因是 Redis 的命令设计目标不是“查询复杂条件”而是“对某一种数据结构做极快操作”。比如 List 只适合从两端存取Hash 适合按字段存取Set 适合做成员去重和集合运算Sorted Set 适合按分数排序。分页这件事本质上是在“有序数据”上取一段窗口所以你要自己决定用哪种结构承载数据。Redis 真正没有封装的是“先筛选条件再排序再取区间”这套 SQL 语义。业务里常见的分页不仅要知道第一页十条还要知道总数、上一页下一页、按时间排序、按权重排序这些语义都必须由数据结构和命令组合出来。所以我把开场确定成一句经验先想清楚页面需要“按什么顺序展示数据”再选 Redis 数据类型最后才谈命令怎么写。1.2 选型对照四种数据类型在分页场景里的角色最合适的做法是用表格把数据结构、排序能力、分页命令、适用场景串起来数据类型是否有序核心分页命令适合场景List有序可重复LRANGE固定顺序列表如回复列表、通知列表Sorted Set有序按 score 排序ZREVRANGE / ZREVRANGEBYSCORE排行榜、按时间倒序的 feed 列表Set无序SSCAN 游标遍历超大集合的增量扫描、离线抽数Hash字段无顺序不适合直接分页只适合按 ID 批量取对象详情List 是最容易理解的方案你用LPUSH往左边推入新数据一组数据就自然按时间倒序排列再用LRANGE key 0 9就能拿到第一页十条。但 List 的分页严格依赖插入顺序如果业务需要“按分数重新排序”或者“按时间区间过滤”就完全不够用。Sorted Set 是我在高并发列表里最常用的类型。它用分数 score 决定成员顺序业务可以把时间戳、权重值、综合得分直接塞进 score。分页时用ZREVRANGE key start stop或ZREVRANGEBYSCORE key max min取区间既能实现第一页到第 N 页的跳页也能通过范围限制实现游标分页。Set 一般不用在常规分页展示上因为它没有顺序。但如果你的页面数据是“去重后的大批历史记录”需要定期遍历处理我会用SSCAN而不是SMEMBERS。这个区别很重要后面会详细讲。Hash 和分页的关系是间接的。常见做法是“用 List/Sorted Set 存 ID用 Hash/MGET 批量取对象详情”而不是直接在 Hash 里做分页。如果直接把整个对象塞进 List翻页时只能拿到一串完整对象想按某个字段排序只能靠重新排序命令效率很低。2. 偏移量分页LRANGE 与 ZREVRANGE 的正确打开方式2.1 List 切片分页最直接也最容易低估的复杂度如果数据量不大分页用 List 是最简单的。假设我们维护一个用户消息列表LPUSH msg:user:10001 msg:9003 LPUSH msg:user:10001 msg:9002 LPUSH msg:user:10001 msg:9001 LPUSH msg:user:10001 msg:9000第 2 页每页 10 条页码从 1 开始那么起始偏移是(2 - 1) * 10 10结束位置是10 10 - 1 19LRANGE msg:user:10001 10 19LRANGE命令的 start 和 stop 都是闭区间。这里有个常见失误有人会把 stop 写成start pageSize也就是LRANGE key 10 20一次会多取一条。正确写法是LRANGE key 10 19。用 List 做分页最大的性能隐患不是命令本身而是偏移量。Redis 的 List 底层是链表和压缩列表组合实现单独的LRANGE读取复杂度是 O(N)这里的 N 不是返回条数而是截取区间长度。列表只有几百条时感觉不到一旦有几万条数据用户翻到第 5000 条Redis 就要扫描整个区间单线程模型下可能引发明显延迟。我自己的判断标准是如果列表长度不超过 1 万用 List 做偏移量分页没问题如果列表会持续增长同时旧数据几乎不删除就要考虑只保留最近 N 条或者干脆换 Sorted Set。2.2 Sorted Set 的 ZREVRANGE有序列表分页的默认方案Sorted Set 比 List 更适合做分页因为它不依赖插入顺序而是依赖 score。业务数据通常希望新的排在前面所以我把时间戳直接作为 scoreZADD feed:user:10001 1698566400 article:101 ZADD feed:user:10001 1698566460 article:102 ZADD feed:user:10001 1698566520 article:103ZADD写入时score 越大的成员排名越靠后。分页要最新内容应该倒序取# 第一页 10 条 ZREVRANGE feed:user:10001 0 9 # 第二页 10 条 ZREVRANGE feed:user:10001 10 19这里还要提一个容易被新人忽略的细节score 相同时Sorted Set 会按 member 的字典序做二次排序。ZREVRANGE对相同 score 的成员会按字典序反向排序如果 member 是文章 ID顺序看起来就是随机的。你第一页和第二页之间可能因为新增数据插到相同 score 区间导致同一条数据被翻出来两次。解决稳定排序的方法有两种。第一是让 score 尽量唯一比如用时间戳 * 10000 自增序号合成一个复合分。第二是在排序结果里加入时间字段人为制造唯一性。第二点会在游标分页部分展开讲。Redis 6.2 之后ZRANGE命令增加了REV、BYSCORE、BYLEX、LIMIT等参数旧命令ZREVRANGE依然能工作两者语法有差异。比如新版可以直接写ZRANGE feed:user:10001 0 9 REV含义等同于ZREVRANGE feed:user:10001 0 9。如果生产环境 Redis 版本还在 6.2 以下依然用老命令最稳。2.3 用 ZRANGEBYSCORE / ZREVRANGEBYSCORE 做区间 LIMIT 分页有时候业务需要的不只是简单翻页还要带上时间范围。比如“查看 7 天内的动态每页 100 条”。Sorted Set 的命令正好能表达这个语义# 取 score 在 [开始时间, 结束时间] 区间内的数据从第 0 条开始拿 100 条 ZRANGEBYSCORE feed:user:10001 1697966400 1698566400 LIMIT 0 100倒序写法则是ZREVRANGEBYSCORE feed:user:10001 1698566400 1697966400 LIMIT 0 100注意ZREVRANGEBYSCORE的参数顺序是“最大值在前最小值在后”这和习惯的min max相反非常容易写反。我在一次联调里就把范围传反了导致页面拿到的是历史最旧的数据。LIMIT offset count中的 offset 是区间内的偏移不是全局偏移。如果你只想做时间窗口分页这个命令非常好用但如果窗口特别大offset 非常深复杂度同样不是常量时间。所以深翻页场景依然要避免用偏移量而应该用游标。3. 游标分页解决深翻页、大数据集、实时数据变动3.1 为什么偏移分页不适合高频实时列表偏移分页的问题在于两次翻页之间列表数据可能发生变化。比如用户在第一页看到的文章 ID 是 10001 到 10010当他翻到第二页时有人新增了三条数据新数据被LPUSH进列表原来的第 10011 条被挤到了后面。这时第二页的起始位置 10 其实指向了之前的另一条数据用户会感觉某些内容被重复或者跳过了。如果是资讯类页面这个感受不明显但如果做的是实时榜单、直播间评论、商品秒杀名单偏移量分页会带来严重的体验问题。这时候应该把“上一页的最后一条数据位置”记下来下一次查询从那个位置继续。这个位置信息就是游标。Redis 里的游标分页通常有两种一种基于 Sorted Set 的 score一种基于 Sorted Set 的 member score 组合。业务列表凡是需要倒序展示我都优先用 score 游标。3.2 时间戳/分数游标的实现细节——同分值的连续性难点假设我们把文章发布时间转换为毫秒时间戳写入 Sorted SetZADD hot_articles 1698566400123 article:10001 ZADD hot_articles 1698566460456 article:10002第一页请求使用ZREVRANGEBYSCORE hot_articles inf -inf LIMIT 0 20客户端记录这 20 条里最后一条的 score比如是1698566400123。下一页请求就变成ZREVRANGEBYSCORE hot_articles (1698566400123 -inf LIMIT 0 20这里的关键是(1698566400123它表示“不含 1698566400123 本身”也就是不带上一条数据。如果直接写1698566400123下一页会包含上次最后一条出现重复。这个方案最大的坑是同一毫秒内可能有多篇文章拥有相同的 score。比如两条文章都是1698566400123你用(1698566400123做上界会把另一条同 score 文章丢掉。很多人在线上遇到“漏数据”基本都是这个原因。我的解决方法是构造复合分数让 score 尽量唯一。常见做法是score 时间戳 * 100000 自增序列号。自增序列号可以用 Redis 自身的INCR生成也可以由业务 ID 取模。这样新的文章插入时哪怕时间戳相同序列号不同score 也不同游标就能精确定位。这正好也呼应了热搜词里出现的“redis生成递增号”在分页设计里递增号不只是一个普通的编号它可以作为分数稳定性的一部分。还有一种更稳健的设计是把member设计成article:10001这样的唯一字符串客户端保存(score, member)两个值下一页查询时既排除分数也排除字典序。但实现会复杂业务量不大时我一般采用复合分数就够了。3.3 SSCAN 游标遍历处理无序大集合的分页前面说的是有序分页还有一种常见需求是对 Set 做大集合遍历。举个例子我们需要处理 100 万用户 ID 集合里的历史数据一次扫描全部取出来必然影响 Redis 性能这时可以用SSCAN。SSCAN user:all 0 COUNT 500第一次执行返回两个结果一个是下一次要用的游标另一个是返回的成员列表。客户端拿返回的游标继续执行SSCAN user:all 291 COUNT 500直到返回的游标变成 0表示遍历完毕。注意COUNT 500只是给 Redis 一个提示不是严格保证每批返回 500 条。大集合或者正在写入的集合返回数量和预估值可能差异很大。SSCAN不像LRANGE那样能明确指定第几页它只适合“批量遍历”不适合面向用户的分页。我经常把它用在离线任务、数据统计、缓存预热里很少直接给接口做展示数据。如果业务真的需要无序分页还是建议先按 ID 建 Sorted Set或者把数据导入分析型存储。4. 代码实战Java、Python 两个版本的实现4.1 Java 用 RedisTemplate 实现 ZSet 分页常见的 Java 项目中使用 Spring Data Redis 的场景我会用RedisTemplate操作 Sorted Set。这里按常见配置说明假设 key 序列化使用StringRedisSerializervalue 序列化使用兼容的 JSON 方式。第一段代码是偏移量分页适合浅翻页场景public PageResultString pageByOffset(String key, long page, long size) { long total redisTemplate.opsForZSet().zCard(key); long start (page - 1) * size; SetString ids redisTemplate.opsForZSet() .reverseRange(key, start, start size - 1); return new PageResult(total, ids); }这段代码的要点是reverseRange(key, start, start size - 1)它对应ZREVRANGE key start start size - 1。如果写成start size会把下一页第一条提前捞回来。第二段代码是 score 游标分页适合 feed 流和实时榜单public ListZSetOperations.TypedTupleString pageByCursor( String key, double lastScore, long size) { return redisTemplate.opsForZSet() .reverseRangeByScoreWithScores( key, lastScore, Double.NEGATIVE_INFINITY, 1, size); }这是我的示意写法。实际项目里要依据 Spring Data Redis 版本确认方法签名但核心逻辑一致从lastScore开始往下取size条。使用游标时我通常会把上一页最后一条的score和member同时返回给前端防止同分数据漏掉。4.2 Python 用 redis-py 快速验证分页与游标日常做脚本验证我更喜欢用 redis-py。连接方式就是热搜词里常见的“python连接redis”import redis r redis.Redis(host127.0.0.1, port6379, db0, decode_responsesTrue) KEY hot_articles page 1 size 10 start (page - 1) * size end start size - 1 # 偏移量分页 page_ids r.zrevrange(KEY, start, end) total r.zcard(KEY)游标分页的写法是每次记录最后一个 scoredef fetch_page_by_cursor(last_score, size): # 注意 redis-py 里 zrevrangebyscore 的参数顺序是 max, min if last_score is None: max_score inf else: max_score ( str(last_score) return r.zrevrangebyscore(KEY, max_score, -inf, start0, numsize)这里用( str(last_score)表示开区间避免重复带上一条。用 Python 做验证有个好处写完可以直接在命令行批量造数然后用r.zadd插入几百条数据测试翻页效果再决定 Java 侧方案。4.3 把“查ID列表”和“查对象详情”拆开线上高并发分页接口我强烈建议分成两层缓存第一层缓存 ID 列表第二层缓存对象详情。比如先拿到article:10001、article:10002再通过MGET批量取详情MGET article:10001 article:10002 article:10003如果直接缓存整个对象列表新增字段、修改昵称、更新状态时都要全量重建列表成本非常高。ID 列表保持短小对象各自独立更新是缓存治理里最基本的习惯。5. 常见问题的现象与排查速查表5.1 分页出现重复或漏数据先查 score 是否唯一你发现第一页和第二页出现同一条数据最直接的原因是ZREVRANGEBYSCORE使用了闭区间或者 Score 相同且游标没有做开区间跳过。我会先用命令行查看集合分数的分布ZREVRANGE key 0 20 WITHSCORES如果发现多条相同 score优先改造分数的生成方式加入自增序列号使同分冲突概率下降。对已有数据可以用脚本重算 score 并重新ZADD。5.2 缓存列表与数据库不一致缓存列表的经典不一致场景是用户新增了一条数据数据库写入成功后Redis 列表没有同步LPUSH/ZADD或者用户删除数据后列表里还残留。我的做法是让 Redis 列表只存 ID并依赖业务侧事件去同步。新增时把 ID 推到列表头部、删除时用LREM或ZREM移除更新详情不影响列表顺序。如果同步流程中途失败还要有补偿任务定期扫描。单纯依赖“删除缓存让 DB 兜底”的方案在列表缓存场景里会让分页接口瞬间打满数据库效果很差。5.3 大 Key 和热 Key 导致阻塞单个 Sorted Set 存了几百万成员每次分页都访问同一个 key一旦某个大促流量进来Redis 主线程容易被这个 key 的复杂操作拖慢。排查时我用--bigkeys参数扫描大 Key然后用ZREMRANGEBYRANK key 0 -N裁剪只保留最近 N 条。如果数据确实必须全量存在还要考虑按时间拆 key比如feed:202401、feed:202402分页命令带上时间范围先定位到具体的 key避免单 key 过热。5.4 序列化导致 key 混乱用 Java 的 RedisTemplate 最容易踩的坑就是 key 和 value 默认使用 JDK 序列化结果 Redis 客户端里看到的 key 是一长串乱码和命令行里写入的 key 对不上。排查时先确认StringRedisTemplate与RedisTemplate是否混用再看配置里的 keySerializer 是否设置为String类型。我的习惯是列表页的 key 全部用String类型value 用 JSON 序列化这样至少能保证跨客户端、跨语言可读。如果历史代码已经保留了默认 JDK 序列化改配置后记得做好数据迁移否则旧 key 会全部失效。5.5 常用排查速查表现象可能原因排查方式处理建议分页重复或漏数据score 相同、游标闭合、新增导致偏移ZREVRANGE key 0 20 WITHSCORES复合分数保证唯一 score游标用开区间第二页数据很慢List 过长、offset 太大LLEN 或 ZCARD 查看长度限制保留最近 N 条或改用游标key 变成乱码序列化配置不一致TYPE key和客户端查看统一 String 序列化缓存和数据库不一致同步逻辑漏执行抽查列表和 DB 的 ID 集合差集用事件同步补偿任务内存增长很快Sorted Set/List 无限增长MEMORY USAGE key裁剪旧数据按时间拆 key6. 我做完这些方案后的几条习惯性建议先说排序稳定性这件事。凡是要给用户看的分页我都会尽量让 score 唯一。不要指望 Redis 自动帮你处理同分元素它的字典序排序对业务来说往往是随机的。用“时间戳 自增序号”这种复合分虽然只多了一行生成代码但能避免非常多的重复数据投诉。再说接口层面的细节。给前端提供分页接口时我会同时返回 total、当前页列表、下一页游标三个字段。如果每页只依赖页码翻页一旦数据频繁变化用户永远不会知道数据已经移动过。返回游标的好处是客户端可以选择继续用游标翻页也可以手动跳页两种模式都能兼容。最后是数据量控制。Redis 适合做“热数据的分页窗口”不适合做无限制的全量列表。高频接口我会让 Sorted Set 只保留最近 2000 条超过的数据直接回源数据库查询。这个上限不是随便定的是根据单个对象详情缓存的大小和 Redis 内存预算算出来的。存 2000 个 ID 和存 2000 个完整对象的内存差别很大实际项目里我会把对象详情单独缓存ID 集合长度控制得更保守一些。
返回列表