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 分库分表设计思路。