news 2026/10/13 2:27:45

Redis分页查询:从LRANGE到ZSET游标方案,深分页避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Redis分页查询:从LRANGE到ZSET游标方案,深分页避坑指南

Redis分页查询:从LRANGE到游标方案,我把在这件事上踩过的坑一次说清楚

最近在做社区Feed流的改造,用户在消息中心刷历史通知,一页10条,往下翻十几页之后明显变慢,查了下监控发现MySQL那边的大偏移量分页把磁盘IO拖上去了。后来把热点列表切到了Redis,分页查询的问题反而变成“怎么从Redis里优雅地取某一页”。

如果你也正在处理类似需求——消息列表、操作日志、排行榜、Feed流,想把分页查询扛到Redis上,或者被Java端查ES超过10000条深度分页卡过,那这篇就是冲着你写的。我会把Redis分页查询涉及的几个主流玩法、数据类型选择、Java侧落地代码、以及超过10000条之后的方案权衡都过一遍,顺带分享一些书上不写的坑。

1. 先想清楚:Redis分页查询到底是在解决什么问题

1.1 数据库深分页的痛,Redis真能治

先说一个很典型的场景。用户中心的通知表,三年积累了上千万行,用户点开“消息列表”要看自己最近100条。MySQL写出来往往是:

SELECT * FROM user_notice WHERE user_id = 123 ORDER BY create_time DESC LIMIT 100, 10;

这条SQL在offset只有100的时候没什么感觉,但用户一路翻到第1000页,offset就到了10000。MySQL需要先扫描到第10010行,再丢弃前面的10000行,只留10行返回。如果create_time没有合适的索引覆盖,那成本更夸张。

这种问题的本质是:数据库分页里offset越大,扫描成本越高。Redis的思路完全不一样——它把列表整个放在内存里,任何一页的读取都是一次内存操作,而且数据结构的取法决定了它在偏移量变大时成本增长很慢,甚至几乎不变。

所以我们经常说“Redis分页查询”,并不是Redis比数据库更聪明,而是它用“空间换时间”,把高频访问的数据在内存里维持住,然后靠自身的数据结构提供低延迟访问。

1.2 一个典型Redis分页缓存架构长什么样

我接手这个项目时,线上架构大概是这样:MySQL存全量数据,Redis专门缓存每个用户最近的几百条通知ID或完整通知对象。查询时先走Redis,命中就直接返回分页结果,没命中再回源MySQL。

这样设计有几个明确的收益:

  • 用户高频翻页时,Redis的RT稳定在1ms上下,MySQL再怎么优化也很难稳定到这个量级。
  • Redis对整体数据的访问成本相对平均,前500页和当前页的性能差异可以忽略。
  • 数据库的压力大幅下降,慢查询少了,磁盘IO占用也掉下来了。

但别高兴太早,Redis分页查询有几个“必须自己做选择”的环节:用哪种数据结构、按什么规则取页、深翻页怎么处理、缓存和DB的一致性怎么保障。这几个问题,我在下面的章节里一个个拆。

2. Redis分页查询的数据类型选型,别一上来就只会用List

2.1 List + LRANGE:最直观也是最容易上手的方案

Redis的List数据结构,底层是双向链表(或quicklist),天然支持从左到右或从右到左按范围取元素,对应命令就是:

# 从 key 里拿 start 到 stop 区间的元素,下标从0开始 LRANGE key start stop

举一个具体例子,比如我们要查第3页,每页10条:

LRANGE user_notice:123 20 29

这就对应第3页的10条数据。配合LLEN可以知道总数,配合LPUSH/RPUSH可以在头部或尾部追加新数据。

我在项目里一开始就是这么干的,代码简单、命令好懂,上线后效果也不错。但用久了就发现几个限制:

  • List里的LRANGE,在底层链表实现里,定位到start位置需要从头往后走,偏移量特别大时耗时会有明显增长。
  • List不天然支持按时间倒序,除非你每次都在LPUSH新数据,让头部永远是新的。如果数据写入顺序和业务需要的显示顺序不一致,就得额外处理。
  • 有人用List的时候存的是完整JSON,几十万条完整对象堆在一个key里,瞬间变成大key,网络传输、内存占用都很难受。

所以我的建议是:List适合短列表、顺序敏感、追加为主的场景,比如最近100条通知、最近50条操作记录。数据量超过几千条且需要频繁翻页的,别把所有东西都塞进一个List。

2.2 ZSET + ZRANGE/ZREVRANGE:带分数的分页才是进阶玩法

Sorted Set是另一个特别适合做分页的数据结构。每个元素带一个score,Redis天然按score排序。分页查询命令如下:

# 升序分页,score从小到大 ZRANGE weekly_rank 0 9 # 降序分页,score从大到小,排行榜常用 ZREVRANGE weekly_rank 0 9 # 带score一起返回,方便之后展示分数 ZREVRANGE weekly_rank 0 9 WITHSCORES

这里的好处在哪?就说排行榜场景。排行榜本质就是按分数倒序,然后按页展示。ZSET在底层是跳表实现,按排名范围取元素的时间复杂度是O(log(N)+M),这比List的链表遍历优势明显得多。

我这边做过一个直播打赏榜,每分钟都在变,用户会不停下滑看自己的排名。用ZSET + ZREVRANGE,每一页都是毫秒级响应,而且新增打赏时ZADD分数会自动调整排序,完全不用手动重排。

如果你的数据不是天然带分数,也可以把时间戳当作score来用,实现“按时间倒序分页”——消息列表、操作日志都可以这么干。score存时间戳,元素存业务ID,ZREVRANGE取出后回表拿完整信息,这也是很常见的设计。

2.3 Set、Hash、String也参与了分页的周边工作

很多人以为Redis分页查询只用List和ZSET,实际上其他数据结构也在分页链路里扮演重要角色:

  • Set + SSCAN:适合做“已读/未读”之类去重集合的分页遍历。SSCAN支持游标遍历,可以在不阻塞Redis的情况下分批取出数据。
  • Hash:适合存分页数据的元信息,比如把“第几页”作为field,把这一页的数据JSON作为value。这种做法适合一次性生成分页结果后缓存,但灵活性比较差,页面一变化就要全量重建。
  • String + 自增ID:有的项目用String维护一个全局自增ID,再把ID映射到对应的数据位置,不过这种方案现在已经很少用了,维护成本太高。
  • Bitmap:在特定场景下可以用来做分页过滤,比如标记用户是否在某个区间内,不过日常用到的概率不大。

我整理了一个选择参考表:

场景推荐结构核心命令备注
消息列表/操作日志ListLRANGE, LLEN适合小列表,注意总长度
排行榜/按分数排序ZSETZREVRANGE, ZREVRANK跳表实现,翻页性能很稳
按时间倒序Feed流ZSETZADD score=时间戳, ZREVRANGE用时间戳当score
去重集合遍历SETSSCAN游标式遍历,不会阻塞
分页结果缓存STRING/HASHGET/HGET适合结果集变化不频繁的接口

3. Java侧落地:Redis分页查询的完整实现过程

3.1 先把环境准备好,别在Redis安装和使用上浪费时间

开始写代码之前,先把Redis跑起来。如果你还没安装Redis,不管是Linux服务器还是本地开发机,都用最常见的几种方式之一:

  • Linux:系统包管理器安装,或者从Redis官网下载源码编译
  • Windows:可以用官方的Memurai或者WSL里跑Linux版本,纯Windows原生的老旧版本不建议用,兼容性一般
  • macOS:Homebrew一行命令brew install redis
  • Docker:docker run -d --name redis -p 6379:6379 redis,这个是开发环境调试最省事的

启动之后记得用redis-cli ping验证一下,返回PONG就说明服务正常了。Spring Boot项目里引入依赖:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>

配置文件里最核心的是连接地址和连接池参数:

spring: data: redis: host: 127.0.0.1 port: 6379 password: timeout: 3000ms lettuce: pool: max-active: 50 max-idle: 20 min-idle: 5

这里有个日常开发中容易忽略的点:Lettuce默认就是Spring Boot整合的客户端,但连接池参数不配的话,高并发下会频繁创建连接。我见到过不少项目只写了host和port,压测时大量TIME_WAIT连接,就是因为连接池没配好。

3.2 消息列表的分页查询:List + LRANGE的Java实现

以用户通知列表为例,伪代码逻辑如下:

@Service public class NoticeCacheService { private static final String NOTICE_KEY_PREFIX = "user_notice:"; @Autowired private StringRedisTemplate redisTemplate; // 查询某一页的通知ID列表 public List<String> pageNoticeIds(Long userId, long page, long size) { String key = NOTICE_KEY_PREFIX + userId; long start = (page - 1) * size; long end = start + size - 1; return redisTemplate.opsForList().range(key, start, end); } // 查询总数 public Long countNotice(Long userId) { String key = NOTICE_KEY_PREFIX + userId; return redisTemplate.opsForList().size(key); } // 新增通知时,往头部插入,并裁剪只保留最近200条 public void pushNotice(Long userId, String noticeId) { String key = NOTICE_KEY_PREFIX + userId; redisTemplate.opsForList().leftPush(key, noticeId); redisTemplate.opsForList().trim(key, 0, 199); } }

这段代码里有几个经验点:

  • 使用StringRedisTemplate时,ID和String互转要你自己来,序列化方式最可控。
  • LTRIM是个被很多人忽略的命令,它能把List裁剪到指定范围。我这个场景里只关心最近200条,超过的直接丢弃,防止单个key无限膨胀成大key。
  • 分页接口不要默认只有当页数据,最好把total一起返回,前端才好在页面上渲染“共多少条/共多少页”。

3.3 排行榜分页:ZSET + ZREVRANGE的Java实现

排行榜场景更复杂一点,因为除了ID,还需要分数。Java侧实现大概这样:

public List<RankItem> pageRank(String key, long page, long size) { long start = (page - 1) * size; long end = start + size - 1; Set<ZSetOperations.TypedTuple<String>> tuples = redisTemplate.opsForZSet().reverseRangeWithScores(key, start, end); List<RankItem> items = new ArrayList<>(); if (tuples != null) { for (ZSetOperations.TypedTuple<String> tuple : tuples) { RankItem item = new RankItem(); item.setUserId(tuple.getValue()); item.setScore(tuple.getScore()); items.add(item); } } return items; }

注意reverseRangeWithScores返回的顺序是score从大到小,正好符合排行榜展示习惯。如果你还想要某个用户排第几名,用:

Long rank = redisTemplate.opsForZSet().reverseRank(key, userId);

这里返回值是0基的,所以要显示第几名的话记得+1。我踩过这个坑,第一次上线时排行榜显示全员排名少了1,用户群反馈“我明明第50名,页面显示49名”,其实就是忘了0基和1基的转换。

3.4 页面上的“上一页/下一页”何止是页码,total也一起返回

我在做列表接口时,返回给前端的结构一般是这样:

{ "code": 0, "data": { "list": [ {"id": "10086", "title": "你的工单已被回复"}, {"id": "10085", "title": "你的优惠券即将过期"} ], "page": 3, "size": 10, "total": 200, "hasMore": true } }

total来自LLEN或ZCARD,hasMore可以用page * size < total判断。这种返回结构的好处是前端可以直接渲染分页组件,不用自己猜是否还有下一页。

但这里要注意:如果List做了LTRIM裁剪,total其实只代表缓存里还剩多少条,并不代表数据库真实总数。产品上如果必须显示真实总数,那就得把总数单独缓存一个key,或者直接从数据库查total,只把列表数据放到Redis里。这两种思路我都用过,后者更常见一些。

4. 超过10000条的深分页:偏移分页到游标方案的选择题

4.1 深分页问题为什么绕不开

Redis分页查询做久了,你迟早会遇到“用户翻了几百页”这种操作。前面提到List在偏移量很大时访问开销会上升,还有另一个隐藏问题:当你用(page - 1) * size这种方式做深分页时,页数越深,Redis命令里涉及的start越大,一次命令取数据的路径就越长。

更麻烦的是业务交互层面——用户明明只翻到了第300页,他看到的瞬间,新的数据又被写入头部,整个列表的偏移量全变了。用户看到的第301页,其实可能和刚才第300页重复了大半。这就是“深度分页+高频写入”的组合问题。

另外,如果你在Java侧查ES,也会撞到类似的墙。ES默认的from + size深度分页上限是10000:

{ "from": 100000, "size": 10, "query": {} }

这个请求会直接报错:Result window is too large, from + size must be less than or equal to: [10000]。热搜词里那个“java es分页查询超过10000”说的就是这件事。

4.2 滚动分页/游标分页,Redis里更优雅的做法

既然偏移分页在深翻页时这么别扭,那就不翻第N页了,改成“记住上一页最后一条数据的位置,接着往下刷”。这套思路叫滚动分页或游标分页。

在Redis的ZSET里实现起来特别顺。比如按时间倒序的消息流,score存时间戳,之前我们是ZREVRANGE key 0 9查第一页。翻页时不用page参数,而是带上上一次最后一条数据的score值:

# 第一页 ZREVRANGE user_feed:123 0 9 # 假设第一页最后一条的score是 1690000000123 # 第二页,从小于该score的数据继续取 ZREVRANGEBYSCORE user_feed:123 (1690000000123 -inf LIMIT 0 10

用ZREVRANGEBYSCORE配合一个开区间(1690000000123就能取到比这个score更小的下一批。这种方式不管翻到多深,都是按score区间定位,跟前面的总数据量基本无关,速度非常稳定。

List场景也可以用类似的思路:缓存每页最后一条数据的下标或ID,下次LRANGE key lastIndex+1 lastIndex+size。但如果List头部不断插入新数据,下标会漂移,所以List更适合配合“ID精确查找”来做滚动分页,而不是下标推算。

至于ES那边的10000限制,常见的解法是改用search_after或scroll游标API。search_after用上一页最后一条数据的排序值继续往后拉,跟Redis ZSET的思路是一样的,只是API形态不同。你在缓存层养成了游标思维,转到ES那边也很容易上手。

4.3 Redis和Java/ES配合时的分层方案

再补充一个比较现实的做法,尤其适合列表量大、数据源又不止一个的场景:

  • 第一层:Redis用ZSET存热门列表的前几百条,按score排序,高频请求直接走Redis。
  • 第二层:超过几百条以后走ES的search_after游标查询,每次返回较小的数据块。
  • 第三层:兜底走MySQL的延迟关联分页,比如先只查主键再回表拿详情。

我之前做操作日志系统就是这套结构。最近7天的日志走Redis ZSET,7天以上的走ES,冷数据走MySQL归档表。用户翻页的体验基本无感知,后端三个数据源各自干自己擅长的事。这比“Redis存全量、一条命令翻到底”靠谱得多,因为缓存全量数据的内存成本一定会在某个量级上压垮你。

5. 实操阶段遇到的坑与排查实录

5.1 警惕大key:一个List存十万条完整对象,Redis直接卡顿

有段时间线上Redis的慢日志里经常出现一个List的LRANGE命令耗时几十毫秒,排查一看,某个运营配置的活动ID下面存了十几万条完整用户对象JSON,单key内存直接飙升到了80MB。

这就是典型的大key问题。大key会让单次命令执行过长、内存碎片增多、主从同步时全量复制数据量巨大。解决思路:

  • List里只存业务ID,不要存完整对象,取到ID后再批量回源数据库或者查Hash。
  • 用LTRIM把List裁剪到合理长度,比如几百条,真实需要更多的话用冷热分层。
  • 如果确实要存很多数据,优先考虑ZSET + 分key,比如按月分key,或者按用户ID分片。

5.2 并发写入导致翻页偏移,用户觉得数据重复/丢失

有一次在直播间做实时评论列表,用户翻着翻着发现上一行内容和刚才看到的有重复,或者某些评论“消失”了。原因很简单:评论不断通过LPUSH写入List头部,用户上一页取的是LRANGE key 0 9,等他翻下一页时最新评论已经插到前面,原来第10条变成了第11条,自然就重复或错位了。

解决这个问题的常用办法:

  • 换成滚动分页,基于最后一条评论的ID或score继续取。
  • 前端展示时用ID做去重,重复数据不渲染。
  • 接受“正在变化的列表没有严格一致性”这个事实,只保证最终一致,这是很多实时Feed流的通行做法。

5.3 key过期时间设太短,用户翻页翻到一半数据没了

我踩过的一个经典坑:缓存key的TTL设了5分钟,但用户某次翻页过程中缓存刚好过期,后端走回源逻辑把数据重新缓存。如果回源源码没有把整个列表完整重建,那缓存里可能只剩当前页的数据,用户点下一页时拿到一个长度不对甚至为空的列表。

后来的对策是分场景对待:

  • 第一次查询时,如果缓存miss,就一次性重建完整列表,再设置TTL。
  • 重建期间用分布式锁或本地锁防止缓存击穿。
  • 列表数据不长时,TTL可以设长一些,甚至不设TTL,配合LTRIM控制长度。

5.4 常用排查命令与问题速查表

下面这个表是我在群里面经常分享的东西,遇到Redis分页相关问题可以先对号入座:

现象可能原因排查命令解决办法
翻页很慢大key / 偏移量过大DEBUG OBJECT key看编码和长度分key、裁剪、改用ZSET
一页数据缺失key过期 / 重建逻辑不完整TTL key,LLEN key延长TTL或修复重建逻辑
数据重复翻页期间有新数据写入观察业务写入时机改用游标分页/前端去重
内存暴涨缓存了全量完整对象INFO memory,MEMORY USAGE key只缓存ID,或裁剪长度
阻塞命令使用了KEYS或大范围遍历Redis日志、慢日志用SCAN代替KEYS
列表顺序不对用LRANGE取降序但数据是升序插入打印List首尾元素验证改用ZSET或RPUSH配合LRANGE

5.5 几个容易被忽略的Redis分页查询细节

最后补充几个细节,都是我在实际开发中被问到过的问题:

  • LRANGE的start和stop支持负数,LRANGE key -10 -1表示最后10条。这个特性在判断“还有没有更多数据”时很好用,但注意负数下标和正数下标混用容易把人绕晕,别在业务代码里写得太花哨。
  • 使用StringRedisTemplate时,所有值都是字符串。如果你存储的是Long类型ID,拿到手后记得自己转型,别让类型问题在比较阶段出bug。
  • 热点key的过期时间要加随机抖动,否则可能一大片key同时过期,造成缓存雪崩。
  • 如果做的是小程序或App接口,传输数据别把大字段一并塞进Redis的分页结果里,返回前裁剪字段或改成分页ID再二次查询。

写在最后的一点经验

我在Redis分页查询这件事上,从一开始无脑用List + LRANGE,到后来换ZSET、上滚动分页、跟ES配合分层,最大的体会是:Redis分页查询没有一个万能方案,关键看你的数据特征和访问模式。列表短、写入少、偏移量可控,List简单好用;列表长、排序强、需要深翻页,ZSET加游标是更稳妥的选择;再往上走,就别指望Redis单扛,该上ES上ES,该冷热分离就分离。

最后再分享一个小技巧:每次上线分页相关接口前,我会用脚本模拟用户连续翻100页,同时并发写入新数据,看看有没有重复、漏数据、耗时抖动。这个习惯帮我挡掉过好几次线上事故,比线上出事再排查划算得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 2:27:28

【WebSocket】什么是 WebSocket?它和 HTTP 有什么本质区别?

WebSocket 协议本质:从“写信”到“打电话”的通信革命 用户问题原文:什么是 WebSocket?它和 HTTP 有什么本质区别? 在构建实时系统时,无数工程师都曾陷入一个困境:明明 TCP 是全双工的,为什么基于它的 HTTP 却只能“你问我答”?当你的金融行情推送因轮询延迟而错失交易…

作者头像 李华
网站建设 2026/10/13 2:26:55

PS5实战调校全攻略:散热、存储与网络优化指南

机器到手那天&#xff0c;我把它从箱子里捧出来时&#xff0c;第一反应不是激动&#xff0c;而是犯愁&#xff1a;这么大的家伙往哪儿放&#xff1f;后面那堆接口哪个跟哪个对应&#xff1f;系统里那么多选项&#xff0c;默认的和优化过的到底差多少&#xff1f;后来折腾了半年…

作者头像 李华
网站建设 2026/10/13 2:26:42

Czkawka 使用指南:用 14 个免费清理工具拿回磁盘空间

Czkawka 使用指南&#xff1a;用 14 个免费清理工具拿回磁盘空间 【免费下载链接】czkawka Multi functional app to find duplicates, empty folders, similar images etc. 项目地址: https://gitcode.com/GitHub_Trending/cz/czkawka Czkawka 是一款用 Rust 编写的磁盘…

作者头像 李华
网站建设 2026/10/13 2:25:12

Spring Boot 启动失败且日志空白?Nacos 配置加载顺序是关键

一次“静默失败”的排查&#xff1a;Spring Boot 应用启动不了&#xff0c;日志却一片空白这几天在做微服务改造时&#xff0c;遇到一个特别磨人的问题&#xff1a;本地启动 Spring Boot 服务&#xff0c;控制台只打印了几行 Spring 的 ASCII 图标&#xff0c;然后就一直卡在那…

作者头像 李华
网站建设 2026/10/13 2:25:02

OpenClaw部署全攻略:从安装到微信绑定,TaoToken统一Key接入实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 2:24:50

降AI率工具全解析:八大功能与自考生论文实操指南

这几年但凡写过毕业论文的人&#xff0c;谁没跟“AI率”打过几回照面。很多自考生本来只是用AI查点资料、列个提纲&#xff0c;结果文章交到检测系统里一看&#xff0c;“疑似AI生成”的比例高得吓人&#xff0c;于是就开始满世界找所谓降AI率工具。可工具真的越贵越好吗&#…

作者头像 李华