多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Redis分页查询方案详解:从LRANGE到ZSet游标与SCAN的选型指南

Redis分页查询方案详解:从LRANGE到ZSet游标与SCAN的选型指南 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页拉出来的还是start0开始的几条但此时第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 membermember为元素本身或者score 自增序列来拼接。拿一个很典型的场景举例你在Redis里维护“用户最新发布的动态列表”score用发布时间戳。同一秒可能发布多条动态如果直接按offset分页两条相同score的动态顺序可能每次都不一样。那么你可以这么设计score 毫秒级时间戳 member 动态ID - 随机后缀或创建序号由于member带上了唯一后缀整个集合内(score, member)字典序是唯一的。翻页时不是用ZREVRANGE key offset count而是记录上一页最后一个(score, member)下次用ZREVRANGEBYSCORE key (score1 -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_scoreNone, last_memberNone): if last_score is not None and last_member is not None: # 稳定游标模式 result redis.zrevrangebyscore( key, f({last_score}, -inf, start0, numsize, withscoresTrue ) else: # 物理分页模式 result redis.zrevrange(key, (page - 1) * size, page * size - 1, withscoresTrue) 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提示实际返回条数可能多也可能少。而且对于大量已过期但尚未被清理的keySCAN在返回时会把它们过滤掉导致某次返回条数偏少甚至为空。你不能在业务里假设“每次都会返回100条”必须写循环判断游标是否归零同时保留“某轮迭代返回0条”的容忍逻辑。和SCAN配套的一个常见需求是“分批删除”。比如一批一批地清理前缀为cache:的key最安全的姿势是先SCAN收集key再按每批几百条UNLINK注意不是DELUNLINK是异步删除大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里缓存了某列表的total123同时缓存了前5页数据。用户请求第5页后服务端发现数据不足一页于是返回了第5页的少量数据。此时用户实际看到的是4页半的内容但total123显示的还是5页。翻到最后一页时缓存命中一个“非真实最后页”用户会以为数据没加载完。这个问题的根治方法是当某页返回条数 pageSize 时认定这是最后一页并把页数缓存一并更新。如果不在应用层做这个修正总条数和页数的缓存会长期割裂。说到底Redis分页查询没有一个“银弹”命令它需要你先判断数据在Redis中扮演的角色主存储还是缓存、集合是否稳定动态插入删除还是静态只读、以及用户翻页语义物理页码还是游标深度翻页。我在实际项目里的习惯是少于1万条、列表稳定的数据直接LRANGE或用ZSet物理分页怎么简单怎么来超过1万条或者数据变动频繁的优先考虑游标方案如果只是给MySQL查询做缓存那重点要放到失效策略和序列化兼容上。把这三个前提想清楚Redis分页查询基本就不会出大乱子。
返回列表