---
title: 跨分片 JOIN 与分页方案
type: concept
domain: sharding
tags: [sharding, join, pagination, binding-table, broadcast, keyset-pagination, deep-pagination]
status: learning
created: 2026-09-23
last_reviewed: 2026-09-23
method: feynman
related_questions:
  - "分库分表之后的怎么进行join操作"
  - "分库分表后如何进行分页查询？"
related_knowledge:
  - ./sharding-fundamentals.md
  - ./sharding-algorithms-id.md
  - ./sharding-migration.md
  - ../mysql/mvcc.md
  - ../redis/cache-three-failures.md
anki_cards: 7
interview_rounds:
  - "2026-09-23-round-1-Q8"
  - "2026-09-23-round-1-Q9"
---

# 跨分片 JOIN 与分页方案

> 跨分片 JOIN 是分库分表的**最大架构限制**，深分页是**最大的性能杀手**。核心思路：能不用 JOIN 就不用，能本地 JOIN 就不跨分片；能游标就游标，不能就限制深度，最后才是 ES。

## TL;DR（30 秒扫完）

- **JOIN 六方案**：绑定表 / 广播表 / 异构索引表 / 大宽表 / 服务化 JOIN / ES，每种对应不同场景
- **JOIN 核心思路**：能本地 JOIN 就本地（同分片键 = 绑定表），能广播就广播（字典小表），实在不行才服务化
- **深分页本质**：`LIMIT offset, size` 要扫 offset+size 行，分片场景还要放大 N 倍
- **分页四解**：游标分页 / 覆盖索引延迟关联 / 限制翻页深度 / ES
- **顺序原则**：能游标就游标 → 不能游标就限制深度 → 最后才是 ES

## 关键结论

- **结论 A**：跨分片 JOIN 是**架构限制**，中间件做 N×M 笛卡尔积，性能随分片数急剧下降
- **结论 B**：绑定表和广播表是**分片中间件核心特性**（ShardingSphere / MyCat 都支持）
- **结论 C**：深分页必须**在设计阶段规避**，不是出问题再治
- **结论 D**：分片键与排序字段**尽量对齐**，能让分页天然局部

## 完整讲解（费曼四步）

### STEP 1 · 概念

**跨分片 JOIN** 是分片后 SQL JOIN 无法直接在 MySQL 层完成的替代方案集合；**分页**是分片场景下数据分散导致 `LIMIT offset, size` 语义变化的应对方案。

### STEP 2 · 大白话

**JOIN 类比**：
- **绑定表** = 同一办公室的两份文件，本地翻一下就行
- **广播表** = 每个办公室都备一份字典，谁都能查
- **异构索引表** = 通讯录先查手机号，再打电话确认
- **大宽表** = 把所有信息印在同一张纸上，但改一处要改多份
- **服务化 JOIN** = 打两个电话拼数据，快但走网络
- **ES** = 专门的档案室存副本，主库只做增删

**深分页类比**：
- 普通分页 = 从书架第 1 本开始找，找到第 100001 本拿走 10 本
- 游标分页 = 从上次拿走的最后一本之后接着找 10 本（永远只挪 10 本）

### STEP 3 · 底层

#### 六种 JOIN 方案对比

| 方案 | 核心机制 | 一致性 | 适用场景 | 代价 |
|------|---------|--------|---------|------|
| **绑定表** | 主子表同分片键，中间件自动本地 JOIN | 强 | 主子表关联 | 需分片键一致 |
| **广播表** | 小表全量复制到每个分片 | 中（异步） | 字典/配置/类目 | 内存翻倍 |
| **异构索引表** | 主表按 A 分片，索引表按 B 分片 | 强（双写） | 多查询路径 | 双写一致性 |
| **大宽表** | 字段冗余到主表 | 强 | 读多写少 | 写放大 |
| **服务化 JOIN** | RPC 跨服务拼接 | 中（缓存） | 跨域强耦合 | 网络开销 |
| **ES/ClickHouse** | 宽表同步到搜索/OLAP | 弱（异步） | 复杂查询/搜索 | 同步延迟 |

#### 绑定表（Binding Table）

**要求**：主子表用**同一分片键**且**值相同**。

```sql
-- 主表按 user_id 分片
orders_00(user_id, order_id, status, ...)   -- user_id=1001
orders_01(...)
orders_02(...)

-- 明细表也按 user_id 分片
order_items_00(user_id, item_id, ...)      -- 同一用户的数据在同一分片
order_items_01(...)
```

中间件（ShardingSphere / MyCat）识别绑定关系后自动做**本地 JOIN**，性能接近单库 JOIN。

**配置示例**：
```yaml
# ShardingSphere 配置
binding-tables:
  - orders, order_items
```

#### 广播表（Broadcast Table）

**原理**：字典小表**全量复制**到每个分片。

```
orders_00 + user_level + product_category + tenant_config
orders_01 + user_level + product_category + tenant_config
orders_02 + user_level + product_category + tenant_config
```

JOIN 完全本地，性能 = 单库 JOIN。

**代价**：
- 小表更新要广播到所有分片（用 MQ 异步）
- 内存翻倍（但字典小表通常 <1w 行，可忽略）

**适合**：万级以下的小表。100w 条字典就不适合了。

#### 异构索引表（Secondary Index Table）

**原理**：主表按 A 分片，异构表按 B 分片。

```sql
-- 主表按 user_id 分片
orders_00(user_id, order_id, status, ...)
orders_01(...)

-- 异构表按 order_id 分片
order_index_00(order_id, user_id, status)
order_index_01(...)
```

查订单详情时：
1. 查异构表按 order_id 定位 → 拿到 user_id
2. 用 user_id 回主表精确定位

**双写一致性**：
- 同事务写入主表 + 异构表（推荐）
- 本地消息表异步
- 定时对账修复
- 极端情况允许短暂不一致

#### 大宽表（Denormalization）

**原理**：把需要的字段直接冗余到主表。

```sql
-- 冗余 order.status、product.name、user.nickname
orders(user_id, order_id, status, product_name, user_nickname, ...)
```

**代价**：写放大（更新要改多份）、字段冗余、维护成本。

**适合**：读多写少，比如订单列表页展示字段。

#### 服务化 JOIN 的三大优化

跨域强耦合场景（订单查用户服务）必须做：

1. **批量接口**：一次 RPC 拉 N 条而不是 N 次 RPC 拉 1 条
2. **多级缓存**：本地缓存 + Redis + 远端服务缓存（短时效缓 30 秒，长时效缓 5 分钟）
3. **异步加载**：主查询走主库，附加数据异步加载，前端分段渲染

#### 电商订单表的典型组合

```
orders          ← 主表按 user_id 分片
order_items     ← 绑定表（同 user_id 分片键）
product_category ← 广播表（复制到所有分片）
phone_index     ← 异构索引表（按 phone 分片）
es_order_index  ← ES 同步，搜索/后台管理走这里
```

### 分页

#### 深分页的本质

```sql
SELECT * FROM t ORDER BY create_time LIMIT 100000, 10;
```

MySQL 要**扫 100010 行**，前 100000 行读回后丢弃。分片场景还要乘以分片数。

**分片中间件执行流程**：
1. 向每个分片发 `SELECT * FROM t ORDER BY x LIMIT 0, offset+size`
2. 所有分片返回后中间件做内存排序
3. 截断前 offset 行，返回后 size 行

代价 = **N 个分片 × 单库深分页耗时**。

#### 四种主流解

**解 1：游标分页（Keyset Pagination）**

```sql
-- 第一页
SELECT * FROM t ORDER BY id DESC LIMIT 10;
-- 拿到最大 id = 100

-- 第二页
SELECT * FROM t WHERE id < 100 ORDER BY id DESC LIMIT 10;
-- 拿到最大 id = 90

-- 第三页
SELECT * FROM t WHERE id < 90 ORDER BY id DESC LIMIT 10;
```

- **优点**：走了主键/排序索引，每页恒定性能
- **缺点**：不能跳页、不能显示"共 X 页"
- **适用**：Feed 流、聊天记录、无限滚动
- **不适用**：搜索框切换条件时游标失效

**解 2：延迟关联**

```sql
-- 第一步：走覆盖索引，只取 id
SELECT id FROM t ORDER BY create_time LIMIT 100000, 10;

-- 第二步：回表拿详情
SELECT * FROM t WHERE id IN (...);
```

- **优点**：第一步虽扫 100010 行索引，但索引比主表小 10 倍
- **缺点**：仍要扫 offset 行
- **适用**：能接受一定 offset，想优化深分页

**解 3：限制翻页深度**

```
// 业务侧禁止翻到第 100 页后
if (pageNum > 100) {
  return "请使用搜索框筛选";
}
```

- **优点**：最简单粗暴，效果最好
- **缺点**：功能受限
- **适用**：99% 用户不会翻到 20 页，成本远低于架构改造

**解 4：ES**

```java
SearchSourceBuilder ssb = new SearchSourceBuilder()
  .from(0)
  .size(10)
  .searchAfter(new Object[]{lastId, lastTimestamp});  // 游标
```

- **优点**：ES `search_after` 天然支持游标分页
- **缺点**：异步同步有延迟
- **适用**：后台管理、报表、搜索场景

#### 分片键与排序字段的对齐

| 分片方式 | 排序字段 | 分页代价 |
|---------|---------|---------|
| 按时间分片 | 按时间排序 | **低**（最新数据在最新分片） |
| 按 hash 分片 | 按时间排序 | **高**（跨全部分片扫描） |

**原则**：排序字段尽量和分片键对齐。

### STEP 4 · 简化

**一句话总结**：JOIN 走"能本地就本地 → 能异构就异构 → 最后才服务化 → ES 兜底搜索"；分页走"能游标就游标 → 不能游标就限制深度 → 最后才 ES"。

**记忆口诀**：
- JOIN 优先级：绑定表 > 广播表 > 异构索引 > 大宽表 > 服务化 > ES
- 分页优先级：游标 > 限制深度 > 覆盖索引 > ES
- 分片键与排序字段对齐 = 分页免费

## 常见误区

- **误区 1**：说"分片后所有 JOIN 都慢" → 正确：**绑定表**和**广播表**可以做到接近单库 JOIN 的性能
- **误区 2**：说"异构索引表就是分片" → 正确：异构索引表是**跨分片查询**的兜底方案，主表和异构表分片键不同
- **误区 3**：说"游标分页解决所有分页问题" → 正确：游标不能跳页，搜索场景不适合；搜索场景要走 ES
- **误区 4**：说"深分页问题不大" → 正确：分片场景深分页代价放大 N 倍，`LIMIT 100000,10` 在分片场景可能几十秒
- **误区 5**：说"限制翻页深度是最懒的做法" → 正确：**限制翻页深度是最高性价比**的做法，99% 用户翻不到 20 页

## 延伸追问

1. **绑定表要求什么前提条件？分片键不一致怎么办？**
   - 主子表分片键必须一致且值相同；不一致时用异构索引表或大宽表兜底
2. **广播表在数据量大时（比如 100w 条字典）还可行吗？**
   - 不适合。广播表只适合万级以内；大字典表用异构索引表或服务化查询
3. **异构索引表的双写一致性如何保证？**
   - 同事务写入主表 + 异构表 → 本地消息表异步 → 定时对账修复；极端情况允许短暂不一致
4. **`LIMIT 100000,10` 在 MySQL 里为什么慢？**
   - InnoDB 要扫 100010 行，前 100000 行读回后丢弃；如果走二级索引还要回表
5. **游标分页的劣势是什么？哪些场景不适合？**
   - 不能跳页、不能显示总页数；搜索框切换条件时游标失效——所以搜索场景不适合
6. **分片中间件做全局分页时如何优化？（如 ShardingSphere）**
   - Hint 强制路由到单分片；聚合查询（COUNT）走异构索引表；预计算排序字段
7. **ES 和 MySQL 主库数据不一致怎么办？**
   - 异步同步接受秒级延迟；关键业务用 CDC（Canal）实时同步；读多写少的后台场景可接受

## 速查表

```
JOIN 六方案:    绑定表 / 广播表 / 异构索引 / 大宽表 / 服务化 / ES
绑定表:         主子表同分片键，本地 JOIN
广播表:         字典小表全量复制，适合万级以下
异构索引:       主表按 A 分片，索引表按 B 分片，两次查询拼接
大宽表:         反范式，适合读多写少
服务化:         批量接口 + 多级缓存 + 异步加载

深分页本质:     LIMIT offset,size 扫 offset+size 行
分页四解:       游标 > 限制深度 > 覆盖索引 > ES

游标分页:       WHERE id < :lastId ORDER BY id DESC LIMIT N
ES 分页:        search_after(lastId, lastTimestamp)

分片键对齐:     排序字段尽量与分片键一致
```

## 关联题目（题库）

- ⚠️ 《分库分表之后的怎么进行join操作》— 2026-09-23 Round 1, ⭐⭐⭐（只答大宽表+服务化，漏绑定/广播/异构）
- ⚠️ 《分库分表后如何进行分页查询？》— 2026-09-23 Round 1, ⭐⭐⭐（漏深分页/游标/覆盖索引）

## 关联知识

- [分库分表基础概念 + 分片规模设计](./sharding-fundamentals.md)
- [分片算法 + 全局 ID 生成](./sharding-algorithms-id.md)
- [5 亿订单分库分表迁移](./sharding-migration.md)
- [Redis 缓存三失效](../redis/cache-three-failures.md)
- [主题地图](./_moc.md)

## Anki 候选卡片

1. **正**：跨分片 JOIN 的六种方案？**反**：绑定表 / 广播表 / 异构索引表 / 大宽表 / 服务化 JOIN / ES
2. **正**：绑定表的前提条件？**反**：主子表必须使用同一分片键且值相同
3. **正**：广播表适合什么规模？**反**：万级以下的字典小表；100w+ 不适合
4. **正**：`LIMIT 100000,10` 为什么慢？**反**：MySQL 要扫 100010 行，前 100000 行读回后丢弃
5. **正**：游标分页的核心 SQL？**反**：`WHERE id < :lastId ORDER BY id DESC LIMIT N`
6. **正**：深分页的分片场景放大倍数？**反**：代价 = N 个分片 × 单库深分页耗时
7. **正**：分片后分页的四种主流解？（优先级）反**：游标分页 → 限制翻页深度 → 覆盖索引延迟关联 → ES

---

*最后更新：2026-09-23*
