分库分表后如何进行分页查询?


一则或许对你有用的小广告

欢迎加入小哈的星球,你将获得:专属的实战项目(4个项目都能学) / 1v1 提问 / 简历修改 / Java 学习路线 / 社群讨论 / 学习打卡 / 每月赠书

  • 《Spring AI 项目实战(问答机器人、RAG 智能客服、联网搜索)》已完结,基于 Spring AI + Spring Boot 3.x + JDK 21...查看介绍

  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot 3.x + JDK 17...查看介绍;演示链接:http://116.62.199.48:7070/

  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接:http://116.62.199.48/

  • 新开坑项目:《从零手撸:秒杀系统高并发优化实战》 正在更新中...,查看介绍

截止目前,星球内专栏累计输出 150w+ 字,讲解图 5110+ 张,还在持续爆肝中.. 后续还会上新更多项目,已有 4700+ 小伙伴加入学习,欢迎点击围观

面试考察点

  1. 分库分表实战经验:面试官想知道的,是你有没有在项目里真踩过分页查询的坑。背几套方案没用,没做过的人会卡在 "跨库如何合并" 这种细节上。

  2. 方案权衡能力:分库分表的分页没有银弹,每种方案都有自己的代价。面试官想看的是你能不能根据业务场景做权衡,而不是死记一种 "全局合并"。

  3. 分布式系统思维:跨库查询本质是分布式数据访问问题,涉及网络 IO、内存合并、数据一致性这些事。能不能站在分布式视角看问题,是高级工程师的一道分水岭。

核心答案

分库分表后的分页查询,核心矛盾是:SQL 的 LIMIT 在单表好用,跨库就废了。因为每个分片只能返回自己那部分数据,无法保证全局有序。

主流方案有 4 种,按落地难度从低到高排:

方案 核心思路 性能 复杂度 适用场景
禁止跳页(游标分页) last_id + LIMIT 替代 OFFSET ⭐⭐⭐⭐⭐ 列表流、瀑布流
全局内存合并法 每个分片取 N 条,内存归并排序 ⭐⭐ 中小数据量、深翻页少
二次查询法 第一次查定位时间戳,第二次精确查 ⭐⭐⭐ 偶尔深翻页
异构索引(ES / HBase) 用专门搜索引擎处理复杂查询 ⭐⭐⭐⭐⭐ 极高 复杂查询、深翻页频繁

生产中最常用的是游标分页 + 异构索引的组合拳,下面详细讲。

深度解析

一、为什么跨库分页这么难?

先看一个具体的例子。假设订单表按 user_id 分了 4 个库,每页 10 条,要查 "第 3 页"(LIMIT 20, 10):

跨库 OFFSET 分页
跨库 OFFSET 分页

跨库分页排序
跨库分页排序

上图展示了“全局内存合并法”的核心问题。每个分片只能基于自己的数据返回 "第 21-30 条",但跨库合并后,这 40 条数据并不一定是全局的前 30 条之后的数据。原因是:

  • 每个库内部有序,库与库之间无序:库 1 的第 21 条可能比库 2 的第 25 条时间晚
  • 要保证全局第 21-30 条正确,每个库必须返回前 30 条(LIMIT 0, 30),内存合并后才能拿到真正的全局前 30 条,再从第 21 条开始取
  • 第 N 页越深,每个分片要查的数据越多:到第 100 页(LIMIT 990, 10),每个分片要返回前 1000 条,4 个库就是 4000 条数据加载到内存

这也就是为什么深翻页是分库分表的死穴。

二、方案一:游标分页(最推荐)

核心思路:不要 OFFSET,用上一页最后一条记录的某个字段做游标

-- 第一页
SELECT * FROM t_order
WHERE user_id = ?
ORDER BY id DESC
LIMIT 10;

-- 第二页(传上一页最后一条的 id)
SELECT * FROM t_order
WHERE user_id = ?
  AND id < ?  -- last_id
ORDER BY id DESC
LIMIT 10;

游标分页流程
游标分页流程

游标分页
游标分页

这方案最妙的地方是:不管翻到第几页,每个分片永远只返回 N 条数据,性能稳得很。

优点:

  • 性能稳定,深翻页也不慢
  • 实现简单,每个分片 SQL 都走索引

缺点:

  • 不能跳页:用户没法从第 1 页直接跳到第 10 页,只能一页一页翻
  • 业务体验受限:传统的分页 UI 不适用,需要改成 "加载更多" 或 瀑布流

这个方案在很多大厂的生产环境都用,比如朋友圈、微博 feed 流、订单列表(手机端)。

三、方案二:全局内存合并法

这是最朴素的想法:让每个分片都查,应用层在内存里合并排序

public List<Order> queryPage(int pageNo, int pageSize) {
    int offset = (pageNo - 1) * pageSize;
    // 每个分片都要取前 offset + pageSize 条
    int limit = offset + pageSize;

    // 并行查询所有分片
    List<CompletableFuture<List<Order>>> futures = new ArrayList<>();
    for (int shard : shards) {
        futures.add(CompletableFuture.supplyAsync(() ->
            orderMapper.queryByShard(shard, limit)
        ));
    }

    // 合并所有分片结果
    List<Order> all = futures.stream()
        .map(CompletableFuture::join)
        .flatMap(List::stream)
        .collect(Collectors.toList());

    // 全局排序后取目标页
    all.sort(Comparator.comparing(Order::getCreateTime).reversed());
    return all.stream()
        .skip(offset)
        .limit(pageSize)
        .collect(Collectors.toList());
}

问题很明显:

  • 翻得越深,越慢。第 100 页每个分片要返回 1000 条,4 个库 4000 条数据进内存
  • 内存压力大,GC 风险高
  • 分片数越多,性能越差

适用场景: 分片数少(2-4 个)、用户翻页深度浅(一般不超过 10 页)。

ShardingSphere 默认就用的这种归并策略,对各分片已排序的结果做流式归并,时间复杂度能压到 O(n),但前提是数据本身在分片内有序。

四、方案三:二次查询法

如果业务确实需要支持跳页,又不想深翻页慢到爆,可以用二次查询法。思路是利用两次查询缩小每个分片的扫描范围,分两步走:

第一次查询:把原 SQL OFFSET X LIMIT Y 改写成 OFFSET X/N LIMIT Y(N 是分片数),下发到每个分片。比如原来要查 OFFSET 20 LIMIT 10,4 个分片就变成各自查 OFFSET 5 LIMIT 10

找最小值:把第一次查询所有分片返回的数据合在一起,找出排序字段的最小值 time_min

第二次查询:所有分片执行 WHERE time >= time_min ORDER BY time,把 time_min 之后的数据全部捞回来。

合并结果:第二次查询的数据在内存里合并排序,再从第 X 条开始取 Y 条,就是最终的全局分页结果。

优点:

  • 比全局合并法数据量大幅减少
  • 结果精确,支持跳页

缺点:

  • 需要两轮查询,逻辑复杂
  • 实现成本高,工程落地不多

这个方案偏理论派,实际生产中用得不多,了解思路即可。面试时讲清楚原理就够了。

五、方案四:异构索引(生产终极方案)

核心思路:用 ES / HBase 等专门处理复杂查询的中间件,主库只管写入

ES 分页查询
ES 分页查询

ES 异构索引
ES 异构索引

上图是异构索引的完整链路,几个关键点说一下:

  • 写入路径:业务正常写入 MySQL 分库分表,Canal 监听 binlog 变更,通过 Kafka 投递到 Elasticsearch
  • 查询路径:分页查询先打 ES,ES 负责复杂的条件过滤、排序、分页;拿到主键 ID 列表后,回 MySQL 分片按主键批量取详细数据
  • 解耦点:ES 专门解决查询复杂度,MySQL 专注于存储与事务,谁也不抢谁的活

优点:

  • 性能优秀,深翻页也能秒级返回
  • 支持任意复杂查询条件(多字段组合、模糊搜索)
  • 主库压力小,写性能不受影响

缺点:

  • 架构复杂度急剧上升,要维护 Canal + MQ + ES 一整套链路
  • 数据延迟:从 MySQL 到 ES 通常有秒级延迟,强一致性场景要慎用
  • 成本高,ES 集群本身就要不少机器

美团、饿了么、有赞这类公司都是这个套路。它的本质是 CQRS(读写分离)思想,把写模型和读模型分开。

面试高频追问

  1. 追问一:游标分页和 OFFSET 分页的本质区别是什么?

    • OFFSET 是 "跳过 N 条",数据库要扫描 N 条才能定位;游标是 "条件过滤",直接走索引定位。前者复杂度是 O(n+m),后者是 O(log N + m)。
  2. 追问二:分库分表后,count 总数怎么算?

    • 同样是难题。常用方案:① 每个分片 count 后求和(慢);② 维护一张统计表(强一致难);③ ES 异构(推荐);④ 业务上尽量避免精确 count,用 "已加载完毕" 替代总数。
  3. 追问三:分页查询时,分片键必须是查询条件吗?

    • 是的!不带分片键的查询会广播到所有分片,性能急剧下降。所以分片键的选择要慎重,订单场景一般用 user_id,确保 C 端查询都带分片键。

常见面试变体

  • "分库分表后,如何实现跨库的 ORDER BY?"
  • "为什么分库分表后深翻页会变慢?怎么优化?"
  • "订单系统分库分表后,用户要导出全部订单怎么办?"

记忆口诀

分页四方案,按场景挑:

  • 瀑布流 → 游标分页
  • 浅翻页 → 内存合并
  • 偶尔深翻 → 二次查询
  • 复杂查询 → ES 异构

总结

分库分表后的分页查询没有银弹,核心是接受 "深翻页是反模式" 这个事实。生产中最常见的组合是 C 端用游标分页、B 端用 ES 异构索引。回答这道题的时候,先把 "为什么难" 讲清楚,再把方案按场景权衡展开,最后落到 CQRS 思想,面试官会觉得你既会写代码,也懂架构。