在后端开发中,分库分表是解决单库单表数据量爆炸、并发瓶颈的必经之路。但随之而来的,是一系列“简单功能变复杂”的坑——分页查询就是最典型的一个。

单库单表时代,我们用 LIMIT offset, size 就能轻松实现分页,比如查询第11页(每页10条),一句 SELECT * FROM t_order ORDER BY create_time DESC LIMIT 100, 10 就能搞定,数据库引擎能通过索引快速定位全局有序数据,性能稳定。

可当数据被拆分到多个库、多个表后,这句简单的SQL就彻底“失灵”了:要么查出来的数据重复、缺失,要么深度分页时性能雪崩,甚至出现排序错乱。很多开发者卡在这里反复调试,却始终找不到优雅的解决方案。

今天,我们就彻底把这个问题讲透——从问题根源出发,拆解4种主流实战方案,分析各自的优缺点与适用场景,再总结避坑技巧,帮你在实际业务中快速选型、落地。

一、先搞懂:分库分表后,分页查询为什么会“乱”?

分页查询变难的核心根源,不是“数据多”,而是分布式环境下的全局有序性缺失——单库单表中,数据库维护着全局有序索引(比如 create_time 上的B+树),offsetsize是基于全局数据的截取;但分库分表后,每个分片都是独立的数据库实例,仅维护本地数据的有序性,缺乏“全局有序视图”。

具体来说,会遇到3个核心问题,每一个都能直接影响业务体验:

1. 深度分页性能暴跌(最常见痛点)

offset 过大时(比如 LIMIT 100000, 10),查询耗时会呈指数级增长。举个例子:假设 t_order 按用户ID哈希分为10个分片(t_order_0至t_order_9),如果沿用单库分页逻辑,会发生两件事:

  1. 应用会向10个分片各发送 SELECT * FROM t_order_x ORDER BY create_time DESC LIMIT 100010(要取到全局第100001-100010条,每个分片都要查前100010条);
  2. 收集10个分片返回的100100条数据,在应用层排序后,再截取前10条作为结果。

这就意味着,原本只需扫描1010条数据的操作,变成了扫描100万+条数据,大量冗余数据的传输和内存排序,直接拖垮应用性能,甚至导致查询超时。某电商案例显示,LIMIT 10000000, 10 的执行时间是 LIMIT 10, 10 的200倍以上。

2. 分页结果不准确(数据重复/缺失)

很多时候,分页查出来的数据要么重复出现,要么莫名缺失,核心原因有两个:

  • 分片规则与排序字段不匹配:比如按用户ID分片,却按创建时间排序,不同分片的时间范围重叠,聚合时很容易漏掉跨分片的连续数据,或重复统计同一批数据;
  • 数据动态变更:查询过程中,某分片插入、删除或更新了数据,会导致前后页数据重叠(比如第1页末尾数据与第2页开头数据重复),或某条数据凭空消失。

3. 多维度排序无法支持

当业务需要按非分片键多字段排序时(比如“按订单金额降序+支付时间升序”),性能会极差甚至无法实现。因为非分片键字段没有全局索引,必须扫描所有分片的全量数据才能完成排序,相当于“分布式全表扫描”,分片数越多,性能越差。

二、4种主流实战方案:从“能用”到“优雅”

分库分表分页没有“银弹”,所有方案都是“trade-off”(取舍)。下面4种方案,覆盖从浅分页到深分页、从简单查询到复杂查询的所有场景,建议结合自身业务选型。

方案1:全局扫描法(暴力法)—— 最简单,但性能最差

这是最基础、最通用的方案,本质是“先拉取、再聚合、最后截取”,完全沿用单库分页逻辑,仅在应用层增加聚合排序步骤。

实现思路

  1. SQL改写:将分页SQL发送到所有分片,注意每个分片都要查询“offset+size”条数据(避免遗漏全局数据);
  2. 并行查询:通过线程池并行调用所有分片,减少等待时间;
  3. 全局聚合:收集所有分片的返回结果,在应用层按排序字段做全局排序;
  4. 精准截取:从排序后的全局列表中,截取 offsetoffset+size 的数据,作为最终分页结果。

伪代码示例

// 1. 构建分片SQL(每个分片都查offset+size条)
String sql = "SELECT * FROM t_order_{0} ORDER BY create_time DESC LIMIT " + (offset + size);
// 2. 并行查询所有分片
List<List<Order>> allShardData = shardExecutor.parallelExecute(sql);
// 3. 全局聚合排序
List<Order> globalData = allShardData.stream()
    .flatMap(Collection::stream)
    .sorted(Comparator.comparing(Order::getCreateTime).reversed())
    .collect(Collectors.toList());
// 4. 截取目标页数据
int start = offset;
int end = Math.min(offset + size, globalData.size());
List<Order> pageData = globalData.subList(start, end);

优缺点与适用场景

  • 优点:开发成本极低,无需修改分片规则,对业务透明,支持任意跳页和多维度排序;
  • 缺点:深度分页性能雪崩,冗余数据传输量大,内存排序压力大,分片数越多性能越差;
  • 适用场景:分片数量少(<4)、分页不深(offset<1000)、查询频率低的场景(比如后台管理系统的低频分页查询)。

方案2:全局唯一键二次查询法—— 浅分页首选,性能优化明显

方案1的核心问题是“传输全量数据”,而方案2通过“先查主键、再查详情”的方式,大幅减少数据传输量,是浅分页(offset<1万)场景的首选方案。

核心思路

利用全局唯一有序键(比如雪花ID、create_time+order_id),将分页查询拆分为两次:第一次仅查询主键和排序字段(数据量极小),聚合排序后确定目标页的主键列表;第二次根据主键列表,路由到对应分片查询完整数据,避免传输冗余字段。

实现步骤

  1. 第一次查询(拉取主键):向各分片发送仅查询排序字段和主键的SQL,减少数据传输量; -- 分片t_order_0的查询(仅查主键和排序字段) ``SELECT order_id, create_time FROM t_order_0 ORDER BY create_time DESC LIMIT 10010;
  2. 全局聚合排序:收集所有分片的(order_id, create_time)数据,在应用层按 create_time 排序,截取第10000-10010条的 order_id 列表(如 (100001, 100002, …, 100010));
  3. 第二次查询(拉取详情):根据 order_id 的分片规则(比如哈希路由),路由到对应分片,查询完整数据并排序; -- 路由到order_id对应的分片,查询完整数据 ``SELECT * FROM t_order_0 WHERE order_id IN (100001, 100002, ..., 100010) ORDER BY create_time DESC;

优缺点与适用场景

  • 优点:相比方案1,数据传输量减少80%以上(主键仅8-16字节,完整行可能数百字节),浅分页性能提升明显,支持跳页;
  • 缺点:offset过大时(如10万),第一次查询仍需拉取大量主键,性能依然衰减;依赖全局唯一有序键,需额外维护;
  • 适用场景:浅分页(offset<1万)、需要跳页、对性能有一定要求的场景(比如后台管理系统的常规分页查询)。

方案3:游标分页法(无偏移量)—— 深分页首选,性能恒定

无论是方案1还是方案2,都无法解决“offset过大导致的性能雪崩”。而游标分页法放弃了 offset,改用“上一页最后一条数据的游标位置”作为查询条件,实现“无偏移量”分页,性能与页码无关,是深分页场景的最优解。

核心思路

类比我们读书:传统offset分页是“翻到第100页”,而游标分页是“从第99页最后一行继续读”。每次查询时,用上次分页返回的“最后一条数据的排序字段值”(游标)作为过滤条件,仅查询游标之后的数据,无需扫描前面的冗余数据。

实现步骤(以订单表为例)

  1. 查询第一页:无需游标,直接查询前10条数据,记录最后一条数据的游标

    (比如 create_time=2026-02-05 10:00:00,order_id=100010);
           -- 第一页:按创建时间倒序,取10条
    SELECT * FROM t_order ORDER BY create_time DESC, order_id DESC LIMIT 10;
    

  2. 查询下一页:用游标作为过滤条件,查询游标之后的数据,同样取10条,更新游标;-- 下一页:用上次的游标过滤,避免offset

    SELECT * FROM t_order 
    WHERE create_time < '2026-02-05 10:00:00' 
    OR (create_time = '2026-02-05 10:00:00' AND order_id < 100010)
    ORDER BY create_time DESC, order_id DESC LIMIT 10;
    

  3. 分库分表适配:将上述SQL发送到所有分片,各分片仅查询符合游标条件的10条数据,应用层聚合排序后,再取前10条(避免跨分片数据遗漏)。

关键优化:多字段排序的游标构建

如果排序字段不唯一(比如 create_time 可能重复),仅用单个字段作为游标会导致数据缺失。此时需要用“排序字段组合”作为游标(如 create_time+order_id),确保游标唯一,避免遗漏数据。

性能对比(1000万条数据测试)

页码Offset分页耗时游标分页耗时性能提升
第1页5ms5ms-
第100页150ms6ms25倍
第1000页1.2s6ms200倍
第10000页8.5s7ms1200倍

优缺点与适用场景

  • 优点:性能恒定,与页码无关,深分页场景优势极大;无需扫描冗余数据,内存压力小;支持大数据量连续浏览;
  • 缺点:不支持跳页(无法直接跳到第100页),无法计算总页数;对数据一致性要求高,数据动态变更可能导致重复/缺失;
  • 适用场景:深分页(offset>1万)、连续浏览场景(比如订单流水、日志流水、Feed流、商品列表滚动加载)。

方案4:搜索引擎辅助法—— 复杂查询、多维度排序首选

如果业务需要“多条件筛选+多维度排序+深度分页”(比如电商的商品搜索分页、用户行为日志查询),前面3种方案都无法满足性能要求。此时最优雅的方式,是采用“查询与存储分离”的架构,用搜索引擎(Elasticsearch/OpenSearch)承担分页查询压力。

核心架构

数据的CRUD操作在MySQL分库分表中完成,分页查询、多条件筛选、排序操作在搜索引擎中完成,通过binlog同步数据,实现“存储强一致、查询高性能”。

  1. 存储层:MySQL分库分表,负责数据的写入、更新、删除,保证数据强一致性;
  2. 同步层:通过Canal、Debezium等工具,MySQL的binlog,将数据增量同步到搜索引擎(ES),确保数据一致性(延迟可控制在100ms内);
  3. 查询层:应用的分页查询、多条件筛选请求,直接发送到ES,利用ES的分布式索引优势,高效完成排序和分页;MySQL仅承担详情查询、写入等操作。

实现示例(ES分页查询)

{
  "query": {
    "bool": {
      "must": [
        {"term": {"user_id": 123}},
        {"range": {"create_time": {"gte": "2026-01-01"}}}
      ]
    }
  },
  "sort": [{"create_time": "desc"}, {"order_id": "desc"}],
  "from": 100000,  // 深分页无压力
  "size": 20
}

优缺点与适用场景

  • 优点:功能最强,天然支持多条件检索、多维度排序、深度分页,性能优异;无需修改分库分表架构,对业务侵入性低;
  • 缺点:架构复杂度提升,需维护ES集群和数据同步链路;需处理同步延迟(可通过“关键数据查主库”兜底);ES深度分页需额外优化(如用search_after替代from/size);
  • 适用场景:多条件筛选+多维度排序+深度分页的场景(比如电商商品搜索、用户行为分析、日志查询系统)。

三、进阶优化:预计算锚点法(非实时场景补充)

对于非实时场景(比如报表系统、历史数据查询),还可以采用“预计算分页锚点”的方式,进一步优化性能,减少无效扫描。

核心思路

通过定时任务,预计算各分片的“分页锚点”(比如每1000条记录的排序字段值),存储在Redis等元数据服务中。查询时,先通过锚点定位目标数据所在的分片及范围,减少各分片的扫描量。

实现步骤

  1. 锚点计算:每日凌晨对各分片执行 SELECT id, create_time FROM orders ORDER BY create_time LIMIT 0, 1000, 2000...,记录每1000条的 create_time 作为锚点,存入Redis;
  2. 查询路由:用户查询第100万页(offset=999990)时,元数据服务计算锚点范围(999990≈1000×999.99),获取各分片第999个锚点的 create_time 值,确定全局最小 time_min
  3. 精准扫描:各分片仅查询 create_time >= time_min 的数据,按 create_time 排序后取前20条(冗余量避免边界误差),汇总后再排序取目标10条。

适用场景

非实时场景(如报表系统、历史数据查询),对数据延迟容忍度高(分钟级),但对查询性能要求高的场景。

四、避坑指南:5个最容易踩的坑及解决方案

即使选对了方案,落地时也容易踩坑。下面5个坑,是我在实际项目中反复遇到的,附上具体解决方案,帮你少走弯路。

坑1:偏移量过大导致性能雪崩

「表现」:offset超过1万后,查询耗时急剧增加,甚至超时;「根源」:全局偏移量无法转化为本地偏移量,每个分片都要扫描大量冗余数据;「解决方案」:禁用深分页跳页,改用游标分页;或用预计算锚点法优化;浅分页场景用方案2。

坑2:分片键与排序字段不匹配导致分页错乱

「表现」:分页数据无序、缺失;「根源」:按哈希分片(如用户ID),却按非分片键(如创建时间)排序,各分片数据范围重叠;「解决方案」:分表时尽量选择“排序字段+分片键”的组合(如按创建时间范围分片);若已按哈希分片,排序时需在应用层做全局排序,确保结果有序。

坑3:数据动态变更导致重复/缺失

「表现」:前后页数据重复,或某条数据凭空消失;「根源」:查询过程中,分片插入、删除、更新数据,导致全局排序结果变化;「解决方案」:游标分页时,用“唯一游标”(如create_time+order_id);关键业务场景,分页查询走主库,避免从库同步延迟;可添加版本号,过滤已删除/更新的数据。

坑4:多表关联分页复杂度飙升

「表现」:关联分库分表后的两张表(如订单表+用户表)分页,性能极差;「根源」:关联字段可能不在同一个分片,需跨分片关联,再分页,成本极高;「解决方案」:优先用宽表设计(将用户信息冗余到订单表),避免跨表关联;若必须关联,在业务层做两次查询(先查订单分页,再批量查用户信息),而非数据库层关联。

坑5:读写分离场景下的数据不一致

「表现」:分页查询从库,出现数据缺失(主库已写入,从库未同步);「根源」:主从同步延迟;「解决方案」:关键业务的分页查询走主库;非关键业务,可等待从库同步完成(如延迟100ms)再查询;或用“主从一致性校验”,发现缺失数据时从主库补查。

五、实践建议:如何快速选型落地?

结合前面的方案和避坑技巧,给出4条落地建议,帮你快速选型,降低开发成本:

1. 优先按业务场景选方案(核心原则)

  • 连续浏览场景(Feed流、订单流水):首选游标分页法;
  • 浅分页+跳页场景(后台管理系统):首选全局唯一键二次查询法;
  • 多条件筛选+多维度排序(商品搜索):首选搜索引擎辅助法;
  • 非实时场景(报表、历史数据):补充预计算锚点法;
  • 分片少、查询低频:可用全局扫描法(快速落地)。

2. 分表设计时,提前考虑分页需求

分表前,明确分页的排序字段和查询场景,尽量选择“排序字段+分片键”的组合(如按创建时间范围分片),减少后续分页优化的成本;避免用无意义的哈希键分片(如随机ID),否则多维度排序会非常困难。

3. 避免过度优化,优先满足业务需求

如果业务分页查询频率低、数据量不大,无需追求“最优方案”,用全局扫描法快速落地即可;只有当分页成为性能瓶颈时,再逐步优化为游标分页或搜索引擎辅助法。

4. 做好坚控与兜底

坚控各分片的查询耗时、内存使用情况,及时发现热点分片;给分页查询设置超时时间(如500ms),超时后降级(如返回前100页数据);关键业务场景,预留“回退方案”(如切换到单库查询、临时关闭分库分表)。

六、总结:没有银弹,适配业务才是关键

分库分表下的分页查询,本质是“解决分布式环境下的全局有序性和性能平衡”问题。我们不需要掌握所有方案,只需记住:

  • 浅分页+跳页:用全局唯一键二次查询法;
  • 深分页+连续浏览:用游标分页法;
  • 复杂查询+多维度排序:用搜索引擎辅助法;
  • 所有方案都有取舍,落地时需结合业务场景、数据量、性能要求,平衡开发成本和用户体验。

随着分布式数据库(如ShardingSphere、TiDB)的发展,很多分页问题已经可以通过中间件自动处理(如ShardingSphere的分页插件,可自动优化offset分页)。但了解底层原理和手动优化方案,能让我们在遇到复杂场景时,依然能快速定位问题、解决问题。

最后,如果你在实际项目中遇到了特殊的分页场景,欢迎在评论区交流,一起探讨最优解决方案~

本站提供的所有下载资源均来自互联网,仅提供学习交流使用,版权归原作者所有。如需商业使用,请联系原作者获得授权。 如您发现有涉嫌侵权的内容,请联系我们 邮箱:alixiixcom@163.com