资讯动态

Redis分页查询实战:解决数据库深分页性能瓶颈

发布时间:2026/10/14 4:53:47 来源:尧图企业网站定制
最近后台收到不少读者私信都在问同一个问题接口返回的分页数据变慢了怎么办。很多人第一反应是加索引、优化SQL但改了一圈发现深分页场景下数据库还是扛不住。今天就把我实际项目里用Redis做分页查询的完整方案拿出来聊聊包括数据结构选型、缓存设计、穿透防护和坑位复盘希望能给你一条可以直接落地的路子。1. 为什么分页查询会慢以及Redis能帮上什么忙1.1 数据库深分页的性能瓶颈到底在哪先说一个最常见的场景后台管理系统里的订单列表用户一页页翻翻到第1000页的时候接口响应时间从30ms直接飙到1.5s。这不是SQL写得烂而是数据库深分页的物理限制决定的。以MySQL为例LIMIT 100000, 20这条语句的执行逻辑是先扫描出前100020行然后丢弃前100000行只返回最后20行。这意味着你翻得越深数据库扫描的无关数据越多IO开销成倍增长。更麻烦的是这种开销还伴随着回表查询如果查询条件里涉及非主键索引还需要根据索引找到主键再回表取整行数据性能进一步恶化。我测过一组数据单表500万行LIMIT 100, 20的查询耗时约18msLIMIT 100000, 20则飙升到1.2s左右而且这个差距在数据量增长时还会继续拉大。本质上深分页的瓶颈不在数据总量而在偏移量带来的无效扫描。1.2 Redis解决分页问题的核心逻辑那Redis凭什么能解决这个问题关键在于它把“查询”变成了“取数”。传统分页查询的过程是数据库扫描大量数据排序然后丢掉大部分结果。而Redis里的分页无论是用List类型还是Sorted Set类型数据都已经按照既定的顺序排好了。你要第1001页就直接从列表的20000号位置开始取20条时间复杂度只有O(20)根本不存在“扫描前100020条再丢弃”的过程。这就像是图书馆里从架上按编号找书如果你已经知道书的精确位置直接走过去拿出来就行不需要逐本翻阅书架。Redis分页的核心优势就是把这个“位置已知”的模型落到内存里。但这里要说清楚Redis分页不是所有场景都适用。它适合热度高、读取频繁、数据总量在一定范围内比如百万级以内的场景。如果数据量达到上亿级别或者每次查询条件都完全不同那Redis的缓存命中率会很低反而浪费内存。1.3 热点场景为什么大家都在搜“redis分页查询”从搜索热词来看尤其是“java es分页查询超过10000”这个词条能看出很多团队在做ES分页时也遇到了类似问题。ES的from size同样存在深分页上限默认超过10000条就会报错。大家把目光转向Redis本质上是需要一个既能加速查询、又能绕过深分页限制的方案。另外Redis自带的三种数据结构天然适合做分页List支持LRANGE命令按下标范围取元素适合先进先出的固定顺序场景。Sorted Set支持ZRANGE、ZREVRANGE、ZRANGEBYSCORE适合需要按某个权重比如时间戳、分数排序的场景。Hash虽然本身不支持范围查询但可以配合Sorted Set做二级索引用于复杂条件过滤。下面的章节我会分别讲这几种方案的落地细节以及在什么情况下选哪种最合理。2. 数据结构选型与分页方案设计2.1 List类型分页最直接但限制最明显List类型的分页主要靠两个命令实现RPUSH把数据压入列表尾部LRANGE key start stop按下标范围取出数据比如商品列表页每天早上把当天的商品ID按顺序压入一个叫product:list:20250101的List里用户请求第2页假设每页20条时执行LRANGE product:list:20250101 20 39就能直接拿到第2页的数据。这种方案非常直观性能也极好百万级列表的LRANGE操作耗时在1ms以内。但这套方案有两个明显短板只能按下标顺序访问如果列表中间有数据变更比如插入一条新的后续所有下标都会错位。无法按条件过滤所有查询条件只能靠业务层在拿到ID列表后二次处理。所以我的经验是List方案适合“数据相对固定、顺序稳定”的场景比如每日热榜、周报列表、归档数据查询。如果是高频变动数据不建议用List硬扛。2.2 Sorted Set方案更灵活的排序分页Sorted Set是Redis分页场景下我最推荐的结构。每个元素关联一个double类型的scoreRedis会按score从小到大自动排序。分页时只需要用ZRANGE命令# 按score升序取第2页(下标20到39) ZRANGE keyword:search:rank 20 39 # 按score降序取第2页 ZREVRANGE keyword:search:rank 20 39这里的关键设计在于score的选择。最简单的方式是存时间戳这样天然按时间排序但如果你需要插入数据后依然保持最新数据在首位就需要score自增。我常用的做法是插入当前时间的毫秒级时间戳作为score然后取数据时用ZREVRANGE倒序取这样最新发布的内容永远排在前面。如果需要支持按时间范围过滤就用ZRANGEBYSCORE key minScore maxScore配合LIMIT参数实现。相比List方案Sorted Set的最大优势是插入和更新时不会破坏已有排序结构因为score决定了位置而不是物理下标。这对于评论列表、消息列表这种持续有数据追加的场景特别合适。2.3 两种方案的数据结构对比与选型建议维度ListSorted Set适用顺序先进先出、固定顺序按score排序可动态调整插入灵活性尾部插入方便中间插入代价高任意位置插入由score自动排序分页命令LRANGEZRANGE / ZRANGEBYSCORE典型场景热榜、归档数据消息流、评论列表、倒排列表内存占用相对较少多一份score略高数据更新位置会变不稳定只变score位置自动调整如果你需要的是“时间倒序的新闻流”这种模型无脑选Sorted Set。如果只是“第二天的昨日热榜”这种固定快照List更省内存。3. 核心实现Java Spring Boot Redis分页完整落地3.1 环境准备与Redis安装开始编码之前先确认你本地的Redis环境是通的。以MacOS Homebrew为例# 安装Redis brew install redis # 启动服务 brew services start redis # 验证连接 redis-cli ping如果返回PONG说明Redis服务已经就绪。Windows用户建议直接用Redis官方提供的Windows移植版或者用Docker跑一个实例。3.2 基于Sorted Set的通用分页组件我在实际项目中封装过一个通用的Redis分页组件核心思路是把业务数据的ID列表维护在Redis的Sorted Set里分页时先用Redis拿到第N页的ID列表再拿着这些ID去数据库批量查明细数据。先定义操作Redis的核心接口Service public class RedisPageService { Resource private StringRedisTemplate stringRedisTemplate; /** * 将数据ID写入Sorted Set */ public void addToSortedSet(String key, String value, double score) { stringRedisTemplate.opsForZSet().add(key, value, score); } /** * 分页查询Sorted Set中的ID列表 * pageNo从1开始 */ public SetString pageQuery(String key, int pageNo, int pageSize) { long start (long) (pageNo - 1) * pageSize; long end start pageSize - 1; return stringRedisTemplate.opsForZSet().reverseRange(key, start, end); } }这段代码里有几个关键点reverseRange返回的是按score从大到小的序列适合展示“最新优先”的场景。如果业务需要从旧到新展示换成range即可。start和end的计算是闭区间所以end要减1。我第一次写的时候直接用了start pageSize结果每页数据都少一条。reverseRange返回的是Set如果你的业务需要保证数据分页的绝对稳定性这里最好转成List因为部分场景下Set迭代顺序可能和预期有偏差。3.3 结合数据库加载明细数据拿到ID列表后下一步是回查数据库加载完整记录。这是整个方案里最考验设计能力的部分因为Redis只存了ID所有展示字段还得靠数据库。public PageResultOrderVO pageOrders(int pageNo, int pageSize) { // 1. 从Redis中获取当前页的订单ID列表 SetString orderIds redisPageService.pageQuery(order:list:20250101, pageNo, pageSize); // 2. 如果Redis数据为空走全量数据兜底查询 if (orderIds null || orderIds.isEmpty()) { return queryFromDatabase(pageNo, pageSize); } // 3. 批量查询数据库 ListOrder orderList orderMapper.selectBatchIds(orderIds); // 4. 按ID顺序重排列 MapString, Order orderMap orderList.stream() .collect(Collectors.toMap(Order::getOrderId, Function.identity())); ListOrderVO result new ArrayList(); for (String id : orderIds) { Order order orderMap.get(id); if (order ! null) { result.add(convertToVO(order)); } } return new PageResult(result, getTotalFromRedis(order:list:20250101)); }这里有个非常容易踩的坑selectBatchIds返回的List是数据库给的顺序和Redis里的ID顺序不一定一致。所以需要按照Redis返回的ID顺序重新排列结果否则页面展示顺序就会乱掉。3.4 总记录数的缓存策略分页查询还需要返回总记录数这个值如果每次都走SELECT COUNT(*)性能开销也很大。我的方案是把总量也缓存起来public long getTotalFromRedis(String key) { Long size stringRedisTemplate.opsForZSet().zCard(key); return size ! null ? size : 0L; }但这里有个隐藏问题如果Redis里的ID列表因为淘汰策略被清理了一部分ZCARD返回的数量就不准了。我的建议是总量缓存单独设置一个永不过期的Key在数据更新时同步维护一个计数器而不是依赖Sorted Set的容量来推总数量。4. 缓存一致性维护与增量数据同步4.1 双写一致性业务数据更新时需要同步Redis用Redis做分页缓存最核心的挑战不是怎么查而是怎么保证缓存里的数据和数据库一致。常见的做法是在业务代码中所有新增、更新、删除操作完成后同步维护Redis里的Sorted Set。以订单关闭为例Transactional public void cancelOrder(String orderId) { // 1. 更新数据库状态 orderMapper.updateStatus(orderId, CANCELED); // 2. 同步移除Redis中的ID stringRedisTemplate.opsForZSet().remove(order:list:20250101, orderId); }这里必须注意事务边界问题。如果第2步执行失败数据库状态改了但Redis没删就会出现脏数据。我的做法是利用Spring的事务同步器在事务提交成功后再执行Redis操作。Transactional public void cancelOrder(String orderId) { orderMapper.updateStatus(orderId, CANCELED); TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { Override public void afterCommit() { stringRedisTemplate.opsForZSet().remove(order:list:20250101, orderId); } } ); }这样即使Redis操作失败至少数据库状态是正确的可以通过后续的补偿任务来修复缓存。4.2 全量重建缓存方案如果业务比较极端一次性导入10万条历史数据逐条写入Redis显然不现实。这时需要全量重建。我的思路是用管道批量写入避免网络开销放大问题。public void rebuildCache(String key, ListOrder orderList) { stringRedisTemplate.executePipelined((RedisCallbackObject) connection - { for (Order order : orderList) { connection.zAdd( key.getBytes(), order.getCreateTime().getTime(), order.getOrderId().getBytes() ); } return null; }); }executePipelined可以把批量命令一次性发送到Redis大幅减少网络往返次数。实测导入10万条数据使用管道耗时约3秒而逐条插入需要20秒以上。但这里有一个数据类型的坑需要注意在管道模式下connection.zAdd用的是Jedis/ Lettuce 原生的连接接口参数顺序是zAdd(byte[] key, double score, byte[] value)和上层ZSetOperations.add(key, value, score)的参数顺序正好相反。我第一次写的时候没注意导致存进去的数据全部乱套排查了好几个小时。4.3 删除策略与内存控制Redis分页缓存最怕内存爆炸。Sorted Set里每个元素大约占用 100KB 到 200KB 的内存取决于value大小如果每个月的数据都存一份内存很容易撑爆。我建议为每个Key设置过期时间常用两种策略定期过期比如EXPIRE order:list:20250101 259200030天适合按月分表的场景。滑动过期每次读取时刷新过期时间适合高热数据。Redis本身也支持maxmemory-policy配置来淘汰数据我习惯设置allkeys-lru但注意这是全局策略可能把其他业务的缓存也淘汰掉。所以更安全的做法是分业务手动管理过期时间不依赖全局策略。5. 大数据量页面性能优化与坑位总结5.1 超大页码带来的隐性风险Redis分页方案虽然避开了数据库深分页但超大页码本身也会带来新问题。比如用户直接跳到第1000页虽然Redis取数很快但你要一次性把前面19万多个ID全部扫描一遍吗不需要ZREVRANGE可以直接跳到指定下标这是Redis的优势。但另一个隐患是如果在跳页过程中Redis里缓存的数据因为过期、淘汰等原因发生了部分缺失跳页的结果就会和数据库不一致。我遇到过一次线上问题用户在翻到第50页时第49页和50页出现了重复数据原因就是Redis的Sorted Set在第49页的位置上被LRU淘汰了一条后面的数据整体前移了一位。解决办法是在业务层面限制最大可翻页数或者使用“上一页的最后一个score作为下一页的起点”这种基于游标的方案。5.2 游标分页另一种值得参考的方案基于游标的分页在Redis场景里也是常见做法和传统的页码分页相比游标分页可以完全避免偏移量问题# 取第1页页大小20 ZREVRANGEBYSCORE order:list:20250101 inf -inf WITHSCORES LIMIT 0 20 # 根据最后一个元素的score做游标取下一页 ZREVRANGEBYSCORE order:list:20250101 1672500000 -inf LIMIT 0 20这种方式的优点是不会因为删除中间数据导致页码错乱适合评论流、Feed流这种增量数据场景。缺点是用户无法直接跳到任意页码只支持“下一页”操作。在实际项目中如果产品经理要求必须支持跳页我会选择页码分页加Redis缓存如果是信息流产品我会选择游标分页。两者没有绝对的优劣取决于产品形态。5.3 缓存穿透与雪崩应对任何Redis缓存方案都绕不开穿透和雪崩问题。穿透是指查询一个不存在的Key导致请求直接打到数据库。分页场景下如果一个分页Key不存在比如还没生成缓存所有用户请求都会落到数据库瞬间打满连接池。我的做法是布隆过滤器先行public boolean isKeyExists(String key) { // 布隆过滤器判断Key是否存在 if (!bloomFilter.mightContain(key)) { return false; } Long size stringRedisTemplate.opsForZSet().zCard(key); return size ! null size 0; }雪崩则是指大量Key在同一时间过期导致数据库压力突然增大。解决办法是给每个Key的过期时间加一个随机后缀让过期时间分散int randomExpireTime 86400 new Random().nextInt(3600); stringRedisTemplate.expire(key, randomExpireTime, TimeUnit.SECONDS);5.4 常见问题排查与避坑实录问题现象可能原因排查方法分页数据重复Sorted Set中部分数据过期/淘汰用ZREVRANGEBYSCORE输出全量ID对比尾页数据不全数据总量统计不准检查总量是否单独维护计数器缓存重建后排序错乱管道模式下参数顺序写反检查zAdd的参数顺序跳页后数据不一致Redis缓存和数据库未做一致性保障事务提交后再同步RedisRedis内存暴涨Sorted Set未设过期时间或value过大检查MEMORY USAGE key命令“Redis分页快但怎么保证数据不错”是所有使用这个方案的人都会问我的问题。老实说没有银弹只能在设计阶段想清楚这个场景真需要跳页吗数据变动频繁吗能不能接受最终一致性想清楚了这些再用Redis分页你会省掉很多半夜线上告警的麻烦。6. 扩展思路把Redis分页和业务场景做深度结合6.1 排行榜、Feed流与Redis分页的结合Redis分页最适合的业务就是排行榜和Feed流。排行榜本质上就是一个以分数为score的Sorted Set天然支持分页。而Feed流则可以用多个Sorted Set做时间线合并比如关注了100个博主每个博主的动态是一个Sorted Set取合并后的第N页可以用ZUNIONSTORE先生成合并列表再做分页。但要注意ZUNIONSTORE在数据量大时会占用大量临时内存比如100个博主各自有1万条动态合并出来的临时Key就是100万条。我建议合并操作放在低峰期或者使用分片合并策略避免高峰期把Redis打满。6.2 与本地缓存结合的多级缓存架构即便Redis再快网络IO还是比本地内存慢。在并发极高的场景下可以考虑在Redis前面再加一层Caffeine本地缓存。查询链路变成先查本地Caffeine命中直接返回。未命中则查Redis拿到ID列表后批量查数据库。数据返回后回填本地缓存和Redis。这层优化在实际项目里能带来5到10倍的性能提升。但前提是你要能容忍缓存更新延迟因为本地缓存一旦建立后续数据变更需要主动失效或者依赖后台定期重建。6.3 后续可以继续扩展的方向如果我手头这个分页组件还要继续演进我会优先做三件事接入Redis Cluster后让分页Key按业务维度做Hash Tag避免跨Slot访问。设计一个缓存健康度监控页面实时展示各分页Key的大小、命中率、过期时间分布。把分页逻辑抽成注解用AOP实现对业务代码的零侵入。尤其是后面这个方向因为分页逻辑往往散落在各个业务模块里抽成注解后维护成本会大幅降低。我在新项目里已经开始做这一层封装了后续有成果了再来分享具体实现。回到最开始的问题数据库深分页性能差怎么破我的答案不是“不用数据库”而是“让数据库只查当前页需要的数据把偏移量的压力挪到Redis的索引上”。数据还在库里索引在内存里查询走索引明细走数据库各司其职性能自然就上来了。这套方案我前后在三个项目里落地过整体稳定性不错你要是有具体的业务场景也建议先在自己业务里小范围压测验证下再决定要不要全量推开。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑