MySQL 分库分表:什么时候需要,怎么设计?
MySQL 的单机能力并不是无限的。
当数据规模、并发请求和存储需求持续增长时,即使已经完成索引优化、SQL 优化、缓存、连接池和读写分离,单个 MySQL 实例仍然可能成为系统瓶颈。
这时候才需要考虑一个更重的方案:
分库分表(Sharding)。
分库分表本质上是把原来集中在一个数据库中的数据和请求拆开,让多个数据库实例共同承担存储和访问压力。
但需要注意:
分库分表解决的是容量和并发扩展问题,并不会自动让每一条 SQL 都变快。
而且一旦进入分库分表阶段,原本简单的 SQL、事务、分页、JOIN、统计查询都会变得更加复杂。
因此,分库分表最重要的不是“怎么拆”,而是什么时候拆,以及按照什么规则拆。
1. 为什么需要分库分表
假设有一张订单表:
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
status TINYINT NOT NULL,
amount DECIMAL(12, 2) NOT NULL,
created_at DATETIME NOT NULL,
KEY idx_user_id (user_id),
KEY idx_created_at (created_at)
);
随着业务增长,这张表可能达到数亿甚至更大的数据量。
问题可能来自几个方面。
单表数据量过大
表越来越大以后:
索引越来越大;
Buffer Pool 缓存命中率下降;
查询和更新涉及更多磁盘 I/O;
索引维护成本增加;
ALTER TABLE 等运维操作更加困难;
备份、恢复时间增加。
即使查询本身使用了正确的索引,大表带来的资源压力仍然存在。
单实例并发能力有限
一个 MySQL 实例需要同时承担:
CPU
内存
磁盘 I/O
连接
锁
Redo Log
Binlog
网络
当业务继续增长时,单实例最终可能成为系统的集中瓶颈。
单纯增加硬件也存在上限
可以通过升级 CPU、内存、SSD 等方式提升单机能力,这就是 Scale Up。
但硬件升级存在成本和物理上限。
分库分表采用的是另一种思路:
应用
│
┌────────┼────────┐
↓ ↓ ↓
MySQL-1 MySQL-2 MySQL-3
通过增加数据库节点横向扩展容量和并发能力。
不过,在真正进行分库分表之前,通常应该先完成:
SQL 优化
↓
索引优化
↓
缓存
↓
连接池与参数优化
↓
读写分离
↓
分库分表
不要因为一张表变大,就直接开始分片。
2. 垂直拆分与水平拆分
分库分表通常可以分成两大类:
垂直拆分
水平拆分
两者解决的问题并不完全一样。
2.1 垂直分库
按照业务领域拆分数据库。
例如原来所有业务都在:
business_db
可以拆成:
user_db
order_db
payment_db
product_db
例如:
business
│
┌────────────┼────────────┐
↓ ↓ ↓
user_db order_db payment_db
│ │ │
用户数据 订单数据 支付数据
这样做的主要目的不是解决某一张表太大的问题,而是隔离不同业务的资源和访问压力。
例如订单系统出现流量高峰,不应该让支付、用户系统完全受到影响。
2.2 垂直分表
按照字段访问特点拆分。
例如:
user
├── id
├── username
├── email
├── avatar
├── profile
├── introduction
└── large_description
如果查询用户列表时只需要:
id
username
avatar
而一些大字段很少访问,就可以拆成:
user
user_profile
这样可以减少热点查询的数据宽度。
2.3 水平分表
水平分表不改变逻辑结构,而是把数据行拆到多个物理表。
例如:
orders
拆成:
orders_00
orders_01
orders_02
...
orders_15
逻辑上仍然是一张订单表。
例如:
user_id = 10001
根据分片规则计算:
10001 % 16 = 1
那么数据进入:
orders_01
查询时也按照同样规则找到对应表。
2.4 水平分库
进一步把这些表分布到不同数据库:
order_db_01
├── orders_00
├── orders_01
├── orders_02
└── orders_03
order_db_02
├── orders_04
├── orders_05
├── orders_06
└── orders_07
...
这样可以同时扩展:
存储容量
CPU
I/O
数据库连接
QPS
这才是通常意义上比较完整的 Sharding。
3. 什么时候应该分库分表
不存在一个绝对的标准,例如:
超过 1 亿行必须分表。
这种说法并不准确。
真正应该关注的是数据库是否已经成为业务的瓶颈。
可以重点观察:
数据规模
例如:
单表数据量持续增长
索引已经非常庞大
备份恢复窗口越来越长
DDL 操作越来越困难
查询压力
例如:
CPU 持续高
磁盘 I/O 持续高
QPS 接近实例能力上限
连接数持续增长
慢查询明显增加
业务增长
如果预计未来几年数据量和流量会增长一个数量级,就应该提前考虑扩展方案。
单实例容量
如果业务已经无法通过:
升级硬件;
优化 SQL;
优化索引;
增加缓存;
读写分离;
继续获得足够的性能,就可以考虑分库分表。
因此,一个更合理的判断方式是:
数据库是否已经成为瓶颈?
│
├── 否 → 继续单库优化
│
└── 是
│
├── 读压力 → 缓存 / 读写分离
│
├── 写压力 → 分库 / 分片
│
└── 数据容量 → 分表 / 分库
4. 分片键怎么设计
分库分表最重要的设计之一就是:
选择分片键(Sharding Key)。
假设:
orders
按照:
user_id
进行分片。
那么:
user_id = 10001
可以确定:
orders_01
这意味着应用可以直接把查询路由到正确的数据节点。
好的分片键通常具备几个特点
高基数
例如:
user_id
order_id
tenant_id
通常比:
status
gender
country
更适合作为分片键。
如果只有几个状态:
status = 0
status = 1
status = 2
那么分片之后仍然容易产生数据倾斜。
数据分布均匀
例如:
user_id % 16
在用户数量足够大的情况下,通常可以获得比较均匀的数据分布。
但实际业务中还要考虑用户活跃度。
如果某些用户远比其他用户活跃,仅仅数据量均匀,并不代表请求量均匀。
查询条件经常携带分片键
这是非常重要的一点。
例如分片键是:
user_id
那么:
SELECT *
FROM orders
WHERE user_id = 10001
ORDER BY id DESC
LIMIT 20;
可以直接路由到一个分片。
但如果查询:
SELECT *
FROM orders
WHERE status = 1
ORDER BY created_at DESC
LIMIT 20;
因为没有:
user_id
系统可能无法确定数据在哪个分片,只能查询多个分片,再进行合并。
这就是常说的:
广播查询。
因此,选择分片键时,不能只考虑“数据怎么均匀”,还必须考虑:
业务最主要的查询路径是什么?
不要轻易使用会变化的字段
例如使用:
department_id
作为分片键。
如果用户频繁换部门,就可能需要移动数据。
分片键最好是:
稳定
高基数
查询常用
分布合理
5. 分库分表后的 ID 怎么设计
分库分表以后,一个常见问题就是:
多个数据库都在生成 ID,如何保证全局唯一?
如果每个数据库都使用:
AUTO_INCREMENT
就可能出现:
db_01 → id = 100
db_02 → id = 100
这显然无法作为全局唯一 ID。
因此通常需要独立的 ID 生成方案。
雪花 ID
Snowflake 是比较常见的方案。
典型结构类似:
时间戳 + 机器/节点标识 + 序列号
优点:
全局唯一;
通常可以有序;
不依赖数据库;
生成速度快。
但需要注意时钟回拨问题。
UUID
UUID 可以直接生成全局唯一标识。
优点是简单,不依赖中心服务。
缺点是:
长度较大;
随机 UUID 作为 InnoDB 聚簇索引主键时可能造成页分裂和随机 I/O;
索引空间也更大。
如果使用 UUID,可以考虑 UUIDv7 等具有时间排序特征的方案。
ULID
ULID 同样兼顾:
唯一性
时间排序
分布式生成
适合一些对字符串 ID 接受度较高的系统。
号段模式
由一个中心服务预先分配 ID 区间:
服务 A → 100000 ~ 199999
服务 B → 200000 ~ 299999
应用在本地生成 ID。
这种方式性能很好,但需要解决号段服务的高可用问题。
实际项目中,可以根据业务特点选择:
Snowflake
UUID / UUIDv7
ULID
号段模式
而不是简单依赖每个分片的自增 ID。
6. 分页、排序与查询怎么处理
这是分库分表之后最容易出现问题的地方之一。
单表查询:
SELECT *
FROM orders
ORDER BY created_at DESC
LIMIT 20 OFFSET 100000;
已经存在深分页问题。
分成 16 个分片之后,问题会进一步复杂。
例如:
orders_00 → Top 20
orders_01 → Top 20
orders_02 → Top 20
...
orders_15 → Top 20
如果需要查询全局最新的 20 条数据,通常不能简单地只查询某一个分片。
一种典型方案是:
应用
│
┌──────┼──────┐
↓ ↓ ↓
Shard 1 Shard 2 Shard 3 ...
│ │ │
↓ ↓ ↓
Top N Top N Top N
└──────┼──────┘
↓
全局排序
↓
Top N
每个分片先取一部分候选数据,然后在应用层进行归并排序。
如果有 16 个分片,每个分片取 20 条,最终可能需要:
16 × 20 = 320
条数据进行归并。
深分页更加复杂
例如:
LIMIT 100000, 20
跨分片后,每个分片都可能需要处理大量数据。
因此之前介绍的 Keyset Pagination / Cursor Pagination 在分库分表场景中更加重要。
例如使用:
created_at + id
作为稳定排序条件:
WHERE
created_at < ?
OR (
created_at = ?
AND id < ?
)
ORDER BY created_at DESC, id DESC
LIMIT 20;
它可以避免不断增加 OFFSET。
但需要注意:
Cursor Pagination 可以解决深分页问题,却不能自动解决跨分片的全局排序问题。
跨分片查询仍然需要进行归并。
7. 跨库事务与跨库查询
分库分表之后,原本简单的事务会变得复杂。
例如原来:
创建订单
↓
扣减库存
↓
创建支付记录
可能全部在一个数据库事务中:
BEGIN;
INSERT INTO orders ...;
UPDATE inventory
SET stock = stock - 1
WHERE product_id = ?;
INSERT INTO payments ...;
COMMIT;
拆库以后可能变成:
order_db
↓
payment_db
↓
inventory_db
这时候普通 MySQL 本地事务已经无法覆盖所有操作。
尽量避免跨库 JOIN
例如:
SELECT *
FROM orders o
JOIN users u ON u.id = o.user_id;
如果:
orders → order_db
users → user_db
就无法再依赖普通 MySQL JOIN。
常见解决方式有三种。
应用层组装
先查询订单:
orders
得到:
user_id
再批量查询用户:
users
最后在应用层组装。
数据冗余
订单中直接保存:
user_id
user_name
这样订单列表不需要实时 JOIN 用户表。
这是一种非常常见的设计。
绑定分片键
如果订单和用户都按照:
user_id
进行分片,那么:
user_id = 10001
对应的数据都可以进入同一个分片。
这样可以尽可能保持原有的 JOIN 能力。
跨库事务怎么办
通常优先考虑:
业务拆分
最终一致性
消息队列
Outbox
Saga
TCC
而不是一上来使用 XA。
因为分布式事务会显著增加系统复杂度,并且对性能和故障处理提出更高要求。
8. 常见分片方案与中间件
实现分库分表大致有三种思路。
应用层自己实现
例如:
$shard = $userId % 16;
$table = sprintf('orders_%02d', $shard);
然后应用自己负责:
分片路由
SQL 执行
跨分片查询
结果合并
事务处理
优点是:
灵活;
可控;
没有额外中间件。
缺点也很明显:
业务代码会逐渐被大量分片逻辑污染。
ShardingSphere
Apache ShardingSphere 是比较成熟的数据库分片生态,可以提供:
分库分表;
分片路由;
读写分离;
分布式事务相关能力;
数据加密等能力。
对于 Java 生态尤其常见。
如果 PHP 应用需要使用类似方案,需要根据实际技术栈选择合适的接入方式,而不是直接假设所有语言都拥有同样完整的客户端能力。
MyCat 等数据库中间件
另一种方式是在应用和 MySQL 之间增加数据库代理层:
Application
↓
Database Proxy
↓
┌────┼────┐
↓ ↓ ↓
DB1 DB2 DB3
应用看到的是一个逻辑数据库。
中间件负责:
SQL 解析
分片路由
SQL 改写
结果合并
这种方案可以降低应用改造成本,但同时引入了新的基础设施。
生产环境需要重点关注:
代理层性能
高可用
故障恢复
SQL 兼容性
运维复杂度
9. 分库分表最容易踩的坑
不要过早分片
分库分表不是性能优化的银弹。
如果一张表只有几百万行,但 SQL、索引、缓存都没有优化,就直接进行分片,往往只是把问题复杂化。
不要只考虑数据量
假设:
Shard 1 → 1 亿数据
Shard 2 → 1 亿数据
看起来非常均匀。
但如果:
Shard 1 → 1000 QPS
Shard 2 → 100 QPS
依然存在明显的热点。
因此需要同时关注:
数据分布
QPS 分布
业务访问模式
不要让分片键无法参与查询
如果绝大多数查询都没有分片键:
user_id
那么大量查询都会变成:
Shard 1
Shard 2
Shard 3
...
Shard N
最后再合并结果。
分片带来的扩展能力可能被广播查询抵消。
不要忽略扩容
最简单的:
user_id % 16
在固定 16 个分片时非常容易理解。
但如果以后增加到:
32 个分片
那么:
user_id % 32
会导致大量历史数据需要重新迁移。
因此生产环境更推荐设计逻辑分片层。
例如:
user_id
↓
逻辑分片
↓
物理数据库
可以预先规划较多逻辑分片,再通过映射关系将逻辑分片分配到实际数据库节点。
这样以后扩容时:
逻辑分片
↓
重新映射
↓
新的数据库节点
迁移范围会更加可控。
不要忽略数据迁移
分库分表通常不是:
改一下配置
↓
上线
生产迁移更接近:
建立新分片
↓
历史数据迁移
↓
数据校验
↓
增量同步 / 双写
↓
灰度切换
↓
观察
↓
完成切换
迁移期间还需要重点关注:
数据一致性
重复数据
漏数据
主键冲突
业务停机时间
回滚方案
因此分库分表应该在系统设计阶段尽可能预留扩展能力,而不是等到数据库已经无法承受时才开始设计。
10. 总结
分库分表本质上是在解决一个问题:
单个数据库实例已经无法继续承担业务的容量和并发增长。
完整的演进路线通常是:
单库单表
↓
SQL / 索引优化
↓
缓存
↓
读写分离
↓
垂直拆分
↓
水平分表
↓
水平分库
其中每一步都应该有明确的业务和性能原因,而不是为了“架构看起来更高级”。
真正进入分库分表阶段以后,需要重点解决:
分片键
全局 ID
查询路由
分页
全局排序
聚合
JOIN
事务
数据迁移
扩容
数据一致性
尤其是分片键,它几乎决定了整个系统后续的数据访问方式。
例如订单系统选择:
user_id
作为分片键,那么后续的查询、索引、分页、数据冗余、扩容方案,都应该围绕这个选择进行设计。
所以分库分表最重要的原则不是:
“把一张大表拆成很多张小表。”
而是:
按照业务访问路径重新分配数据,让数据库能够通过增加节点继续水平扩展。
同时也要认识到,分库分表是一种有代价的架构设计。
它换来的不是简单的“查询更快”,而是:
更大的数据容量
更高的并发承载能力
更强的水平扩展能力
而代价则是:
查询复杂度 ↑
事务复杂度 ↑
运维复杂度 ↑
数据迁移成本 ↑
系统开发成本 ↑
因此,能不分就不分,需要分时再分,并且从第一天就考虑未来如何扩容。
这才是比较稳妥的 MySQL 分库分表设计思路。