分库分表后如何进行分页查询?
一则或许对你有用的小广告
欢迎加入小哈的星球,你将获得:专属的实战项目(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+ 小伙伴加入学习,欢迎点击围观
面试考察点
-
分库分表实战经验:面试官想知道的,是你有没有在项目里真踩过分页查询的坑。背几套方案没用,没做过的人会卡在 "跨库如何合并" 这种细节上。
-
方案权衡能力:分库分表的分页没有银弹,每种方案都有自己的代价。面试官想看的是你能不能根据业务场景做权衡,而不是死记一种 "全局合并"。
-
分布式系统思维:跨库查询本质是分布式数据访问问题,涉及网络 IO、内存合并、数据一致性这些事。能不能站在分布式视角看问题,是高级工程师的一道分水岭。
核心答案
分库分表后的分页查询,核心矛盾是:SQL 的 LIMIT 在单表好用,跨库就废了。因为每个分片只能返回自己那部分数据,无法保证全局有序。
主流方案有 4 种,按落地难度从低到高排:
| 方案 | 核心思路 | 性能 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 禁止跳页(游标分页) | 用 last_id + LIMIT 替代 OFFSET |
⭐⭐⭐⭐⭐ | 低 | 列表流、瀑布流 |
| 全局内存合并法 | 每个分片取 N 条,内存归并排序 | ⭐⭐ | 中 | 中小数据量、深翻页少 |
| 二次查询法 | 第一次查定位时间戳,第二次精确查 | ⭐⭐⭐ | 高 | 偶尔深翻页 |
| 异构索引(ES / HBase) | 用专门搜索引擎处理复杂查询 | ⭐⭐⭐⭐⭐ | 极高 | 复杂查询、深翻页频繁 |
生产中最常用的是游标分页 + 异构索引的组合拳,下面详细讲。
深度解析
一、为什么跨库分页这么难?
先看一个具体的例子。假设订单表按 user_id 分了 4 个库,每页 10 条,要查 "第 3 页"(LIMIT 20, 10):
上图展示了“全局内存合并法”的核心问题。每个分片只能基于自己的数据返回 "第 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 等专门处理复杂查询的中间件,主库只管写入。
上图是异构索引的完整链路,几个关键点说一下:
- 写入路径:业务正常写入 MySQL 分库分表,Canal 监听 binlog 变更,通过 Kafka 投递到 Elasticsearch
- 查询路径:分页查询先打 ES,ES 负责复杂的条件过滤、排序、分页;拿到主键 ID 列表后,回 MySQL 分片按主键批量取详细数据
- 解耦点:ES 专门解决查询复杂度,MySQL 专注于存储与事务,谁也不抢谁的活
优点:
- 性能优秀,深翻页也能秒级返回
- 支持任意复杂查询条件(多字段组合、模糊搜索)
- 主库压力小,写性能不受影响
缺点:
- 架构复杂度急剧上升,要维护 Canal + MQ + ES 一整套链路
- 数据延迟:从 MySQL 到 ES 通常有秒级延迟,强一致性场景要慎用
- 成本高,ES 集群本身就要不少机器
美团、饿了么、有赞这类公司都是这个套路。它的本质是 CQRS(读写分离)思想,把写模型和读模型分开。
面试高频追问
-
追问一:游标分页和 OFFSET 分页的本质区别是什么?
- OFFSET 是 "跳过 N 条",数据库要扫描 N 条才能定位;游标是 "条件过滤",直接走索引定位。前者复杂度是 O(n+m),后者是 O(log N + m)。
-
追问二:分库分表后,count 总数怎么算?
- 同样是难题。常用方案:① 每个分片 count 后求和(慢);② 维护一张统计表(强一致难);③ ES 异构(推荐);④ 业务上尽量避免精确 count,用 "已加载完毕" 替代总数。
-
追问三:分页查询时,分片键必须是查询条件吗?
- 是的!不带分片键的查询会广播到所有分片,性能急剧下降。所以分片键的选择要慎重,订单场景一般用
user_id,确保 C 端查询都带分片键。
- 是的!不带分片键的查询会广播到所有分片,性能急剧下降。所以分片键的选择要慎重,订单场景一般用
常见面试变体
- "分库分表后,如何实现跨库的 ORDER BY?"
- "为什么分库分表后深翻页会变慢?怎么优化?"
- "订单系统分库分表后,用户要导出全部订单怎么办?"
记忆口诀
分页四方案,按场景挑:
- 瀑布流 → 游标分页
- 浅翻页 → 内存合并
- 偶尔深翻 → 二次查询
- 复杂查询 → ES 异构
总结
分库分表后的分页查询没有银弹,核心是接受 "深翻页是反模式" 这个事实。生产中最常见的组合是 C 端用游标分页、B 端用 ES 异构索引。回答这道题的时候,先把 "为什么难" 讲清楚,再把方案按场景权衡展开,最后落到 CQRS 思想,面试官会觉得你既会写代码,也懂架构。
