适用读者:正被"单表 8000 万行,要不要分"这个问题困住的架构师 / 技术负责人。
前置:S11-A02(部署与迁移)、A03(MySQL 到 ES 与缓存一致性)、S17(分布式基础)。
覆盖旧文:4580(单库单表与分库分表的优缺点)、3196(基于 Hash 散列的数据库路由组件设计)→ 2 篇 / 约 5,000 字符。
扩展点:E-S11-029(分不分表的四项量化判据)、E-S11-030(分片键选型与三种路由算法)、E-S11-031(扩容双写与灰度迁移的验收条件)。
上一篇:S11-J08 · 约束、变量、游标、触发器与存储函数:服务端编程的边界
下一篇:S11-X02 · 安装、管理与片段登记。
1. 一句话结论
分库分表是"最后手段",不是"扩展手段"。 它把事务、联表、排序分页、全局唯一 ID、运维五件事同时变难,换来的是写吞吐与单表规模。只有当"索引 + 归档 + 读写分离 + 缓存"全都试过仍不够时,才轮到它。
出处:
A-4580(优缺点对比)、A-3196(Hash 路由组件)。
2. 四项量化判据(E-S11-029)
| 判据 | 触发线(经验值) | 先做的替代方案 |
|---|---|---|
| 单表行数 | > 2,000 万(B+ 树 3 层 → 4 层,索引多一次 IO) | 归档历史、冷热分离 |
| 单表容量 | > 500 GB ~ 1 TB | 大字段外置(OSS/MinIO) |
| 写入 QPS | 单实例写 > 5,000(含索引维护) | 批量写、异步化、消息削峰 |
| 连接/锁竞争 | 活跃连接打满、行锁等待 P99 > 100 ms | 读写分离、缩短事务、分热点 |
判据:"行数多"不等于"必须分"——若访问永远是"按主键点查 + 最近 3 个月范围",归档 + 分区表就能解决 90% 场景,成本比分片低一个数量级。
分区表(Partition) vs 分表(Sharding):
| 维度 | 分区表 | 分库分表 |
|---|---|---|
| 实现层 | MySQL 原生(单实例) | 应用/中间件 |
| 跨分区查询 | 支持(可能扫全分区) | 需聚合层 |
| 事务 | 单实例事务 | 分布式事务 |
| 上限 | 单机资源 | 可水平扩 |
3. 分片键与三种路由算法(E-S11-030)
分片键(shard key)选型三条硬约束:
① 高基数:取值足够多(用户 ID 可以,性别/状态不行)
② 均匀:Hash 后分布均匀(避免热点分片)
③ 查询必带:绝大多数查询都带上它,否则跨片
| 算法 | 公式 | 优点 | 缺点 |
|---|---|---|---|
| 取模 | shard = hash(key) % N |
简单、均匀 | 扩容要搬数据(N 变化几乎全搬) |
| 范围 | 按 ID 区间/时间 | 范围查询友好、扩容简单 | 热点(新数据集中) |
| 一致性 Hash / 虚拟槽 | 哈希环 + 虚拟节点 | 扩容只搬 1/N | 实现复杂、需处理数据倾斜 |
旧文 3196 的 Hash 路由组件设计(重构表达):
// 路由组件四要素:分片函数、分片拓扑、路由缓存、兜底策略
public interface ShardRouter {
int shardOf(long userId, int shardCount); // ① 分片函数:hash(userId) & (N-1)(N 为 2 的幂时等价取模)
}
public final class DefaultShardRouter implements ShardRouter {
private final int shardCount; // ② 拓扑:2 的幂,便于位运算与成倍扩容
private final LoadingCache<Long, Integer> cache; // ③ 缓存:热点 key → 分片号(Guava/Caffeine)
public int shardOf(long userId, int shardCount) {
return (int) ((userId ^ (userId >>> 32)) & (shardCount - 1)); // 扰动后取低位
}
}
| 设计点 | 做法 | 理由 |
|---|---|---|
| 分片数取 2 的幂 | 16 / 32 / 64 | & (N-1) 代替 %,成倍扩容只搬一半 |
| Hash 扰动 | h ^ (h>>>32)(同 HashMap) |
避免低位规律性导致倾斜 |
| 路由缓存 | 本地 Caffeine + 短 TTL | 避免每次计算;拓扑变更靠 TTL 生效 |
| 兜底 | 分片不存在 → 默认片 + 告警 | 防止上线新用户时路由失败 |
不要用 userId.hashCode() |
依赖 JDK 实现 | 换语言/版本不一致 |
跨片查询的三种处理:
| 需求 | 处理 |
|---|---|
| 按非分片键查询 | 基因法(把分片键编进业务 ID)/ 二级索引表(ES) |
| 跨片排序分页 | 各片取前 N 再归并(内存排序);深分页改用游标 |
| 跨片聚合 | 并行查询 + 结果合并;或走 ES/数仓 |
4. 扩容与迁移:双写 → 校验 → 切读 → 下线(E-S11-031)
① 建新分片(N → 2N),旧数据按新规则回填(离线任务,限速)
② 双写:写请求同时写旧片与新片(以新片为准,旧片留兜底)
③ 校验:定时对比新旧片行数与关键字段摘要,差异率 < 0.01% 才继续
④ 切读:灰度按 userId 取模把读流量切到新路由(1% → 10% → 50% → 100%)
⑤ 下线旧路由:观察 7 天无异常后停双写、回收旧片
| 阶段 | 验收条件 | 回滚动作 |
|---|---|---|
| 回填 | 目标片数据完整(校验和一致) | 停任务、清目标片 |
| 双写 | 双写延迟 P99 < 50 ms,失败率 < 0.1% | 关双写开关(配置中心) |
| 切读 | 错误率、P99 不劣化 | 灰度比例退回上一档 |
| 下线 | 7 天无差异 | 保留旧片只读 30 天 |
ADR 摘要(可直接抄进评审):
决策:orders 表按 user_id 分 32 片(2 的幂),取模 + 扰动哈希
理由:写 QPS 已达 6k 且索引维护成本高;user_id 覆盖 98% 查询
代价:跨片分页需归并;订单号改为「时间戳 + 分片号 + 序列」保证全局唯一
替代方案:分区表(单机资源不够)、归档(历史数据已占 60%,仍需处理热数据)
回滚:双写开关 + 灰度比例,5 分钟内可退
5. 分布式 ID 与事务的连带问题
| 问题 | 方案 | 代价 |
|---|---|---|
| 全局唯一 ID | 雪花算法(时间 + 机器 + 序列)/ 号段模式(Leaf)/ UUIDv7 | 时钟回拨、号段服务单点 |
| 跨片事务 | 本地消息表 + 最终一致 / Seata AT / TCC | 复杂度、补偿逻辑 |
| 跨片唯一约束 | 独立唯一索引表 / Redis 占位 | 额外一次写 |
| 跨片 join | 冗余字段 / 宽表 / ES | 数据一致性维护 |
判据:分片之后"强一致"基本退出选项——架构上要默认接受最终一致,并准备好对账任务。
6. 反例:五种"分了更糟"
| 做法 | 后果 |
|---|---|
按 status 分片 |
只有几个值 → 严重倾斜 |
| 一开始就分 256 片 | 运维成本爆炸,单表反而小到浪费 |
| 分片后仍要求跨片强一致事务 | 引入分布式事务,延迟翻倍 |
| 分片键用业务可变字段(如手机号换绑) | 数据要迁移,路由失效 |
| 无灰度双写直接切 | 数据丢失且无法回滚 |
7. 自测
- 四项量化判据与对应替代方案?
- 分区表与分库分表的三点差别?
- 分片键三条硬约束?三种路由算法的扩容代价?
- 分片数为什么取 2 的幂?
- 扩容五阶段与各阶段回滚动作?
答案
- 行数 2kw / 容量 1TB / 写 5k / 锁等待;归档、大字段外置、异步化、读写分离。
- 单实例原生 vs 应用层;跨分区查询可能扫全分区;事务单机 vs 分布式。
- 高基数、均匀、查询必带;取模扩容全搬、范围有热点、一致性 Hash 搬 1/N。
& (N-1)替代取模且成倍扩容只搬一半。- 回填→双写→校验→切读→下线;停任务、关双写、退灰度、留只读。
8. 下一篇
S11-X02 · 安装、管理与片段登记 —— 安装/启动、SHOW 系列管理命令、复制表/临时表/元数据、以及教材残缺片段的处置表。
本篇属于系列《S11 · 数据库与 SQL》。旧文来源:A-4580、A-3196;扩展点 E-S11-029/030/031。版本口径 MySQL 8.0;8.4 相关项标待核。