第一次被问到“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 = 19:
LRANGE 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。业务数据通常希望新的排在前面,所以我把时间戳直接作为 score:
ZADD 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 Set:
ZADD 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 序列化使用StringRedisSerializer,value 序列化使用兼容的 JSON 方式。
第一段代码是偏移量分页,适合浅翻页场景:
public PageResult<String> pageByOffset(String key, long page, long size) { long total = redisTemplate.opsForZSet().zCard(key); long start = (page - 1) * size; Set<String> 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 List<ZSetOperations.TypedTuple<String>> 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(host='127.0.0.1', port=6379, db=0, decode_responses=True) 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)游标分页的写法是每次记录最后一个 score:
def 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", start=0, num=size)这里用"(" + 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 | 裁剪旧数据,按时间拆 key |
6. 我做完这些方案后的几条习惯性建议
先说排序稳定性这件事。凡是要给用户看的分页,我都会尽量让 score 唯一。不要指望 Redis 自动帮你处理同分元素,它的字典序排序对业务来说往往是随机的。用“时间戳 + 自增序号”这种复合分,虽然只多了一行生成代码,但能避免非常多的重复数据投诉。
再说接口层面的细节。给前端提供分页接口时,我会同时返回 total、当前页列表、下一页游标三个字段。如果每页只依赖页码翻页,一旦数据频繁变化,用户永远不会知道数据已经移动过。返回游标的好处是客户端可以选择继续用游标翻页,也可以手动跳页,两种模式都能兼容。
最后是数据量控制。Redis 适合做“热数据的分页窗口”,不适合做无限制的全量列表。高频接口我会让 Sorted Set 只保留最近 2000 条,超过的数据直接回源数据库查询。这个上限不是随便定的,是根据单个对象详情缓存的大小和 Redis 内存预算算出来的。存 2000 个 ID 和存 2000 个完整对象的内存差别很大,实际项目里我会把对象详情单独缓存,ID 集合长度控制得更保守一些。