跳到正文

分片策略与扩容方案 ​

一句话:分片把一张逻辑表按分片键拆到多个物理库表,用路由换单机容量上限;代价是跨片查询、分布式事务和扩容迁移都从「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、分批迁移、对账、灰度切读、切写、保留回滚窗口,任何一步省了都会在数据一致性上还回来。

把一门技术讲透到既懂也会用