主题
分片策略与扩容方案
一句话:分片把一张逻辑表按分片键拆到多个物理库表,用路由换单机容量上限;代价是跨片查询、分布式事务和扩容迁移都从「SQL 问题」变成「系统问题」。
适用版本:通用方案;ShardingSphere 5.5.x 的路由表达式语法见 02-ShardingSphere 深度配置。分片数是容量规划结果,不是固定值。
为什么需要
单库单表先遇到的通常不是 B+ 树太深,而是:
- 单机磁盘、IOPS、CPU 到顶,加索引也压不住写入。
- 大表 DDL、备份、恢复的时间窗口越来越长。
- 单表数据量让 Buffer Pool 命中率下降,查询抖动。
分片把数据和写入压力分散到多个实例,让每张物理表回到可控规模。它解决的是「单机容量和吞吐」,不是「查询变快」——跨片查询反而更慢。
核心机制
先判断:什么时候不该分片
| 先尝试 | 适用场景 | 为什么比分片便宜 |
|---|---|---|
| 加索引 / 改 SQL | 慢查询集中在少数 SQL | 不改架构,回滚成本低 |
| 读写分离 / 加从库 | 读多写少 | 复制延迟可接受时最直接 |
| 缓存 | 热点数据、重复读 | 挡住大部分读流量 |
| 归档 / 分区表 | 历史数据占大头 | 冷热分离,在线表变小 |
| 换引擎 / NewSQL | 需要弹性扩缩、分布式事务 | 运维复杂度转移给产品 |
经验阈值:单表 2000 万行开始评估、5000 万行认真考虑,但这只是经验值。真正该看的指标是单表大小、QPS、写入延迟、DDL/备份窗口;一张 1 亿行的冷表可能比分片更该归档。
分片键怎么选
分片键是第一个决定,选错后面全是补丁。
- 高基数:
sex只有两个值,最多两个分片。 - 查询覆盖:80% 以上的核心查询要带这个键,否则每次都广播。
- 分布均匀:按地区分片会让一线城市分片变成热点。
- 稳定不变:分片键一旦写入就不该更新,改键等于跨片搬数据。
常见优先级:user_id / customer_id(天然均匀、查询覆盖好)> order_id(均匀但查询常不带它)> 时间(适合归档,热点在最新分片)> 复合键(时间 + 取模,兼顾归档和均匀)。
如果核心查询有两个维度(用户查订单、客服按订单号查),常见做法是:用 user_id 做主分片键,另建 order_id → user_id 的映射表或缓存,避免按订单号查询时广播所有分片。
分片算法
| 算法 | 路由 | 均匀度 | 扩容代价 | 适用 |
|---|---|---|---|---|
| 取模 | id % N | 高 | 高,N 变化后大部分数据要重算 | 用户、订单 |
| 哈希取模 | hash(id) % N | 高 | 同取模 | 字符串键 |
| 范围 | [0,1000w) → 表0 | 低,尾部热点 | 低,加区间即可 | 流水、归档 |
| 时间 | 按月 → t_order_202601 | 低,最新表最热 | 低,按月加表 | 日志、账单 |
| 复合 | 时间 + 取模 | 中 | 中 | 大流量订单 |
一致性哈希的正确画法
一致性哈希把哈希值域首尾相连成一个环,节点按 hash(node) 落在环上,数据按 hash(key) 落在环上后顺时针找第一个节点。
数据永远归属「自己顺时针方向的第一个节点」:新增 D(700) 前,hash 落在 (500, 900] 的数据归 C;新增后只有 (500, 700] 改归 D,其余不动。换句话说,新节点只从它的顺时针后继节点接管一段区间,不是「迁移到新节点」这么模糊的说法。
一致性哈希的代价是:节点少时区间不均,需要虚拟节点(每个物理节点映射几十到几百个虚拟点)来打散;路由多一次查找,通常用有序结构二分。
扩容方案
| 方案 | 迁移量 | 前提 | 代价 |
|---|---|---|---|
| 翻倍扩容(N → 2N) | 约 50% | 分片数必须是 2 的幂,用位运算 | 仍需一次大迁移 |
| 一致性哈希 | 约 1/(N+1) | 接受虚拟节点和额外路由 | 实现和调试复杂 |
| 虚拟槽(如 16384 槽) | 按槽迁移 | 槽到节点的映射可改 | 需要槽管理组件 |
取模扩容的数学事实:N=4 时 id=5 在分片 1;N=8 时在分片 5。N 一变,绝大多数 ID 的目标分片都变,所以「取模 + 平滑扩容」本身是矛盾的;要么翻倍并接受 50% 迁移,要么一开始就用哈希环/虚拟槽。
跨片查询
| 查询 | 处理 |
|---|---|
| 带分片键等值 | 精确路由到一个分片(最优) |
| 不带分片键 | 广播到所有分片,再归并;分片越多越慢 |
| 跨片 JOIN | 绑定表可在单库完成;否则拆成多次查询在应用层组装 |
| 跨片排序 + 分页 | 每个分片取 top N 再归并;深分页代价极高 |
聚合 COUNT/SUM | 各分片算局部结果再汇总;AVG 要 SUM/COUNT 两次 |
实战场景
一、分片键选了 order_id,客服和用户查询都要广播 64 个分片
现象:订单表按 order_id % 64 分了 64 片,读写压测都正常。上线后运营后台“按用户查最近订单”和“按手机号查订单”两个高频接口 P99 到 3 秒,DB 监控显示一次查询会打满 64 个分片,慢查询日志里全是同一条 SQL 的 64 份拷贝。
排查:分片键是第一个决定,选错后面全是补丁。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 核心查询是否带分片键 | 统计 Top SQL 里 WHERE 是否含 order_id | 占比不足 20% 即命中 |
| 广播比例 | 中间件的“全路由”查询计数 | 高频接口全路由即成本来源 |
| 分片键基数 | 每个分片承载的查询量分布 | 基数低(如 sex)会限制分片数上限 |
| 键是否稳定 | 是否存在更新分片键的业务操作 | 改键等于跨片搬数据 |
| 第二维度需求 | 是否天然有两个高频查询入口(用户、订单号) | 单分片键无法同时覆盖 |
| 是否本来不该分片 | 单表行数、QPS、写入延迟、DDL/备份窗口 | 2000 万行只是经验值,冷表更该归档 |
对症做法:
text
1. 主分片键换成 user_id(天然均匀、80% 以上核心查询都带它):
用户维度查询单片命中,写入也均匀。
2. 第二维度用映射表而不是广播:
order_id → user_id 的映射(可以是 DB 表、也可以是 Redis/KV),
先查映射拿到 user_id,再按分片键精确路由。
3. 多维检索(状态 + 时间 + 渠道 + 关键字)不要靠分库分表硬扛:
写路径双写一份到 ES,列表查询走 ES 拿 ID 再回库取详情。
4. 手机号等辅助索引维度,用“索引表分片”:
以 phone 为分片键建 (phone, order_id) 索引表,与主表分片策略独立。
5. 分片键一旦写入就不该更新:需要变更的业务动作
(例如订单转移用户)要走“新增 + 删除”的跨片迁移流程。java
// 按订单号查的精确路由:两次单片查询替代 64 片广播
long userId = orderUserIdMapping.load(orderId); // 映射表/缓存
return orderMapper.selectByIdAndUserId(orderId, userId); // 带分片键,单片命中复盘要点:判据是“全路由查询占比”——上线后应从“个位数百分比”逐步压到接近 0,可按中间件的路由统计取数。同时要守住“先判断该不该分片”:加索引、读写分离、缓存、归档分区表都比分片便宜,回滚成本低;只有当单表大小、写入延迟、DDL 窗口三项同时承压时才动手,见 ShardingSphere 深度配置。
二、% 16 改成 % 32,一半数据要搬家且双写不一致
现象:容量规划不足,把 16 片扩到 32 片。方案是“直接改分片算法为 32”,结果切换期间出现两类错误:一部分订单查询不到(旧数据还在 16 片上,路由已按 32 算),一部分新写入被迁移任务覆盖。迁移跑了 26 小时,业务在头 3 小时已经报错。
排查:取模的分片数 N 一变,大部分数据的归属就重算。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 需搬迁比例 | id % 16 与 id % 32 的归属差异 | 取模扩容约需搬一半数据 |
| 迁移时长 | 历史数据量与限速能力 | 越长则不一致窗口越大 |
| 双写方向 | 迁移期间写的是旧片还是新片 | 两边都写就可能互相覆盖 |
| 路由一致性 | 应用配置中心的路由版本是否统一生效 | 部分实例用旧配置即“查不到” |
| 业务能否接受 | 迁移窗口内的读写语义 | 决定采用停机、双写还是预分片 |
对症做法:
text
1. 首选“预分片 + 虚拟分片”:
物理上先建 64 张表(虚拟分片),逻辑分片数可以变,
扩容只做“表 → 新库”的搬迁,不重算取模,搬迁量接近零。
2. 取模场景必须扩容时,用成倍扩容 + 双写迁移:
① 新集群按 32 片建好 → ② 应用双写(旧片继续服务读)
→ ③ 后台按主键区间回填历史 → ④ 校验两侧一致(行数 + 抽样字段)
→ ⑤ 切读到新片 → ⑥ 停旧片写。每一步都可回滚。
3. 一致性校验不能只看行数:
按分片做 count + 校验字段(金额合计、状态分布),差异进人工队列。
4. 迁移限速:按业务低峰跑,且给旧片留出写余量,
否则回填会把在线写拖成锁等待。
5. 时间维度分片(按月/按天)扩容成本最低,但热点在最新分片——
写压力全部集中在当前分片,需要配合“时间 + 取模”的复合分片。java
// 双写期间的幂等:以业务主键为准,迁移任务不覆盖更新的写
// INSERT ... ON DUPLICATE KEY UPDATE gmt_modified = VALUES(gmt_modified), ...
// WHERE 旧值.gmt_modified <= 新值.gmt_modified复盘要点:判据是“扩容过程中查询不到数据的错误率应为 0”,靠“读旧片直到校验通过”保证。更根本的是容量规划指标:单表行数、单库容量、DDL 窗口、备份窗口四项要有水位线,而不是等出问题再改算法——分片算法的成本在变更那一刻才显现。
三、跨片分页与聚合把内存打满,导出接口 OOM
现象:SELECT ... ORDER BY create_time DESC LIMIT 10000, 20 在 64 片上执行,中间件需要每片取 10020 条再归并,单次查询在内存里堆了约 64 万行;导出任务并发几个就 OOM,同时 count(*) 跨片统计耗时 40 秒。
排查:分片后的排序/聚合语义不变,但成本乘上分片数。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 归并放大了多少 | 单片返回行数 × 分片数 | offset 越大倍数越高 |
| 中间件内存 | 中间件进程堆使用与 memory 参数上限 | 与查询 offset 相关即命中 |
| 排序字段 | 是否所有分片都按同一索引有序 | 无索引则每片先 filesort |
| 聚合下推 | SUM/COUNT 是否下推到各片 | 未下推会拉全量回中间件 |
| 深分页需求 | 用户是否真的需要跳到第 N 页 | 多数“翻页”其实是遍历导出 |
| 多维查询 | 是否带非分片键的条件组合 | 该走 ES,不是分片 |
对症做法:
sql
-- 1. 游标分页代替 offset:每片只需返回 limit 条,成本与深度无关
SELECT id, order_no, create_time FROM t_order
WHERE user_id = ? AND create_time < ? -- 上一页最后一条的位置
ORDER BY create_time DESC LIMIT 20;
-- 排序键不唯一时用 (create_time, id) 组合游标,保证翻页确定
-- 2. 计数与列表分离:计数走预聚合表或缓存,不在请求里跨片 count(*)
SELECT shard_key, cnt FROM t_order_count WHERE date = ?; -- 各片定时汇总
-- 3. 导出走异步任务:按分片逐个扫(带主键区间),结果写对象存储
-- 绝不在在线接口里做“全片归并 + 深分页”text
4. 多维筛选/聚合迁到 ES:MySQL 只负责按主键/分片键的精确读写。
双写与映射表的一致性用“可靠事件 + 对账”保证,
见 [S04 可靠事件投递](../../S04_消息与中间件/09_RocketMQ/13-可靠事件投递:本地消息表与幂等.md)。
5. 必须跨片归并时给中间件设上限:
单次查询最大内存、最大归并行数、超时,超过直接拒绝而不是拖垮进程。复盘要点:判据是“单条查询的最大归并行数”和“中间件堆使用峰值”,都要有硬上限并拒绝超限请求,而不是靠调大内存续命。分片带来的是写与精确读的扩展性,代价是跨片查询成本乘性放大——这条交换要在决定分片时就写进方案,而不是上线后再补。
最小上手
先在单库验证分片键的分布,不要等上线后才发现倾斜:
sql
-- 假设按 user_id 取模 4 库
SELECT MOD(user_id, 4) AS shard, COUNT(*) AS rows_cnt
FROM t_order
GROUP BY shard
ORDER BY shard;用一段可复制的脚本验证一致性哈希的迁移范围(Python 3):
python
import bisect, hashlib
def h(s):
return int(hashlib.md5(s.encode()).hexdigest()[:8], 16)
ring = sorted([(h(n), n) for n in ("A", "B", "C")])
keys = [f"user:{i}" for i in range(10_000)]
def owner(k):
i = bisect.bisect_right([p for p, _ in ring], h(k)) % len(ring)
return ring[i][1]
before = {k: owner(k) for k in keys}
ring = sorted(ring + [(h("D"), "D")])
moved = [k for k in keys if owner(k) != before[k]]
print(f"moved={len(moved)} / {len(keys)} = {len(moved)/len(keys):.1%}")
# 期望:约 1/4 的数据从 C 迁到 D生产动作
- 容量规划:按「当前行数 + 3 年增长 × 单行大小」算每个分片的大小;单分片建议控制在几百 GB 以内,留出 DDL 和备份窗口。
- 迁移流程:双写(或 CDC)→ 历史数据分批迁移 → 条数 + 抽样校验 → 读流量 10%/50%/100% 灰度 → 写切换 → 老集群只读保留 7–14 天。
- 双写一致性:同一主键的写必须串行,或下游用 upsert;否则会出现 update 先于 create 到达、新集群字段缺失。
- 校验:不只看条数,抽样比对关键字段;差异要能定位到具体主键和批次。
- 监控:各分片行数/增长率偏差、单分片 QPS/CPU、广播查询比例、慢 SQL、双写失败数、对账差异数。
- 回滚:切换前确认老集群仍可写回或至少有反向同步;只读保留不等于能回滚。
边界
- 分片不解决查询慢:跨片查询、归并排序、深分页通常比分片前更慢。
- 分片键不可在线改:改分片键等于全量重分布,必须走迁移流程。
- 分布式事务变复杂:能单库完成的操作不要跨片;必须跨片时用 Seata 等方案,并接受性能与补偿复杂度。
- 全局 ID 是前置条件:自增主键在分片后冲突,必须换成雪花、号段或 UUID(不推荐当聚簇索引)。
- 运维成本是硬成本:分片数越多,DDL、备份、监控、故障定位都按分片数放大。
讲清楚
分片解决的是单机容量和写入吞吐,不是查询变快。先判断能不能用索引、从库、缓存、归档解决,再决定分。分片键要基数高、覆盖大部分查询、分布均匀且不变;取模均匀但扩容要迁移一半数据,一致性哈希只迁移相邻区间但要虚拟节点,虚拟槽把迁移粒度变小。跨片查询、JOIN、深分页和分布式事务都是分片后的固定成本。扩容走双写/CDC、分批迁移、对账、灰度切读、切写、保留回滚窗口,任何一步省了都会在数据一致性上还回来。