📖 本篇简介:当单表数据量突破千万、单机磁盘跑满,垂直扩展(加内存/加 CPU)已经到头,分库分表是必由之路。本篇系统讲解垂直拆分、水平拆分的思路,分片键的选择原则,常见分片算法,分区表与分库分表的区别,以及扩容、数据迁移的经典方案。学完本篇,你将能为一个千万级订单表设计合理的分片方案,并说出 ShardingSphere 等中间件的适用场景。本系列示例基于 MySQL 8.0 语法。
目录
- 1 为什么要分库分表
- 2 垂直拆分
- 3 水平拆分与分片键
- 4 常见分片算法
- 5 分库分表中间件
- 6 扩容与数据迁移
- 7 分区表
- 8 分库分表速查表
- 9 常见坑与注意事项
- 10 练习题与参考答案
1 为什么要分库分表
单表数据量到什么程度该拆?业界经验值:
| 数据量 | 表现 | 建议 |
|---|---|---|
| < 100 万行 | 查询很快 | 不用拆 |
| 100 万 ~ 1000 万 | 加索引尚可 | 优化 SQL、加索引 |
| 1000 万 ~ 5000 万 | 查询开始慢、DDL 慢 | 考虑读写分离、归档 |
| > 5000 万 / 单表 > 50GB | DDL 动辄几小时、备份慢 | 必须分库分表 |
分库分表本质上是"把一张大表拆成多张小表,分散到多台机器上",让每台机器的负载回到单库单表的健康水平。
1.1 垂直拆分 vs 水平拆分
分库分表两大方向:
- 垂直拆分(Vertical):按业务/字段拆。要么把不同业务的表拆到不同库(订单库、用户库),要么把一张表的冷字段拆到另一张表;
- 水平拆分(Horizontal):按行拆。把同一张表的数据按某个规则(如 user_id 取模)分散到 N 张结构完全相同的表 / 库。
垂直拆分: 水平拆分:
┌──────────────┐ ┌──────────────┐
│ 订单库 │ │ orders_0 │
│ orders表 │ │ orders_1 │
├──────────────┤ vs │ orders_2 │
│ 用户库 │ │ orders_3 │
│ users表 │ │ ... │
└──────────────┘ └──────────────┘
实际项目里通常是"先垂直拆业务、再水平拆大表"组合使用。
2 垂直拆分
2.1 按业务拆库
互联网公司最早的拆分动作,就是按业务域把单库拆成多个库:
- 早期:一个
shop库里面有 users、orders、products、payments 等所有表; - 垂直拆分后:
user_db、order_db、product_db、pay_db分别独立。
好处:
1. 不同业务可以独立扩容、独立部署;
2. 一个业务出问题不会拖垮整库;
3. 团队分工清晰,订单团队只管 order_db。
代价:跨库 JOIN 不能直接用了,应用层要组装。
2.2 按字段拆表
有些表有"热字段 + 冷字段"的特性。比如商品表 products:
products 表(热字段):id, name, price, stock, status
products_detail 表(冷字段):id, description, detail_html, images, specs_json
热字段经常被列表查询用到,冷字段(描述、详情图)只有商品详情页才查。把冷字段拆到 products_detail 表,主表变小、Buffer Pool 命中率更高。
-- 建商品主表(热字段)
CREATE TABLE products (
id BIGINT PRIMARY KEY,
name VARCHAR(100),
price DECIMAL(10,2),
stock INT,
status TINYINT
);
-- 建商品详情表(冷字段,按 id 一对一)
CREATE TABLE products_detail (
product_id BIGINT PRIMARY KEY,
description TEXT,
detail_html MEDIUMTEXT,
images JSON,
specs JSON
);
3 水平拆分与分片键
3.1 什么是分片键
水平拆分时,决定"一行数据落到哪张表/哪个库"的字段叫分片键(Sharding Key)。比如按 user_id % 16 分 16 张表,分片键就是 user_id。
分片键选得好不好,直接决定了后续所有查询能不能命中单表。
3.2 分片键选择原则
| 原则 | 说明 |
|---|---|
| 高基数 | 值要分散,避免热点 |
| 高查询频率 | 业务最常作为 WHERE 条件的字段 |
| 不轻易变更 | 选了之后很难换,换了要全量迁移 |
| 尽量唯一 | 优先选 user_id / order_id 这类唯一键 |
反例:
- 用
status当分片键:所有"待支付"订单都挤在同一分片,热点; - 用
create_time当分片键(按天):跨时间范围查询要扫所有分片; - 用没索引的字段当分片键:分片路由后还要再扫全表。
3.3 典型分片键选择
| 业务表 | 推荐分片键 | 理由 |
|---|---|---|
| 订单表 | user_id | 90% 查询是"我的订单" |
| 交易流水表 | user_id | 同上 |
| 日志表 | 按时间(月)+ 随机 | 只按时间归档,不分片键 |
| 消息表 | user_id | 用户维度查询 |
4 常见分片算法
4.1 取模(Mod)
最经典:shard = hash(user_id) % N。比如分 16 张表:
orders_0: user_id % 16 == 0
orders_1: user_id % 16 == 1
...
orders_15: user_id % 16 == 15
优点:数据分布均匀、热点少。
缺点:扩容时 N 变了,几乎所有数据都要迁移(比如从 16 扩到 32,大部分 user_id 的取模结果变了)。
4.2 范围(Range)
按分片键的范围分段:
orders_0: user_id ∈ [1, 1000000]
orders_1: user_id ∈ [1000001, 2000000]
...
优点:扩容简单,新加一段就行。
缺点:容易热点(新数据都写最新那段),范围查询跨分片多。
4.3 一致性哈希(Consistent Hashing)
把节点和 key 都映射到一个 0~2^32 的环上,key 顺时针找到第一个节点。扩容时只影响相邻节点的数据,迁移量小。
# 一致性哈希极简示意(本机实跑)
class HashRing:
def __init__(self, nodes):
# 环:sorted({hash(node): node})
self.ring = sorted({hash(n): n for n in nodes}.items())
def get(self, key):
h = hash(key)
# 顺时针找第一个大于等于 h 的节点
for pos, node in self.ring:
if pos >= h:
return node
return self.ring[0][1] # 兜回第一个节点
ring = HashRing(["db0", "db1", "db2", "db3"])
for uid in [1001, 1002, 1003, 1004, 1005]:
print(f"user_id={uid} → {ring.get(uid)}")
实跑输出(Python 的 hash 每次进程随机化,节点少时分布可能偏,但同一个 key 多次路由结果稳定):
user_id=1001 → db3
user_id=1002 → db3
user_id=1003 → db3
user_id=1004 → db3
user_id=1005 → db3
生产环境的一致性哈希会给每个节点配多个"虚拟节点",解决节点少、分布不均的问题。虚拟节点数一般设为真实节点的 100~200 倍。
4.4 时间维度(按月/按天)
日志、流水这类表,按 YYYYMM 分表:orders_202601、orders_202602...
优点:归档简单(直接 DROP 老表);缺点:跨月查询要扫多表。
5 分库分表中间件
业务自己写代码路由分片太麻烦,业界有成熟的中间件:
| 中间件 | 类型 | 特点 |
|---|---|---|
| ShardingSphere(JDBC) | 客户端 jar | 轻量、和应用同进程,灵活 |
| ShardingSphere-Proxy | 代理 | 独立部署,对应用透明,像 MySQL |
| MyCat | 代理 | 老牌,国内用得多 |
| Vitess | 代理 | YouTube 开源,Go 写的 |
| 云厂商 DDS | 云 | 零运维,锁云 |
ShardingSphere 是 Apache 顶级项目,国内最主流。它的配置示例(yaml 节选):
# ShardingSphere 逻辑表 orders 配置
rules:
- !SHARDING
tables:
orders:
actualDataNodes: ds_${0..3}.orders_${0..15} # 4 库 × 16 表
tableStrategy:
standard:
shardingColumn: user_id # 分片键
shardingAlgorithmName: order_inline
keyGenerateStrategy:
column: order_id
keyGeneratorName: snowflake # 分布式 ID
shardingAlgorithms:
order_inline:
type: INLINE
props:
algorithm-expression: orders_${user_id % 16}
应用侧只连 ShardingSphere,写 SELECT * FROM orders WHERE user_id=1001,中间件自动路由到 ds_2.orders_1。
5.1 分布式 ID
分库分表后,自增主键不能用了(每个库都从 1 开始会冲突)。常见方案:
| 方案 | 优点 | 缺点 |
|---|---|---|
| UUID | 简单 | 太长、无序、做主键性能差 |
| 号段模式 | 性能高 | 依赖 DB |
| 雪花算法(Snowflake) | 趋势递增、本地生成 | 依赖机器时钟 |
| Redis INCR | 简单 | 依赖 Redis |
Snowflake 是最常用的:64 位 long,由"时间戳 + 机器 ID + 序列号"组成,本地生成、全局唯一、趋势递增。
5.2 分布式事务
分库分表后,一个业务操作往往要写多个库。比如"下单"要写 order_db 的订单、扣 product_db 的库存、写 pay_db 的流水,本地事务管不到跨库。常见方案:
| 方案 | 原理 | 适用 |
|---|---|---|
| 2PC / XA | 两阶段提交,强一致 | 性能差,少用 |
| TCC | Try-Confirm-Cancel,业务层实现 | 金融核心 |
| Saga | 长事务拆多步,每步有补偿 | 互联网常见 |
| 本地消息表 | 本地事务写消息表,异步投递 | 最终一致最常用 |
本地消息表是互联网公司最常用的"最终一致"方案:订单库本地事务里同时写订单和消息表,后台任务轮询消息表、投递给 MQ,库存库消费 MQ 扣库存。即使中间某步失败,重试即可。
6 扩容与数据迁移
最痛的不是第一次分片,而是从 16 张表扩到 32 张表。
6.1 双写迁移法
经典扩容流程:
1. 老分片按 user_id % 16 路由;
2. 新分片按 user_id % 32 路由;
3. 应用同时写老表和新表(双写);
4. 跑历史数据迁移程序,把老表数据按新规则导到新表;
5. 校验一致后,读切到新表;
6. 再观察一段时间,停老表写入。
-- 迁移程序示意(伪 SQL):把老 orders_0 的数据按新规则分散到新表
INSERT INTO orders_0_new SELECT * FROM orders_0 WHERE user_id % 32 = 0;
INSERT INTO orders_1_new SELECT * FROM orders_0 WHERE user_id % 32 = 1;
-- ... 老表一行可能要拆到多张新表
6.2 停机迁移 vs 在线迁移
| 方式 | 停机迁移 | 在线双写迁移 |
|---|---|---|
| 业务影响 | 要停几小时 | 不停机 |
| 复杂度 | 简单 | 高 |
| 适用 | 小表、非核心业务 | 大表、核心业务 |
大表迁移必须在线双写,停机一小时业务损失巨大。
7 分区表
分区表(Partition)和分库分表容易混淆。它是逻辑上一张表、物理上多个文件,由 MySQL 自己管理,对应用透明。
7.1 分区 vs 分库分表
| 维度 | 分区表 | 分库分表 |
|---|---|---|
| 由谁管理 | MySQL 自动 | 应用/中间件路由 |
| 表结构 | 一张表 | 多张结构相同的表 |
| 跨节点 | 单机 | 多机 |
| 容量上限 | 单机磁盘 | 水平扩展 |
| 适用 | 单表过大但还能单机扛 | 必须水平扩展 |
7.2 RANGE 分区示例
按时间分区,日志类表最常用:
-- 按创建时间按月分区
CREATE TABLE orders (
id BIGINT,
user_id BIGINT,
amount DECIMAL(10,2),
create_time DATETIME,
PRIMARY KEY (id, create_time) -- 分区键必须在主键里
)
PARTITION BY RANGE (TO_DAYS(create_time)) (
PARTITION p202601 VALUES LESS THAN (TO_DAYS('2026-02-01')),
PARTITION p202602 VALUES LESS THAN (TO_DAYS('2026-03-01')),
PARTITION p202603 VALUES LESS THAN (TO_DAYS('2026-04-01')),
PARTITION pmax VALUES LESS THAN MAXVALUE
);
查询带 WHERE create_time BETWEEN ... 时,MySQL 只扫相关分区(Partition Pruning),性能接近单表。
7.3 分区类型
| 类型 | 适用 |
|---|---|
| RANGE | 按范围(时间、金额) |
| LIST | 按枚举值(地区、品类) |
| HASH | 均匀分布 |
| KEY | 类似 HASH,用 MySQL 自带 hash |
7.4 广播表与绑定表
ShardingSphere 里还有两个常用概念:
- 广播表(Broadcast Table):像字典表(省份、品类)这种数据量小、更新少的表,在每个分片库都存一份,JOIN 时不用跨库;
- 绑定表(Binding Table):orders 和 order_item 都按 user_id 分片,同一个 user_id 的订单和订单明细落到同一个分片,JOIN 可以本地完成。
这两种设计能大幅减少跨片 JOIN,是分库分表后保持查询性能的关键。
8 分库分表速查表
| 概念 | 一句话解释 |
|---|---|
| 垂直拆分 | 按业务/字段拆库拆表 |
| 水平拆分 | 按行拆到多表多库 |
| 分片键 | 决定一行落在哪的字段 |
| 取模算法 | hash(key) % N |
| 范围算法 | 按 key 区间分段 |
| 一致性哈希 | 扩容迁移量小 |
| 分布式 ID | Snowflake / 号段 |
| 分区表 | MySQL 自动管理,单表多文件 |
| 双写迁移 | 扩容时同时写新旧表 |
| ShardingSphere | 主流分片中间件 |
关键决策速记:
| 场景 | 推荐方案 |
|---|---|
| 单表 < 1000 万 | 不分,优化 SQL |
| 单表 1000 万 ~ 5000 万 | 读写分离 + 归档 |
| 单表 > 5000 万 | 水平分表 |
| 日志/流水 | 按时间分区或分表 |
| 跨分片 JOIN 多 | 重新考虑分片键 |
9 常见坑与注意事项
坑 1:分片键选错,跨片查询多。
选了 create_time 当分片键,但业务最常查"我的订单"(按 user_id)。每次查都要扫所有分片,性能极差。选分片键前必须先看业务最常用的查询条件。
坑 2:跨片 JOIN。
分库分表后,orders 和 users 在不同库,JOIN 不能直接写。要应用层组装,或者把冗余字段(user_name)存到订单表。
坑 3:分布式事务。
一个业务操作要写两个库(订单库 + 库存库),本地事务管不到。要引入 TCC、Saga、或最终一致(消息队列)。
坑 4:扩容时停机几小时。
大表迁移没做双写,直接停机导数据。几小时停机对核心业务不可接受。必须在线双写。
坑 5:自增主键还想用。
分库分表后每个库自增 ID 会重复。必须用分布式 ID(Snowflake 等)。
坑 6:把分区表当分库分表用。
分区表还是单机,磁盘跑满了还是要拆。分区只解决"单表文件过大、DDL 慢"的问题,不解决水平扩展。
坑 7:分片数一开始就按极限设。
一开始就分 1024 张表,管理复杂、查询慢。一般按"未来 3 年数据量"估算,分 16~64 张起步。
坑 8:跨片分页排序。
SELECT * FROM orders ORDER BY create_time DESC LIMIT 10000, 20 在分片环境下,每个分片都要返回前 10020 条,由中间件合并排序再取 20 条。跨片深分页性能极差,业务上要避免,或者强制带分片键。
10 练习题与参考答案
练习 1:垂直拆分和水平拆分的本质区别是什么?
<details>
<summary>参考答案</summary>
垂直拆分是按业务/字段拆:把不同业务的表拆到不同库,或把一张表的冷字段拆到另一张表。水平拆分是按行拆:同一张表的数据按某个规则分散到 N 张结构相同的表/库。前者解决"业务耦合、单表字段过多",后者解决"单表数据量过大"。
</details>
练习 2:分片键选择要遵循哪几条原则?
<details>
<summary>参考答案</summary>
高基数(值分散、不热点)、高查询频率(业务最常 WHERE 的字段)、不轻易变更(选了难换)、尽量唯一(优先 user_id/order_id)。
</details>
练习 3:订单表 5000 万行,90% 查询是"我的订单"(按 user_id),该怎么分?
<details>
<summary>参考答案</summary>
按 user_id 取模分 16 张表(orders_0 ~ orders_15),分布式 ID 用 Snowflake。"我的订单"查询只路由到一张表,性能好。订单详情查询用 order_id,order_id 里嵌入 user_id 信息(雪花算法或设计时绑定),也能路由到对应分片。
</details>
练习 4:16 张表要扩到 32 张,怎么不停机完成?
<details>
<summary>参考答案</summary>
双写迁移:应用同时按新旧规则写;跑迁移程序把老表数据按新规则导到新表;校验一致后读切新表;观察一段时间停老写。
</details>
练习 5:分区表和分库分表有什么区别?什么时候用分区表?
<details>
<summary>参考答案</summary>
分区表是 MySQL 自动管理的单表多文件,对应用透明,不跨机器;分库分表是应用/中间件路由到多机多表。分区表适合"单表过大但还能单机扛、按时间归档"的场景(如日志);数据量必须水平扩展到多机时才用分库分表。
</details>
练习 6:什么是广播表和绑定表?为什么它们能减少跨片 JOIN?
<details>
<summary>参考答案</summary>
广播表是小字典表在每个分片库都存一份,JOIN 时本地就能完成;绑定表是两张表按相同分片键(如都按 user_id)分片,同一个 user_id 的数据落到同一分片,JOIN 也是本地完成。两者都避免了把数据拉到中间件合并。
</details>
下一篇预告:第 24 章 性能监控与参数调优
文章回复
0 条公开回复