面试官:分库分表后跨库分页和聚合怎么办?答"查所有库再合并"就挂了
引言
面试官问:"分库分表之后,跨库分页和聚合查询怎么做?"
我答:"每个库查出来,在内存里合并排序,再取对应的页。"
面试官追问:"16 个库,每库 100 万订单,要查第 100 页,你这套要查多少行?"
我算了一下:
每页 10 条,第 100 页 → offset = 990
每个库要查 990 + 10 = 1000 条
16 个库 → 16,000 条
应用层再对这 16,000 条排序,取第 991–1000 条
"16,000 条不算多吧?"我还嘴硬。
"那第 10,000 页呢?"
offset = 99,990
每个库查 100,000 条
16 个库 → 1,600,000 条
会议室安静了。
这不是段子。我们线上真踩过:运营后台的订单列表支持跳页,某次查到第 50 页,接口 30 秒超时,16 个库的 CPU 同时飙到 90%。DBA 拿着慢查询日志找过来,每条都是 LIMIT 5000, 10——单看一条不慢,16 条并发的 LIMIT 5000,10,加上应用层归并,直接把连接池打满。
那次之后我把跨库查询拆成两张账单:
- 分页的账单:
分片数 × (offset + limit)的扫描放大,offset 越大越离谱 - 聚合的账单:哪些函数能"分着算",哪些不能——
COUNT能加,AVG不能平均
先放结论总表:
| 问题 | 错误直觉 | 正确认知 | 首选方案 |
|---|---|---|---|
| 跨库分页 | 各库查完合并就行 | 成本是 N×(offset+limit),深分页必炸 | 游标分页 / 异构索引 |
| 浅分页 | 必须上 ES | 分片少、数据量小,归并就够 | 内存归并 |
| 跨库 COUNT | 各库 count 相加 | 可分解,直接加 | 并行归约 |
| 跨库 AVG | 各库平均值再平均 | 权重不同,必须 SUM/COUNT | 二次聚合 |
| 跨库 DISTINCT | 各库去重后相加 | 跨片仍可能重复 | 全局去重 / HLL |
| 多维聚合 | 一条 SQL 查所有库 | 各片局部聚合 + 应用层合并 | 两阶段聚合 / 预聚合 |
一句话破题:分库分表拿走的是"全局有序"和"全局归约"这两个能力,跨库分页是在没有全局有序的情况下重新造一个全局游标,跨库聚合是在没有全局归约的情况下重新做一次 reduce——能带分片键的查询不要跨库,必须跨的,按数据量选归并、游标还是异构索引,别拿 OFFSET 硬扛。
一、先定义问题:分片之后,数据库失去了什么
1.1 失去了全局有序
单库分页靠的是 B+ 树索引的全局有序:ORDER BY create_time DESC LIMIT 990, 10 能直接定位到第 990 条之后。
分片之后,每个库只有"局部有序",16 个库的第 990 条互不相干。要得到全局第 991–1000 条,只有两条路:
- 把 16 个库的前 1000 条都拿上来,排一次,取第 991–1000
- 想办法让每个库只返回"可能进入全局前 1000"的那一小段
第一条就是"内存归并",第二条就是"二次查询"和"游标"的出发点。
1.2 失去了全局归约
单库 SELECT AVG(amount) FROM t_order 是一条执行计划。分片之后,每个库只能算自己的局部聚合,全局结果要在应用层再算一次。
问题是:不是所有聚合函数都能"分着算再合并"。
| 函数 | 能否各片算完再合并 | 怎么合并 |
|---|---|---|
| COUNT | ✅ | 各片相加 |
| SUM | ✅ | 各片相加 |
| MAX / MIN | ✅ | 各片取最大/最小 |
| AVG | ❌ | 必须 SUM / COUNT,不能平均值再平均 |
| COUNT DISTINCT | ⚠ | 分片键等于去重键时可相加,否则要全局去重 |
| 中位数 / P99 | ❌ | 不能分解,需全局排序或近似算法 |
1.3 成本公式
跨库分页成本 ≈ 分片数 N × (offset + limit) 行的扫描与传输 + 应用层排序
跨库聚合成本 ≈ 分片数 N × 单片聚合量 + 应用层归并
(DISTINCT 还要加上全局去重的内存/网络成本)
把 16、1000、10000 代进去,就能提前知道一个查询会不会炸,而不是等 DBA 找上门。
1.4 回答之前先问四个问题
面试和实战都一样,先别急着给方案,先确认边界:
| 问题 | 为什么问 | 影响 |
|---|---|---|
| 分片键是什么? | 决定查询能不能落到单库 | 带分片键 → 根本不跨库 |
| 查询带不带分片键? | 90% 的"跨库难题"其实是设计问题 | 带 → 单库查询,零成本 |
| 数据量和分片数? | 决定归并内存和网络成本 | 百万级和十亿级方案完全不同 |
| 能不能接受近似/延迟? | 精确 DISTINCT、实时聚合成本极高 | 可近似 → HLL/ES;不可 → 预聚合 |
如果分片键选得对,大多数"跨库分页"根本不会发生。 后面所有方案,都是在"查询维度与分片键不一致"这个前提下做的补救。
二、跨库分页:四代方案,一代比一代贵
2.1 第 0 代:让查询带上分片键(最优解)
订单表按 buyer_id 分 16 片。用户查"我的订单":
SELECT * FROM t_order
WHERE buyer_id = 10086
ORDER BY create_time DESC
LIMIT 0, 10;
分片键在 WHERE 里,路由到唯一一个分片,这就是单库分页,没有任何跨库成本。
这一代不是"方案",是设计目标:能单库解决的查询,不要让它跨库。 后面三代都是为"查询维度不是分片键"的场景准备的,比如商家查订单、运营查全量订单。
2.2 第 1 代:内存归并(UNION + 应用层排序)
最直白的做法:每个分片查 offset + limit 条,全部拿上来,应用层排序后取目标页。
// 逻辑上等价于把 16 条物理 SQL 的结果合并
List<Order> merged = new ArrayList<>(shardCount * (offset + limit));
for (DataSource shard : shards) {
merged.addAll(query(shard,
"SELECT * FROM t_order ORDER BY create_time DESC LIMIT ?", offset + limit));
}
merged.sort(Comparator.comparing(Order::getCreateTime).reversed());
List<Order> page = merged.subList(offset, Math.min(offset + limit, merged.size()));
用 ShardingSphere 时,这条逻辑 SQL 会被自动改写成 16 条物理 SQL 并归并:
SELECT * FROM t_order ORDER BY create_time DESC LIMIT 990, 10;
中间件帮你省了手写归并,但省不掉扫描放大:每个分片仍然要扫 1000 条。
| 指标 | 数值(16 分片、每页 10 条) |
|---|---|
| 第 1 页扫描行数 | 16 × 10 = 160 |
| 第 100 页扫描行数 | 16 × 1000 = 16,000 |
| 第 10,000 页扫描行数 | 16 × 100,000 = 1,600,000 |
| 应用层排序复杂度 | O(N·(offset+limit) log(...)) |
| 能否跳页 | ✅ |
| 精确性 | ✅ |
适用边界:分片数 ≤8、单表数据量不大、只翻前几页(比如前 20 页)。超过这个范围,内存、GC、网络、DB 扫描四笔账一起爆炸。
2.3 第 2 代:二次查询法(面试标准答案)
核心思路:第一轮先找到一个"全局边界值",第二轮只查边界附近的数据,避免把大量无关行搬来搬去。
第一轮:每个分片 ORDER BY time DESC LIMIT offset+limit
→ 取各分片返回结果里的全局最小 time,记为 T
第二轮:每个分片 WHERE time >= T ORDER BY time DESC LIMIT offset+limit
→ 只查边界附近,实际返回量通常远小于 offset+limit
合并第二轮结果,计算第一轮中 time > T 的行数 c
最终页 = 合并结果中跳过 (offset − c) 行,取 limit 行
// 第一轮:每片取 offset+limit,找到全局边界 T
List<Row> first = new ArrayList<>();
for (DataSource shard : shards) {
first.addAll(query(shard,
"SELECT * FROM t_order ORDER BY create_time DESC, id DESC LIMIT ?", offset + limit));
}
long boundary = first.stream().mapToLong(Row::getTime).min().orElse(0);
// 第二轮:每片只查 >= T 的数据
List<Row> second = new ArrayList<>();
for (DataSource shard : shards) {
second.addAll(query(shard,
"SELECT * FROM t_order WHERE create_time >= ? ORDER BY create_time DESC, id DESC LIMIT ?",
boundary, offset + limit));
}
// 第一轮中严格大于边界的行,一定排在最终页之前
long before = first.stream().filter(r -> r.getTime() > boundary).count();
second.sort(Comparator.comparing(Row::getTime).reversed()
.thenComparing(Row::getId, Comparator.reverseOrder()));
List<Row> page = second.subList((int) (offset - before),
(int) (offset - before) + limit);
三个必须注意的细节:
- 边界值要带 tie-breaker。
time有大量并列值时,单靠时间无法确定排名,必须用(create_time, id)复合比较,否则翻页会重复或漏数据。 - 第二轮不一定比第一轮小。
time >= T的最坏情况仍是每片offset+limit行,所以它是"缓解"不是"消灭"。它的价值在于:绝大多数业务的时间分布下,T 附近的记录数远小于 offset。 - 两轮 = 2N 次查询。分片多、DB 压力大时,查询次数翻倍本身就是成本。
| 指标 | 二次查询法 |
|---|---|
| 查询次数 | 2 × N |
| 第一轮扫描 | N × (offset+limit) |
| 第二轮扫描 | 通常远小于 N × (offset+limit) |
| 能否跳页 | ✅ |
| 精确性 | ✅(需处理并列值) |
| 适用 | 中等数据量、必须支持跳页、分片数不太多 |
2.4 第 3 代:游标分页(Seek Method,深分页的正解)
不让用户跳页,只提供"下一页"。每次查询带上上一页最后一条记录的排序值:
-- 第一页
SELECT * FROM t_order
ORDER BY create_time DESC, id DESC
LIMIT 10;
-- 下一页:用上一页最后一条的 (create_time, id) 作为游标
SELECT * FROM t_order
WHERE (create_time, id) < ('2026-10-01 12:00:00', 10086)
ORDER BY create_time DESC, id DESC
LIMIT 10;
分片执行时,每个分片都带同样的游标条件、各取 10 条,应用层归并后取前 10 条,再把第 10 条的 (create_time, id) 作为下一页游标。
// 每个分片只返回 limit 条,归并后再取 limit 条
List<Order> merged = new ArrayList<>(shardCount * limit);
for (DataSource shard : shards) {
merged.addAll(query(shard,
"SELECT * FROM t_order WHERE (create_time, id) < (?, ?) " +
"ORDER BY create_time DESC, id DESC LIMIT ?",
cursorTime, cursorId, limit));
}
merged.sort(...);
List<Order> page = merged.subList(0, limit);
| 指标 | 游标分页 |
|---|---|
| 每页扫描 | N × limit(与页码无关) |
| 查询次数 | N |
| 能否跳页 | ❌ 只能上一页/下一页 |
| 精确性 | ✅ |
| 额外成本 | 游标要随请求传递;数据插入不影响已翻页结果(更稳定) |
深分页的正解就是它。 offset 从 990 涨到 99,990,游标分页的每页成本还是 16 × 10 = 160 行,恒定不变。
代价是产品形态:不能跳页、不能显示"共 10,000 页"、不能随机选页。运营后台如果非要跳页,要么改成"下一页 + 条件筛选",要么上第 4 代。
2.5 第 4 代:异构索引(ES / 宽表 / 数仓)
当查询维度多、必须任意跳页、还要求多维排序时,关系库的分片已经不适合承担这个查询了。标准做法是把数据同步到为查询而生的存储:
MySQL 分片集群
│ Binlog / CDC(Canal、Debezium、Flink CDC)
▼
Elasticsearch(按查询维度建索引)
│
├── 全文检索、多条件组合
├── 任意跳页:from + size(受 max_result_window 限制,默认 10000)
└── 深分页:search_after(游标)
ES 的关键参数和坑:
| 项目 | 说明 |
|---|---|
index.max_result_window | 默认 10000,from + size 超过直接报错 |
search_after | 深分页正解,必须带唯一 tie-breaker(如 _id) |
track_total_hits | 默认只精确到 10000,要精确总数需显式开启(有成本) |
cardinality 聚合 | 近似去重(HLL),不是精确值 |
| 同步延迟 | 通常秒级,不能当作强一致读 |
异构索引的代价是多一套存储、多一条同步链路、多一份一致性维护。它不是"分页方案",是"把查询能力外包给另一个系统"。中小规模业务上 ES 做分页,往往是杀鸡用牛刀。
2.6 四代方案对比
| 方案 | 查询次数 | 每页扫描量 | 能否跳页 | 精确 | 适用规模 | 主要风险 |
|---|---|---|---|---|---|---|
| 0. 带分片键 | 1 | 单库 limit | ✅ | ✅ | 所有 | 查询维度受限 |
| 1. 内存归并 | N | N×(offset+limit) | ✅ | ✅ | 小数据、浅分页 | 深分页爆炸 |
| 2. 二次查询 | 2N | 首轮 N×(offset+limit),次轮收窄 | ✅ | ✅ | 中等数据 | 并列值、查询翻倍 |
| 3. 游标分页 | N | N×limit(恒定) | ❌ | ✅ | 深分页/导出 | 产品不能跳页 |
| 4. 异构索引 | 1(查 ES) | ES 内部 | ✅ | ⚠ 同步延迟 | 多维查询/大数据 | 一致性、运维成本 |
选型顺序:0 → 3 → 2 → 4 → 1。 能带分片键就不跨库;必须深分页就上游标;游标不满足产品需求再考虑二次查询;数据量和维度都上来之后,才轮到 ES/数仓。内存归并排在最后,因为它是唯一一个"页码越深越慢"的方案。
三、跨库聚合:先分清哪些函数能"分着算"
3.1 可分解函数:各片算完直接合并
-- 每个分片执行
SELECT COUNT(*) AS cnt, SUM(amount) AS sum, MAX(amount) AS max
FROM t_order WHERE create_time >= '2026-10-01';
long count = partials.stream().mapToLong(Partial::getCnt).sum();
BigDecimal sum = partials.stream().map(Partial::getSum)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal max = partials.stream().map(Partial::getMax)
.max(BigDecimal::compareTo).orElse(BigDecimal.ZERO);
COUNT、SUM、MAX、MIN 都是可分解聚合(decomposable aggregation):局部结果可以合并成全局结果,各片并行算,应用层做一次加法/取极值。
3.2 不可分解函数:AVG 是经典陷阱
AVG 不能"各片平均值再平均",因为每个分片的行数不同,权重不同。
分片 A:10 行,AVG = 100 → SUM = 1,000
分片 B:90 行,AVG = 10 → SUM = 900
错误算法:(100 + 10) / 2 = 55
正确算法:(1,000 + 900) / (10 + 90) = 19
差了近 3 倍。正确做法是让每个分片返回 SUM 和 COUNT,应用层再除:
SELECT SUM(amount) AS sum, COUNT(*) AS cnt FROM t_order WHERE ...;
BigDecimal avg = totalSum.divide(
BigDecimal.valueOf(totalCount), 2, RoundingMode.HALF_UP);
面试里只要听到"各库 AVG 再平均",基本可以直接结束。 这是跨库聚合最高频的错误答案。
3.3 COUNT DISTINCT:难不难,看分片键
| 场景 | 能否各片去重后相加 | 原因 |
|---|---|---|
按 buyer_id 分片,统计 COUNT(DISTINCT buyer_id) | ✅ | 同一个 buyer 只可能在一个分片 |
按 order_id 分片,统计 COUNT(DISTINCT buyer_id) | ❌ | 同一 buyer 的订单散在多个分片,相加会重复 |
统计 COUNT(DISTINCT product_id) | ❌ | 商品和分片键无关,跨片重复不可避免 |
精确解只有两条路:
- 全局去重:各片返回 distinct 值集合,应用层做并集。基数小(比如几万)可以;基数上百万,内存和网络都吃不消。
- 预聚合/异构索引:把 distinct 结果提前算好,或者交给 ES 的
cardinality、Redis 的 HyperLogLog 做近似。
近似解的误差量级:HLL 在 16,384 个寄存器时标准误差约 0.81%,对"UV 大概多少"够用,对"财务对账"不够用。
3.4 GROUP BY:两阶段聚合
-- 第一阶段:每个分片局部聚合
SELECT seller_id, SUM(amount) AS sum, COUNT(*) AS cnt
FROM t_order
WHERE create_time >= '2026-10-01'
GROUP BY seller_id;
-- 第二阶段:应用层按 seller_id 再合并
-- sum = 各片 sum 相加;cnt = 各片 cnt 相加;avg = sum / cnt
这就是 MapReduce 的 map-side combine + reduce。两个坑:
- 分组基数大:按
product_id分组,每个分片返回几十万组,网络传输比原始数据还大。此时应该上预聚合表或 ES/ClickHouse。 - 数据倾斜:某个 seller 的订单占了 60%,它的局部聚合结果也最大,但聚合本身是"每个 key 独立",倾斜影响比 join 小;真正会炸的是
GROUP BY之后再排序取 Top N。
3.5 精确 vs 近似:先问业务能不能接受误差
| 需求 | 方案 | 误差 |
|---|---|---|
| 财务金额汇总 | 精确两阶段 SUM/COUNT | 0 |
| UV / 去重用户数 | HyperLogLog | ~0.81% |
| P99 延迟 | t-digest / 直方图 | 可控 |
| 实时大盘 | 预聚合 + 增量更新 | 取决于窗口 |
| 多维下钻 | ClickHouse / ES 聚合 | 精确或近似可选 |
"精确"和"实时"和"低成本"三者最多选两个。 跨库聚合的选型,本质是让业务方明确放弃哪一个。
3.6 聚合方案对比
| 方案 | 精确性 | 实时性 | 成本 | 适用 |
|---|---|---|---|---|
| 应用层归并 | 精确 | 实时 | 低(数据量小) | COUNT/SUM/MAX/MIN、小基数 DISTINCT |
| 两阶段 GROUP BY | 精确 | 实时 | 中 | 中等基数分组 |
| 预聚合表 | 精确(按窗口) | 准实时 | 中 | 固定维度报表 |
| ES 聚合 | 精确/近似可选 | 秒级延迟 | 高 | 多维下钻、全文+聚合 |
| ClickHouse | 精确 | 秒级 | 高 | 大规模 OLAP |
| HLL/t-digest | 近似 | 实时 | 低 | UV、分位数 |
四、代码实战:把归并写对,比写出来更重要
4.1 分页归并的三个必查项
public PageResult<Order> queryPage(int pageNo, int pageSize) {
int offset = (pageNo - 1) * pageSize;
// ① 并发查所有分片,别串行——串行会把 N 倍延迟叠起来
List<CompletableFuture<List<Order>>> futures = shards.stream()
.map(s -> CompletableFuture.supplyAsync(() ->
s.query("SELECT * FROM t_order ORDER BY create_time DESC, id DESC LIMIT ?",
offset + pageSize), executor))
.toList();
List<Order> merged = futures.stream()
.map(CompletableFuture::join)
.flatMap(List::stream)
.sorted(Comparator.comparing(Order::getCreateTime).reversed()
.thenComparing(Order::getId, Comparator.reverseOrder()))
.toList();
// ② 边界保护:某分片数据不足时,subList 会越界
int from = Math.min(offset, merged.size());
int to = Math.min(offset + pageSize, merged.size());
return new PageResult<>(merged.subList(from, to), merged.size());
}
三个必查项:
- 并行查询 + 超时控制。串行 16 个库,每个 50ms,一页就是 800ms;并行也要给每个 future 设超时,别让一个慢分片拖死整页。
subList边界。某个分片数据少、或者总数据不足 offset 时,直接subList(offset, offset+limit)会抛IndexOutOfBoundsException。- 总条数另算。
total要单独COUNT(*)各片相加,别拿当前页大小推算;而且深分页场景下,精确 total 本身就是一次全量扫描,能不给就别给。
4.2 聚合归并代码
public Stats stats(LocalDate day) {
List<Partial> partials = shards.parallelStream()
.map(s -> s.queryPartial(day)) // 返回 cnt / sum / max
.toList();
long count = partials.stream().mapToLong(Partial::cnt).sum();
BigDecimal sum = partials.stream().map(Partial::sum)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal avg = count == 0 ? BigDecimal.ZERO
: sum.divide(BigDecimal.valueOf(count), 2, RoundingMode.HALF_UP);
return new Stats(count, sum, avg);
}
4.3 三个高频翻车点
| 翻车点 | 现象 | 原因 | 解法 |
|---|---|---|---|
| PageHelper 分片下 total 错 | 总页数不对、翻页重复 | 它按单库语义生成 count 和 limit | 分片场景别用 PageHelper,手写归并或 ShardingSphere |
COUNT(DISTINCT) 偏大 | 去重数比真实值高 | 各片 distinct 相加,跨片重复没消 | 全局并集 / HLL |
| 串行查 16 个库 | 接口 RT = 16 × 单库 RT | for 循环顺序查 | 并行 + 超时 + 降级 |
中间件不是万能的。 ShardingSphere 能帮你改写 SQL、归并 ORDER BY/LIMIT、合并部分聚合,但 COUNT(DISTINCT) 的跨片语义、深分页的扫描放大,它同样解决不了——语义问题永远要业务层自己想清楚。
五、分片键设计:最好的跨库查询是不跨库
5.1 按查询维度分片
订单有两个天然查询维度:
- C 端用户:查"我的订单" → 按
buyer_id分片 - B 端商家:查"店铺订单" → 按
seller_id分片
一张表只能选一个分片键。选 buyer_id,商家查询就跨库;选 seller_id,用户查询就跨库。
5.2 异构索引表:用冗余换路由
标准解法是建两张表,同一份数据,两种分片:
t_order_buyer (按 buyer_id 分 16 片)← 支撑 C 端查询
t_order_seller (按 seller_id 分 16 片)← 支撑 B 端查询
同步方式:
双写(事务内两张表都写,强一致但性能差)
Binlog/CDC(异步同步,秒级延迟,主流方案)
代价是存储翻倍、写入放大、一致性要单独治理。但如果商家查询是核心链路,这笔账比"每次查询都跨 16 个库归并"便宜得多。
5.3 基因法:把分片键藏进 ID
有一种更巧的做法:生成订单号时,把分片键的 hash 作为"基因"嵌进去。
order_id = 时间戳 + 用户ID后4位 + 序列号
查询时可以从 order_id 反解出 buyer_id → 直接路由到分片
它解决的是"按订单号查订单"的路由问题,解决不了"按商家维度查列表"——后者还是得靠异构索引。别把基因法当成万能钥匙。
5.4 预聚合表:把聚合成本从查询时移到写入时
固定维度的报表(比如"每个商家每天的订单数/金额"),不要在查询时跨 16 个库 GROUP BY,而是在写入时增量更新一张汇总表:
-- 汇总表按 (seller_id, stat_date) 唯一
INSERT INTO t_order_daily_stat (seller_id, stat_date, order_cnt, order_amount)
VALUES (?, ?, 1, ?)
ON DUPLICATE KEY UPDATE
order_cnt = order_cnt + 1,
order_amount = order_amount + VALUES(order_amount);
查询时按 seller_id 路由到单库,直接读汇总行。代价是写入多一次,收益是查询从"16 库聚合"变成"单行读取"。
六、按数据量选型:一张决策树
查询带分片键吗?
├── 带 → 单库查询,结束
└── 不带
├── 分页
│ ├── 只翻前几页、分片 ≤8 → 内存归并
│ ├── 深分页 / 导出 → 游标分页(产品让不让跳页?)
│ ├── 必须任意跳页、数据中等 → 二次查询
│ └── 多维排序 + 大数据量 → ES search_after
└── 聚合
├── COUNT/SUM/MAX/MIN → 各片并行算,应用层合并
├── AVG → SUM/COUNT,禁止平均值再平均
├── DISTINCT → 分片键=去重键?是→可加;否→全局并集/HLL
├── 固定维度报表 → 预聚合表
└── 多维下钻 → ES / ClickHouse / 数仓
| 数据规模 | 分页方案 | 聚合方案 |
|---|---|---|
| <100 万,分片 ≤8 | 内存归并 | 应用层归并 |
| 100 万–1 亿 | 游标分页 / 二次查询 | 两阶段聚合 + 预聚合 |
| >1 亿,多维查询 | ES / 数仓 | ES / ClickHouse |
| 任意规模,固定报表 | — | 预聚合表 |
| 任意规模,UV/分位数 | — | HLL / t-digest |
七、面试怎么答:先反问,再分层
7.1 标准回答结构
第一层:能不能不跨库
先确认分片键和查询条件。带分片键就是单库,这是最优解。
第二层:必须跨库,分页怎么解
浅分页 → 各片取 offset+limit 归并
深分页 → 游标(seek),成本与页码无关
必须跳页 → 二次查询,或异构索引(ES)
第三层:聚合怎么解
可分解函数 → 各片算完合并
AVG → SUM/COUNT
DISTINCT → 看分片键,否则全局去重或 HLL
GROUP BY → 两阶段聚合,基数大上预聚合/OLAP
收尾:设计层面
异构索引表、预聚合、按查询维度规划分片键
7.2 面试官追问对照表
| 追问 | 回答要点 |
|---|---|
| 为什么不能每库查 offset+limit 再合并? | 扫描量 N×(offset+limit),深分页线性放大;内存、网络、DB 三头承压 |
| 二次查询法真的能解决深分页吗? | 缓解,不是消灭;两轮 2N 次查询,首轮仍扫 offset,边界并列值要用复合键 |
| 游标分页的缺点? | 不能跳页、不能精确显示总页数;适合 feed/导出,不适合后台任意跳页 |
| AVG 为什么不能平均? | 各片行数不同,权重不同;必须 SUM/COUNT |
| COUNT DISTINCT 怎么算? | 分片键等于去重键时可加;否则全局并集或 HLL 近似 |
| ES 的 from+size 有上限吗? | 默认 10000,深分页用 search_after |
| 分片键怎么选? | 按核心查询维度;次要维度用异构索引表 |
| 中间件能自动解决吗? | 能改写和归并,但解决不了跨片语义和扫描放大 |
八、常见问题
8.1 用 ShardingSphere 是不是就不用管跨库分页了?
不是。它能改写 SQL、归并结果,但 LIMIT 990,10 在 16 个分片上仍会变成 16 条 LIMIT 0,1000,扫描放大原样存在。中间件解决的是"代码怎么写",解决不了"数据要扫多少"。
8.2 分片数越多,分页越慢吗?
线性相关。N×(offset+limit) 里的 N 就是分片数。16 片扩到 64 片,同一页的扫描量翻 4 倍。分片数不是越多越好,它同时放大了跨库查询的成本。
8.3 游标分页能不能支持"上一页"?
可以,把比较方向反过来:WHERE (create_time, id) > (cursor) ORDER BY create_time ASC, id ASC LIMIT 10,拿到结果后再倒序展示。但"跳到第 100 页"仍然做不到——游标只有相对位置,没有绝对页码。
8.4 二次查询法的边界值为什么容易错?
排序字段有并列值时(同一秒大量订单),"最小 time"无法唯一定位排名。必须用 (time, id) 复合键,并且第二轮查询的条件、排序、去重都要用同一套复合键,否则会重复或漏行。
8.5 为什么 COUNT(DISTINCT) 各片相加会偏大?
同一个去重值可能出现在多个分片。按 order_id 分片时,同一个买家在 16 个分片都有订单,各片 distinct 后相加,这个买家被数了 16 次。只有分片键等于去重键时,相加才等价于全局去重。
8.6 ES 能不能完全替代分库分表查询?
不能。ES 适合检索和多维聚合,不适合作为唯一数据源:同步有延迟、事务能力弱、更新成本高、运维复杂。它通常是"分片库 + ES 异构索引"的组合,而不是替代。
8.7 聚合查询能不能直接上 ClickHouse?
可以,但要接受它的边界:适合大规模 OLAP 和批量写入,不适合高并发点查和频繁更新;数据从业务库同步过去有延迟。报表、大盘、多维分析适合;交易链路的实时查询不适合。
8.8 有没有一招通吃的方案?
没有。跨库分页和聚合的成本来自"分片"这个前提本身,所有方案都是在成本、实时性、精确性、产品形态之间做交换。能带分片键就不跨库,这是唯一接近"免费"的解法。
九、总结
决策树
跨库查询
├── 带分片键 → 单库,结束
├── 分页
│ ├── 浅分页 → 内存归并
│ ├── 深分页 → 游标(成本恒定)
│ ├── 必须跳页 → 二次查询
│ └── 多维/超大数据 → ES search_after
└── 聚合
├── 可分解 → 并行归约
├── AVG → SUM/COUNT
├── DISTINCT → 全局并集 / HLL
├── 固定报表 → 预聚合表
└── 多维分析 → ES / ClickHouse
速查表
| 场景 | 方案 | 关键点 |
|---|---|---|
| 跨库浅分页 | 各片 offset+limit 归并 | 分片 ≤8、页码浅 |
| 跨库深分页 | 游标 (time,id) < | 成本 N×limit,与页码无关 |
| 必须任意跳页 | 二次查询 | 边界值 + 复合键 + 2N 查询 |
| 多维检索/跳页 | ES search_after | 同步延迟、max_result_window |
| COUNT/SUM/MAX | 各片算完合并 | 可分解 |
| AVG | SUM/COUNT | 禁止平均值再平均 |
| COUNT DISTINCT | 分片键=去重键才可加 | 否则全局并集/HLL |
| GROUP BY | 两阶段聚合 | 基数大上预聚合 |
| 固定报表 | 预聚合表 | 写入时增量更新 |
一句话
分库分表拿走了全局有序和全局归约,跨库分页是在重建全局游标,跨库聚合是在重建全局 reduce——分页的成本是 N×(offset+limit),聚合的成本是"函数能不能分解";能带分片键就别跨库,深分页用游标别用 OFFSET,AVG 用 SUM/COUNT 别用平均值再平均。
关键数据
- 16 分片、第 100 页:每库扫 1000 行,合计 16,000 行
- 16 分片、第 10,000 页:每库扫 100,000 行,合计 1,600,000 行
- 游标分页每页成本恒定 N×limit,与页码无关
- AVG 错误算法(100+10)/2=55 vs 正确 1900/100=19
- HLL 标准误差约 0.81%(16,384 寄存器)
- ES
from+size默认上限 10,000,深分页用search_after
给团队的建议
| 项目状态 | 建议 |
|---|---|
| 正在设计分片 | 先按核心查询维度定分片键,次要维度预留异构索引 |
| 后台列表支持任意跳页 | 改成"筛选 + 游标下一页",或单独走 ES |
接口出现 LIMIT 大 offset | 立刻改成游标,别等 DBA 找上门 |
| 报表跨库 GROUP BY | 上预聚合表,别在查询时扫 16 个库 |
| 统计口径有 AVG/DISTINCT | 先和业务确认能否接受近似,再选 HLL/全局去重 |
| 面试回答 | 按"不跨库 → 归并 → 游标 → 二次查询 → 异构索引"分层讲 |
互动话题:你们分库分表后,跨库分页最后是怎么解的?有没有因为
LIMIT 大 offset把库打挂过?评论区聊聊你的分片键和查询维度,点赞最高的场景我拆成下一篇的完整复盘。
标题:面试官:分库分表后跨库分页和聚合怎么办?答"查所有库再合并"就挂了
作者:jiangyi
地址:http://jiangyi.space/articles/2026/11/01/1793463617285.html
公众号:服务端技术精选
- 引言
- 一、先定义问题:分片之后,数据库失去了什么
- 1.1 失去了全局有序
- 1.2 失去了全局归约
- 1.3 成本公式
- 1.4 回答之前先问四个问题
- 二、跨库分页:四代方案,一代比一代贵
- 2.1 第 0 代:让查询带上分片键(最优解)
- 2.2 第 1 代:内存归并(UNION + 应用层排序)
- 2.3 第 2 代:二次查询法(面试标准答案)
- 2.4 第 3 代:游标分页(Seek Method,深分页的正解)
- 2.5 第 4 代:异构索引(ES / 宽表 / 数仓)
- 2.6 四代方案对比
- 三、跨库聚合:先分清哪些函数能"分着算"
- 3.1 可分解函数:各片算完直接合并
- 3.2 不可分解函数:AVG 是经典陷阱
- 3.3 COUNT DISTINCT:难不难,看分片键
- 3.4 GROUP BY:两阶段聚合
- 3.5 精确 vs 近似:先问业务能不能接受误差
- 3.6 聚合方案对比
- 四、代码实战:把归并写对,比写出来更重要
- 4.1 分页归并的三个必查项
- 4.2 聚合归并代码
- 4.3 三个高频翻车点
- 五、分片键设计:最好的跨库查询是不跨库
- 5.1 按查询维度分片
- 5.2 异构索引表:用冗余换路由
- 5.3 基因法:把分片键藏进 ID
- 5.4 预聚合表:把聚合成本从查询时移到写入时
- 六、按数据量选型:一张决策树
- 七、面试怎么答:先反问,再分层
- 7.1 标准回答结构
- 7.2 面试官追问对照表
- 八、常见问题
- 8.1 用 ShardingSphere 是不是就不用管跨库分页了?
- 8.2 分片数越多,分页越慢吗?
- 8.3 游标分页能不能支持"上一页"?
- 8.4 二次查询法的边界值为什么容易错?
- 8.5 为什么 COUNT(DISTINCT) 各片相加会偏大?
- 8.6 ES 能不能完全替代分库分表查询?
- 8.7 聚合查询能不能直接上 ClickHouse?
- 8.8 有没有一招通吃的方案?
- 九、总结
- 决策树
- 速查表
- 一句话
- 关键数据
- 给团队的建议
评论