1. Redis分页查询的需求长什么样:先把三种场景分清楚
先说个结论:Redis里没有一个类似SELECT ... LIMIT x, y的命令体系,所有分页能力都要靠数据结构的特点自己拼出来。很多同学第一次接到“Redis分页查询”需求时,第一反应是搜命令、找Demo,搜了一圈发现大家说法还不一样,越看越乱。我自己的感受是:真正要做的第一步不是写代码,而是把业务需求拆成正确的类型,因为不同类型对应的方案和坑完全不一样。
我把实际业务里遇到过的Redis分页诉求归成三类:
第一类是内存列表分页。典型场景是消息队列的待处理列表、临时任务池、最近浏览记录。数据本身就在Redis里,没有关系型数据库作为“主存储”,Redis就是唯一数据源。这类需求的特点是:数据量可能不小,但单条value通常不大,需要频繁翻页、快速响应。
第二类是缓存分页。数据主存储仍然在MySQL/MongoDB里,Redis只是把查询结果缓存起来。业务要求页面有筛选、排序、分页,Redis缓存的是某一页或某个维度的查询结果。这类需求最容易踩“缓存一致性”的坑,因为分页参数组合是爆炸的,你很难把所有组合都精确命中和失效。
第三类是全量数据流式分页。比如定时任务要遍历Redis里的所有key去处理,或者要把一个大List/ZSet的数据分批取出来做迁移。这里关注点已经不在“用户体验分页”,而在“怎么既不阻塞Redis,又能安全地分批拉完数据”。
把需求归好类,再谈具体技术实现才不会跑偏。下面每个章节我会先说方案本身,再说它适合哪一类需求,最后给出我踩过的坑和结论。我尽量用Redis命令加上一些伪代码来说明,方便你直接照着改。
需要先提一句的是:方案没有绝对的好坏,只有适配不适配。很多人上来就推ZSet、推Scan,好像别的方案都是错的,这个本身就不客观。比如数据量几百条的列表分页,你用最原始的LRANGE一点问题都没有,用什么高级方案反而是浪费精力。
2. List/Set的机械分页:LRANGE和SRANDMEMBER的适用边界
List结构在Redis里本质是一个双向链表,所以它提供的分页是顺序范围访问。核心命令就是LRANGE key start stop。举个例子:
> RPUSH page:list a b c d e f g h i j (integer) 10 > LRANGE page:list 0 4 1) "a" 2) "b" 3) "c" 4) "d" 5) "e"第1页取LRANGE list 0 4,第2页取LRANGE list 5 9,公式就是start = (pageNo - 1) * pageSize、stop = pageNo * pageSize - 1。这个公式小学生都会写,但实际项目里最容易翻车的点不在命令本身,而在数据被其他操作改动之后,你的“页”就错位了。
举个例子,你做了一个“待处理任务队列”,用户不停翻页,同时有消费者从队列头部LPOP任务处理掉了。那么第1页拉出来的还是start=0开始的几条,但此时第0条已经被人取走了,用户看到的第1页其实是原来的第2页内容,翻到后面还会出现某一条被重复看到或完全跳过的情况。
很多人会用RPOPLPUSH或者LREM去“处理”完再回来,但这样处理完的任务从List里消失了,页码永远在漂移。所以我的结论是:List只适合数据相对静态、且允许并发操作造成轻微错位的场景。比如一个“最近浏览记录”列表,用户翻页时自己也会产生新的浏览记录,那List天然是好的:新数据从头部LPUSH进来,旧的从尾部剔除,用户翻页看到的是近似时序的数据,错位了用户也感知不到。
再讲Set。Set是无序集合,严格来说不适合分页。你拿SMEMBERS出来排序再截断,性能差且每次结果顺序不固定;用SRANDMEMBER模拟“随机翻页”则根本不能作为分页方案,因为它是随机抽样的,不是“第N页”的稳定语义。如果一定要对Set做分页,我见过两种临时方案:一是额外维护一个List来保存Set元素的顺序快照;二是直接把Set转成List再做内存分页。前者有_data同步问题,后者适合数据量很小(几百条以内)的场合。
所以这一节真正想强调的是:List/Set的机械分页本质上是“位置定页”,它依赖列表数据的稳定性。如果数据变动频繁而业务又不允许错位,你得升级到ZSet的“排名定页”或者引入独立的偏移量机制。
我最早做Redis分页时就用过LRANGE做任务列表,当时没想明白这个错位问题,结果线上出现了同一个任务被两个用户同时“处理”的情况——因为前端先拿到第1页数据展示,另一边另一个会话已经把任务从队列里LPOP走了,后端再按Id去处理时发现记录已经不在队列里。虽然最终靠业务幂等兜住了,但那次教训让我意识到:选型之前先问一句“我的数据在分页区间内会被改动吗”,比纠结API怎么调重要得多。
3. ZSet有序分页:按排名翻页与人造唯一游标
如果业务里有明确的“排名”“得分”“时间排序”这类指标,ZSet就是最自然的分页载体。Skiplist(跳跃表)的结构让ZSet可以高效地按照score范围定位,这比List的线性遍历要靠谱得多。
基础做法是两条命令:
> ZADD rank:list 10 a > ZADD rank:list 20 b > ZADD rank:list 30 c # 从第1名到第3名 > ZREVRANGE rank:list 0 2 WITHSCORES 1) "c" 2) 30 3) "b" 4) 20和List一样,ZREVRANGE/ZRANGE支持start和stop,做简单分页完全够用。但这里有个关键差异:score可以重复。如果一堆成员都是同一个score,比如“按更新时间排序”时同一秒更新了多条记录,那么按偏移量翻页时顺序就会变得不稳定,翻页可能重复或漏数据。
解决重复score的分页问题,业界通用的做法是复合游标。你也别把它想得多高级,核心就是:让每个元素在逻辑上拥有一条唯一且有序的“游标”,游标常用score + member(member为元素本身)或者score + 自增序列来拼接。
拿一个很典型的场景举例:你在Redis里维护“用户最新发布的动态列表”,score用发布时间戳。同一秒可能发布多条动态,如果直接按offset分页,两条相同score的动态顺序可能每次都不一样。那么你可以这么设计:
score = 毫秒级时间戳 member = 动态ID + "-" + 随机后缀(或创建序号)由于member带上了唯一后缀,整个集合内(score, member)字典序是唯一的。翻页时不是用ZREVRANGE key offset count,而是记录上一页最后一个(score, member),下次用ZREVRANGEBYSCORE key (score+1 -inf LIMIT 0 count这样只取比上一页更小的记录。具体命令类似:
# 第一页:取最新的5条 > ZREVRANGE news:feed +inf -inf BYSCORE LIMIT 0 5 WITHSCORES 1) "1001" 2) 1730000000123 3) "1000" 4) 1730000000100 # 第二页:从上一页最后一条往后取 > ZREVRANGE news:feed (1730000000123 -inf BYSCORE LIMIT 0 5 WITHSCORES注意到命令里那个左括号(吗?它的含义是“开区间”,也就是不包含1730000000123这条自己。所以上一页边界记录本身已经被看过了,下一页从它之前的记录开始取。这套做法在Redis 6.2以后还能用ZRANGE统一语法来写,6.2之前的ZREVRANGEBYSCORE也一样能达到目的。
但这里有个我自己琢磨出来的小细节:只在score精度不够时才需要拼member,如果score本身就是唯一的(比如自增ID),用BYSCORE + 开区间就足够了。不要为了炫技给所有ZSet都硬造复合游标,能简单就简单。
还要注意一个非常容易踩的命令语义坑:ZREVRANGEBYSCORE key max min LIMIT offset count里的LIMIT有两个参数,第一个是跳过条数(offset),第二个是返回数量(count)。很多人下意识按MySQL的写法填成pageNo * pageSize, pageSize,这没错,但它没有“从指定member开始”的能力,所以你要实现“翻页”还得靠开区间BYSCORE。如果你用的是ZRANGE key start stop做物理分页,那是另一种做法,复杂度低但同样面临重复score顺序问题。
我自己在生产环境用的ZSet分页模板大概是这样的:第一页用ZREVRANGE物理分页,同时在response里返回lastScore和lastMember;后续翻页时如果有重复score或需要稳定游标,则改用ZREVRANGEBYSCORE (lastScore (lastMember BYSCORE LIMIT 0 pageSize。用伪代码表示:
def get_rank_page(redis, key, page, size, last_score=None, last_member=None): if last_score is not None and last_member is not None: # 稳定游标模式 result = redis.zrevrangebyscore( key, f"({last_score}", "-inf", start=0, num=size, withscores=True ) else: # 物理分页模式 result = redis.zrevrange(key, (page - 1) * size, page * size - 1, withscores=True) return result这样既兼顾了第一页的用户心智(第一页用页码翻),又保证深翻页时不乱序。做完之后你会发现,ZSet的深分页性能依然很好,因为Skiplist对score区间定位是O(logN)的,跳过了大偏移量的线性扫描。
4. 数据量过万后别硬翻页:SCAN与游标式批次遍历
“超过10000条”这个热搜词背后是有真实痛点的。当ZSet或者Hash里的成员数量达到数万、数十万之后,即使ZSet定位很快,你也不会想用ZRANGE key 99990 99999这种方式去取最后一页——虽然Skiplist能跳到那个位置,但整个操作会付出额外的随机访问成本。更重要的是,很多大数据分页场景根本不需要“跳到第10000页”,而是“给我把100万条数据分批洗完”。
这时要引入的其实是两套完全不同的工具:
第一套:SCAN系列命令。包括SCAN(遍历所有key)、HSCAN(遍历Hash内部字段)、SSCAN(遍历Set内部元素)、ZSCAN(遍历ZSet内部元素)。它们的特点是基于游标(cursor)的增量迭代,不是一次性返回所有数据,而是每次返回一小批和一个新的游标值,直到游标回到0表示遍历结束。
> SCAN 0 MATCH user:* COUNT 100 1) "17423" 2) 1) "user:1000" 2) "user:1001" ... > SCAN 17423 MATCH user:* COUNT 100 1) "0" 2) 1) "user:9999" ...游标本身是一个不透明的“迭代状态”,你完全可以把它想象成一个翻书签:这次读到哪,下次从哪继续。对于大批量遍历场景,SCAN比KEYS要安全得多,因为它是分段执行的,不会阻塞Redis主线程太久。还是那句话,别在生产环境跑KEYS *,这个很多人都提醒过,但我还是要再强调一遍:数据量一大,KEYS会卡死整个实例。
第二套:限定范围的游标翻页。就是我在第3节说的BYSCORE + 开区间思路。它的核心是把“翻页”转换成“向后取N条直到取完”,每次记录最后一条的位置作为下一轮的起点。相比SCAN,这套做法有个额外好处:支持反向遍历和按score排序,而且不需要维护一个全局游标对象。
那么,什么时候用SCAN类的游标,什么时候用ZSet的score游标?我个人判断标准是这样的:
- 如果目标是遍历整个key空间(比如做数据清理、过期检查、批量删除前缀为
tmp:的key),用SCAN。 - 如果目标是遍历单个大Collection(比如一个超过十万成员的ZSet、一个字段上千的Hash/Sets),首选
HSCAN/SSCAN/ZSCAN,其次才是score游标。 - 如果目标是给用户提供具备稳定顺序的分页API,那必须用带有score或索引语义的数据结构,SCAN不能满足(SCAN的遍历顺序对业务不可预期)。
这里还要提一个比较隐蔽的坑:SCAN的COUNT参数并不是“精确返回N条”,它只是一个给引擎的hint(提示),实际返回条数可能多也可能少。而且对于大量已过期但尚未被清理的key,SCAN在返回时会把它们过滤掉,导致某次返回条数偏少甚至为空。你不能在业务里假设“每次都会返回100条”,必须写循环判断游标是否归零,同时保留“某轮迭代返回0条”的容忍逻辑。
和SCAN配套的一个常见需求是“分批删除”。比如一批一批地清理前缀为cache:的key,最安全的姿势是先SCAN收集key,再按每批几百条UNLINK(注意不是DEL,UNLINK是异步删除,大key不会阻塞)。写出来大概是:
# 每轮拿100个key,删除后再拿下一轮 redis-cli --scan --pattern "cache:*" --count 100 | xargs -n 100 redis-cli UNLINK当然实际工程里最好由代码控制游标,不要用管道串联,输出会非常不可控。真用shell方式做,也建议在低峰期执行。
5. 缓存分页场景下的Redis位置:数据一致性比查询更值得操心
前四节讲的都是“数据本就在Redis里怎么分页”,但真实业务里还有很大一类是“MySQL存主数据,Redis做查询缓存”。这种场景里,“Redis分页查询”这个命题往往会转变成两个更麻烦的问题:
- 第一,缓存怎么设计,才能让分页响应够快?
- 第二,数据更新了,缓存怎么失效,才能不让用户看到脏页?
先回答第二个。我用过多年的方案是“先更新DB,再删除Redis key”。删除而不是更新缓存的原因很简单:更新缓存很容易造成并发下的写覆盖,比如两个事务同时拿到旧数据,各自算好新值再写回Redis,后写的人覆盖先写的人,缓存就和DB不一致了;而删除key则是“下次查询时回源DB重建缓存”,天然避免了中间态。相关的一致性策略很多文章都讲烂了,实际编码里我还会加一层“延迟双删”:先删缓存,再更新DB,休眠几百毫秒再次删缓存。这个“二次删除”主要清理的是在第一次删除后、DB更新前读请求写入的旧缓存。
再来看分页缓存本身。一个常规分页接口,通常会缓存两类数据:
- 总记录数(count)
- 当前页的ID/列表(page)
每页的缓存key至少要包含业务维度、排序规则、页码、每页条数,例如:page:order:list:{sortBy}:{pageNo}:{pageSize}。这听起来很直观,但一旦排序规则变化或筛选条件组合多,key的维度会爆炸。所以实践上我们经常把分页对象整体序列化进value,而不是一个字段一个字段地拆开缓存。
比较麻烦的是“总条数”的缓存失效。举个例子:列表新增了一条记录,所有页的数据都可能往后挪,原本缓存的总条数也会变。如果每次新增都把所有分页缓存全删掉,成本很高;如果只删当前最新页的缓存,用户翻到后面的页又可能拿到旧数据。应对方法通常有三个:
- 给分页key设置一个较短的TTL,比如30秒。这个方案适合“列表允许轻微延迟”的业务,比如内容聚合页。
- 事务ID版本号:每次列表结构变化时递增一个版本号,拼进分页key里。这样一变结构,旧key全部失效,但查询会瞬间击穿到DB,适合写少读多的场景。
- 只缓存“非最后一页”的数据:列表最后一页的内容最不稳定(随时可能新增一条变成非最后一页),所以干脆只缓存前N页,最后一页回源DB。
我实际做得比较多的是版本号方案。以社区帖子列表为例,我在Redis里存一个meta:post:list:version,每当发帖/删帖就INCR一次,分页key拼上版本号。用户翻页时如果发现缓存miss直接走DB。虽然这样每次发帖会让所有分页缓存失效,但发布动作本身频率不高,收益远比“维护几十个分页key的精确失效”靠谱。反过来,如果你的业务是高频写入、低频读取,那短TTL可能是更好的选择。
这里再补一个容易被忽略的细节:空页缓存。当某页查询结果为空时,如果不缓存,就会出现“用户正好翻到空页、DB压力同时上来”的情况,极端时可能引发缓存穿透。所以空结果也要给一个非常短的TTL缓存下来,例如5秒,避免每次空页都打到MySQL。这也是我们系统一次夜间被大量机器人翻页请求打满DB之后总结出来的教训。
还有序列化问题。分页结果通常是一个包含list、total、pageNo、pageSize等字段的对象。如果直接缓存整个对象,序列化方式很重要。我经历过一次线上故障:Jackson默认序列化了一个LocalDateTime类型,结果下游业务反序列化时类型不匹配,整个分页接口抛异常。后来我们统一改用GenericJackson2JsonRedisSerializer或者给时间字段加自定义格式注解。这类问题不算分页特有,但确实最容易在分页数据结构里爆出来。
6. 实战经验:选型对比与我没写进文档的三个坑
这一节把前五节的内容做一个对比梳理,同时讲几个我在真实项目里被坑过、而且很少能在官方文档里看到的经验。
先上一张选型速查表,方便大家根据自己的场景快速定位:
| 业务场景 | 推荐方案 | 不推荐/慎用 |
|---|---|---|
| 消息队列/待处理任务列表 | List + 独立偏移量/队列消费 | 按页码分页,因为数据会动态变化 |
| 排行榜/按分数排名 | ZSet + ZREVRANGE物理分页 | Set随机分页 |
| 海量集合的批量遍历 | SCAN / HSCAN / ZSCAN | 直接KEYS全表扫描 |
| 场景Feed流/最新动态 | ZSet + score开区间游标 | List按offset翻页(新数据插头会全局错位) |
| MySQL查询结果缓存分页 | Redis String缓存分页对象 + 版本号失效 | 所有页缓存不加版本号且永久有效 |
| 用户自己录入的静态数据列表 | List LRANGE物理分页,够用,别过度设计 | 无 |
这个表只是一个起点,实际业务往往多种场景混在一起,你需要根据数据变化频率和用户容忍度做取舍。
接下来分享三个让我印象深刻的坑。
坑一:id从0开始换页后的缓存空洞。曾经有个分页接口,前端喜欢把页码从1传参,而后端代码里写的是offset = pageNo * pageSize,没减1。这意味着第一页从第pageSize条开始返回了,第一页最后一条成了第二页的第一条,首尾重复。这个bug隐藏得很深,因为总量大时看起来每一页都“有数据”,直到有用户反馈“我翻页时有一条重复出现了”。排查后才发现是边界语义没理清。后来我把所有分页代码统一封装成一个PageQueryUtil,统一计算start = (pageNo - 1) * pageSize,不再让业务代码手写偏移量,这类问题才被彻底堵住。
坑二:SCAN游标在大key分页时返回了一个“空页”的前置问题。前面提到SCAN的COUNT只是一个hint。有一次我们写了一个定时任务,从Redis里把所有用户ID批量扫描出来同步到ES。线上运行时报的日志是“扫描完成”,但ES里只同步了一半的数据。排查时发现问题出在:每轮SCAN取出1000个key,直接丢到线程池异步处理,但我判断“本轮是否结束”的条件写成了结果是空数组就BREAK,结果某一轮SCAN刚好返回0条并且游标不为0,代码就误判结束了。所以正确逻辑是:游标归零才是终止条件,返回条数为空不代表扫描结束。这个判断我后来给所有人强调过,但犯错的成本实在很低,顺手就写错了。
坑三:缓存总条数与分页页数不一致导致的“鬼页”。场景是这样的:Redis里缓存了某列表的total=123,同时缓存了前5页数据。用户请求第5页后,服务端发现数据不足一页,于是返回了第5页的少量数据。此时用户实际看到的是4页半的内容,但total=123显示的还是5页。翻到最后一页时,缓存命中一个“非真实最后页”,用户会以为数据没加载完。这个问题的根治方法是:当某页返回条数 < pageSize 时,认定这是最后一页,并把页数缓存一并更新。如果不在应用层做这个修正,总条数和页数的缓存会长期割裂。
说到底,Redis分页查询没有一个“银弹”命令,它需要你先判断数据在Redis中扮演的角色(主存储还是缓存)、集合是否稳定(动态插入删除还是静态只读)、以及用户翻页语义(物理页码还是游标深度翻页)。我在实际项目里的习惯是:少于1万条、列表稳定的数据,直接LRANGE或用ZSet物理分页,怎么简单怎么来;超过1万条或者数据变动频繁的,优先考虑游标方案;如果只是给MySQL查询做缓存,那重点要放到失效策略和序列化兼容上。把这三个前提想清楚,Redis分页查询基本就不会出大乱子。