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:在特定场景下可以用来做分页过滤,比如标记用户是否在某个区间内,不过日常用到的概率不大。
我整理了一个选择参考表:
| 场景 | 推荐结构 | 核心命令 | 备注 |
|---|---|---|---|
| 消息列表/操作日志 | List | LRANGE, LLEN | 适合小列表,注意总长度 |
| 排行榜/按分数排序 | ZSET | ZREVRANGE, ZREVRANK | 跳表实现,翻页性能很稳 |
| 按时间倒序Feed流 | ZSET | ZADD score=时间戳, ZREVRANGE | 用时间戳当score |
| 去重集合遍历 | SET | SSCAN | 游标式遍历,不会阻塞 |
| 分页结果缓存 | STRING/HASH | GET/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页,同时并发写入新数据,看看有没有重复、漏数据、耗时抖动。这个习惯帮我挡掉过好几次线上事故,比线上出事再排查划算得多。