跨分片 JOIN 与分页方案

sharding 📚 learning sharding-join-pagination · sharding · join · pagination · binding-table · broadcast · keyset-pagination · deep-pagination

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 分片强(双写)多查询路径双写一致性
大宽表字段冗余到主表强读多写少写放大
服务化 JOINRPC 跨服务拼接中(缓存)跨域强耦合网络开销
ES/ClickHouse宽表同步到搜索/OLAP弱(异步)复杂查询/搜索同步延迟

绑定表(Binding Table)

要求:主子表用同一分片键且值相同。
-- 主表按 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。 配置示例:
# 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 分片。
-- 主表按 user_id 分片
orders_00(user_id, order_id, status, ...)
orders_01(...)

-- 异构表按 order_id 分片
order_index_00(order_id, user_id, status)
order_index_01(...)
查订单详情时:
  • 查异构表按 order_id 定位 → 拿到 user_id
  • 用 user_id 回主表精确定位
双写一致性:
  • 同事务写入主表 + 异构表(推荐)
  • 本地消息表异步
  • 定时对账修复
  • 极端情况允许短暂不一致

大宽表(Denormalization)

原理:把需要的字段直接冗余到主表。
-- 冗余 order.status、product.name、user.nickname
orders(user_id, order_id, status, product_name, user_nickname, ...)
代价:写放大(更新要改多份)、字段冗余、维护成本。 适合:读多写少,比如订单列表页展示字段。

服务化 JOIN 的三大优化

跨域强耦合场景(订单查用户服务)必须做:
  • 批量接口:一次 RPC 拉 N 条而不是 N 次 RPC 拉 1 条
  • 多级缓存:本地缓存 + Redis + 远端服务缓存(短时效缓 30 秒,长时效缓 5 分钟)
  • 异步加载:主查询走主库,附加数据异步加载,前端分段渲染

电商订单表的典型组合

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

分页

深分页的本质

SELECT * FROM t ORDER BY create_time LIMIT 100000, 10;
MySQL 要扫 100010 行,前 100000 行读回后丢弃。分片场景还要乘以分片数。 分片中间件执行流程:
  • 向每个分片发 SELECT * FROM t ORDER BY x LIMIT 0, offset+size
  • 所有分片返回后中间件做内存排序
  • 截断前 offset 行,返回后 size 行
代价 = N 个分片 × 单库深分页耗时。

四种主流解

解 1:游标分页(Keyset Pagination)
-- 第一页
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:延迟关联
-- 第一步:走覆盖索引,只取 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
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
  • 分片键与排序字段对齐 = 分页免费

常见误区

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

延伸追问

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

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

Anki 候选卡片

Q: 跨分片 JOIN 的六种方案?
A: 绑定表 / 广播表 / 异构索引表 / 大宽表 / 服务化 JOIN / ES
Q: 绑定表的前提条件?
A: 主子表必须使用同一分片键且值相同
Q: 广播表适合什么规模?
A: 万级以下的字典小表;100w+ 不适合
Q: LIMIT 100000,10 为什么慢?
A: MySQL 要扫 100010 行,前 100000 行读回后丢弃
Q: 游标分页的核心 SQL?
A: WHERE id < :lastId ORDER BY id DESC LIMIT N
Q: 深分页的分片场景放大倍数?
A: 代价 = N 个分片 × 单库深分页耗时

关联题目

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

关联知识

JOIN 走本地优先,分页走游标优先,最后才 ES
图书馆 JOIN:绑定表=同办公室翻文件,广播表=每个办公室备字典,异构索引表=通讯录先查号码;分页:普通=从第 1 本找 10 本,游标=从上次最后一本之后接着找
✦ 记 忆 口 诀 ✦
JOIN 优先级 绑定表 大于 广播表 大于 异构 大于 大宽表 大于 服务化 大于 ES / 分页优先级 游标 大于 限制深度 大于 覆盖索引 大于 ES
关键可视化
六种 JOIN 方案对比
flowchart TB
  J[跨分片 JOIN 方案] --> A[绑定表 主子表同分片键]
  J --> B[广播表 字典小表全量复制]
  J --> C[异构索引表 主表按 A 分片 索引表按 B 分片]
  J --> D[大宽表 反范式 字段冗余]
  J --> E[服务化 JOIN RPC 跨服务]
  J --> F[ES 或 ClickHouse 异步同步]
  A --> AR[强一致 需分片键一致]
  B --> BR[异步 内存翻倍 适合万级以下]
  C --> CR[双写 一致性靠对账修复]
  D --> DR[写放大 适合读多写少]
  E --> ER[网络开销 批量接口 多级缓存]
  F --> FR[弱一致 异步 搜索场景]
绑定表原理
flowchart LR
  M[主表 orders 按 user_id 分片] --> O0[orders_00 user_id 等于 1001]
  M --> O1[orders_01]
  M --> O2[orders_02]
  S[明细表 order_items 同 user_id 分片] --> I0[order_items_00]
  S --> I1[order_items_01]
  S --> I2[order_items_02]
  O0 -.同一分片.-> I0
  O1 -.同一分片.-> I1
  O2 -.同一分片.-> I2
  X[ShardingSphere 识别绑定] --> Y[自动本地 JOIN]
  Y --> Z[性能接近单库]
异构索引表
flowchart TB
  M[主表 按 user_id 分片] --> O0[orders_00 user_id]
  M --> O1[orders_01]
  H[异构表 按 order_id 分片] --> I0[order_index_00 order_id user_id]
  H --> I1[order_index_01]
  Q[查询订单详情] --> Q1[第一步 查异构表按 order_id 定位]
  Q1 --> Q2[拿到 user_id]
  Q2 --> Q3[第二步 回主表精确定位]
  Q3 --> Q4[返回订单数据]
深分页的本质
flowchart TB
  S[SELECT limit offset size] --> A[MySQL 扫 offset 加 size 行]
  A --> B[前 offset 行读回后丢弃]
  B --> C[分片场景 代价乘以 N]
  C --> D[例 limit 100000 10 分 8 片]
  D --> E[代价 8 乘以 单库深分页 100010 行]
  E --> F[分片中间件归并 top N 截断]
深分页四种主流解
flowchart TB
  P[深分页解] --> A[游标分页 keyset]
  A --> AR[WHERE id 小于 lastId ORDER BY id DESC LIMIT N]
  A --> AB[优点 恒定性能 无 offset]
  A --> AD[缺点 不能跳页 不能显示总页数]
  P --> B[覆盖索引加延迟关联]
  B --> BR[第一步 走覆盖索引取 id]
  B --> BR2[第二步 回表拿详情]
  B --> BB[索引比主表小 10 倍]
  P --> C[限制翻页深度]
  C --> CR[业务侧禁止翻到第 100 页后]
  C --> CC[99 百分号用户翻不到 20 页]
  P --> D[ES search_after]
  D --> DR[游标模式 天然支持]
电商订单表典型组合
flowchart LR
  O[电商订单] --> M[主表 orders 按 user_id 分片]
  O --> I[明细表 order_items 绑定表]
  O --> C[商品类目表 广播表]
  O --> P[phone_index 异构索引 按手机号]
  O --> E[es_order_index 搜索场景]
  M --> M1[我的订单 90 主查询]
  I --> I1[订单详情]
  C --> C1[类目筛选 广播本地]
  P --> P1[按手机号查订单]
  E --> E1[搜索 后台管理 报表]
知识关系

⬆️ 前置(Prerequisite)

sharding-fundamentalssharding-algorithms-id

🔄 延伸(Extends)

sharding-migration

⚡ 对比(Contrast)

缓存三失效— 分片后的 JOIN/分页问题也常靠缓存层解决;两个领域都强调'降级到最简方案'
🎯 概念 📏 规则 ⚠️ 误区 🔍 追问 ✨ 口诀 共 0 张卡,点击翻面