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分页查询的数据类型选型别一上来就只会用List2.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是另一个特别适合做分页的数据结构。每个元素带一个scoreRedis天然按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存时间戳元素存业务IDZREVRANGE取出后回表拿完整信息这也是很常见的设计。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原生的老旧版本不建议用兼容性一般macOSHomebrew一行命令brew install redisDockerdocker run -d --name redis -p 6379:6379 redis这个是开发环境调试最省事的启动之后记得用redis-cli ping验证一下返回PONG就说明服务正常了。Spring Boot项目里引入依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-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 ListString 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 ListRankItem pageRank(String key, long page, long size) { long start (page - 1) * size; long end start size - 1; SetZSetOperations.TypedTupleString tuples redisTemplate.opsForZSet().reverseRangeWithScores(key, start, end); ListRankItem items new ArrayList(); if (tuples ! null) { for (ZSetOperations.TypedTupleString 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或ZCARDhasMore可以用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 lastIndex1 lastIndexsize。但如果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 ZSET7天以上的走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配合LRANGE5.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页同时并发写入新数据看看有没有重复、漏数据、耗时抖动。这个习惯帮我挡掉过好几次线上事故比线上出事再排查划算得多。