在数据库分库分表架构下进行分页查询,是一个经典的技术挑战。随着业务数据量的增长,单库单表往往无法满足性能和存储需求,于是采用水平拆分(分库分表)将数据分散到多个独立的物理节点上。但数据分布到多个节点后,原本在单库上简单的 LIMIT offset, size 分页操作将变得复杂,因为各分片的数据是独立的,无法直接通过一条 SQL 实现跨分片的排序和分页。本文将详细阐述分库分表下分页查询的原理、常见方案及其优缺点。
一、分库分表对分页查询带来的挑战
假设我们将一张订单表按照订单ID哈希分到了 4 个库中,每个库又分了 8 张表,总共 32 个物理表。现在需要执行如下查询:
sql
SELECT * FROM order
WHERE status = 1
ORDER BY create_time DESC
LIMIT 10000, 20;
如果是在单库单表,数据库可以直接利用索引快速找到第 10001 到 10020 条记录返回。但在分库分表环境下:
· 数据分散在多个分片,每个分片都有自己的数据集合。
· 即使每个分片都执行上述 SQL(取各自分片的 LIMIT 10000, 20),得到的结果只是该分片上按时间排序的第 10001-10020 条记录,将这些结果合并起来并不一定是全局的第 10001-10020 条记录。
· 因为全局排序需要考虑所有分片的数据交错排序,简单的各分片独立分页无法得到正确结果。
正确的做法需要从所有分片中获取足够多的数据,在应用层进行全局排序和截取。但随着偏移量增大,需要查询的数据量也会急剧增加,导致性能严重下降。这就是分库分表下分页查询的主要挑战。
二、常见分页查询方案
- 全局查询法(最朴素方案)
原理:
由应用层向所有分片发起查询,每个分片执行相同的 SQL(带 ORDER BY 和 LIMIT offset, size),然后应用层接收到所有分片返回的数据后,在内存中进行归并排序,最后取出全局第 offset 到 offset+size 条记录。
缺点:
· 随着 offset 增大,每个分片需要返回的数据量也增大(每个分片需要返回 offset+size 条记录),网络传输和内存开销巨大。
· 假设总分片数为 N,查询偏移量为 offset,则实际从数据库拉取的数据总量为 N * (offset + size),随着 offset 增大呈线性增长,性能极差。
· 不适合深分页场景。
适用场景:
数据总量不大、分片数量较少、或者 offset 较小的场景(例如只查询前几页)。
- 禁止跳页,使用游标(CURSOR)分页
原理:
利用排序字段的唯一性(例如主键或时间戳+ID),每次查询时携带上一页最后一条记录的排序字段值,然后查询所有分片中大于该值的记录,再合并排序取前 size 条。
例如上一页最后一条记录的 create_time 为 '2023-01-01 10:00:00',ID 为 10086,则下一页查询 SQL 为:
sql
SELECT * FROM order
WHERE status = 1
AND (create_time, id) > ('2023-01-01 10:00:00', 10086)
ORDER BY create_time, id
LIMIT 20;
各分片执行此查询后,应用层将所有分片返回的结果(每个分片最多 20 条)合并排序,再取前 20 条即为下一页数据。
优点:
· 每次查询各分片只需返回 size 条记录,网络传输和内存消耗小,性能稳定,不受偏移量影响。
· 适用于无限滚动、顺序翻页的场景。
缺点:
· 无法跳页(例如直接跳到第 100 页),只能一页一页往后翻。
· 需要排序字段能唯一确定顺序(通常加主键辅助)。
适用场景:
App 列表、新闻资讯等移动端无限滚动场景,以及不要求随机跳页的业务系统。
- 使用数据库中间件或分库分表组件
许多分库分表中间件(如 Apache ShardingSphere、MyCAT、TDDL 等)内置了分页查询支持。其底层通常采用全局查询法,但会进行一些优化:
· 流式处理 + 归并排序:中间件逐条从各分片获取数据,在内存中构建一个优先队列(堆),不断从各分片拉取下一条数据,直到填满 size 条为止。这样可以避免一次性拉取大量数据,但每个分片仍需按照排序顺序返回所有数据(直到满足全局前 size 条),实际上仍然需要扫描 offset + size 条数据,只是内存占用较小。
· 基于时间戳的优化:部分中间件允许配置分片键与排序键的关系,如果排序键包含分片键,则可直接定位到单个分片,避免全分片扫描。
优缺点:
· 优点:对应用透明,开发简单,适合中小规模系统。
· 缺点:深分页性能依然不佳,因为中间件仍然需要从每个分片拉取大量数据(直到堆顶取够 size 条)。例如要取第 10000 页,每页 20 条,则 offset=199980,中间件需要从每个分片拉取至少 199980+20 条数据进行排序,成本极高。
- 二次查询法(部分中间件采用)
原理:
为了减少数据拉取量,可以先获取每个分片的"分片页",再通过二次查询修正数据。
假设需要查询全局第 offset 页(每页 size 条),具体步骤:
- 向所有分片发送查询,ORDER BY sort_col LIMIT offset, size,获取每个分片的"分片页数据"。
- 从这些分片页中,找出最小的排序值(记为 min_sort)和最大的排序值(记为 max_sort),以及每个分片返回的最后一个排序值。
- 根据这些边界值,估算全局第 offset 条记录可能在的排序值范围。
- 构造一个更精确的查询条件(例如 sort_col BETWEEN min_sort AND max_sort),再次向所有分片查询该范围内的所有数据。
- 将二次查询的数据在内存中排序,精确截取出全局第 offset 页。
这种方案比全局查询法减少了一些数据传输量,但实现复杂,且精度依赖于数据分布均匀性,极端情况下仍可能拉取大量数据。
- 基于索引表/全局排序表
原理:
额外建立一张全局索引表(可能也在分库分表中),只包含主键和排序字段(如 create_time)。每次插入业务数据时,同时在索引表中插入一条记录,索引表按排序字段全局排序(或通过分片键保证局部有序)。分页查询时,先查询索引表获得主键列表,再根据主键去各分片拉取完整数据。
例如索引表结构:index_table(id, sort_key, shard_key)。查询第 offset 页的 20 条记录:
- 从索引表中查询 SELECT id FROM index_table ORDER BY sort_key LIMIT offset, size。
- 得到 20 个主键后,按分片键分组,批量从各分片拉取完整数据。
优点:
· 查询性能高,深分页友好,因为索引表可以单独优化(如使用单库单表,或者也分库但分片键与业务表不同)。
· 分页逻辑集中在索引表,业务表查询简单。
缺点:
· 需要维护索引表的一致性,增加了写入开销(双写或异步同步)。
· 如果索引表也分库分表,需要设计合适的分片键,否则仍面临跨分片查询。
- 使用搜索引擎(如 Elasticsearch)作为辅助索引
原理:
将需要参与查询、排序的字段同步到搜索引擎(如 Elasticsearch)中。分页查询时,先在 ES 中完成排序、分页,得到主键列表,然后根据主键去数据库拉取完整数据(可能涉及多个分片,但可根据主键路由到具体分片)。
优点:
· ES 擅长海量数据的检索、排序和分页,支持深分页(Scroll/After)和随机跳页。
· 可同时支持复杂的查询条件。
缺点:
· 引入额外组件,增加系统复杂度和维护成本。
· 数据同步存在延迟(准实时),对实时性要求极高的场景需谨慎。
· 需要解决 ES 与数据库的数据一致性问题。
三、总结与建议
方案 适用场景 优点 缺点
全局查询法 偏移量小、分片少 简单直接 深分页性能差
游标分页 顺序翻页、不支持跳页 性能稳定,无深分页问题 无法跳页
中间件 中小规模系统,透明分片 开发成本低 深分页性能差
二次查询法 数据分布均匀,可接受一定误差 比全局查询优化 实现复杂,极端情况性能下降
索引表 允许写入放大,对实时性要求高 查询性能好 维护成本高,需双写
搜索引擎 海量数据、复杂查询、可接受延迟 功能强大,支持深分页 引入额外组件,数据同步延迟
实际选型建议:
· 首先评估业务是否真的需要"深分页+随机跳页"。很多时候产品可以引导用户使用"加载更多"而非跳页,从而使用游标分页,这是最简单高效的方案。
· 如果必须支持随机跳页,且数据量不大(百万级),可以考虑单库单表或使用中间件但限制最大 offset(如不允许超过 10000 页),并在业务上做限制。
· 对于 TB 级数据,且频繁深分页,建议引入搜索引擎(如 ES)或构建全局索引表。
· 无论哪种方案,都应尽量避免使用 LIMIT M, N 这种深分页形式,尽量通过条件缩小范围(例如按时间范围分段查询)。
理解分库分表下分页查询的本质------将全局排序分散到各分片后再合并,就能针对业务特点选择最合适的折中方案。