06 分库分表——何时做、怎么做、做了之后怎么办
摘要: 分库分表是很多工程师心中”高级”的代名词,但它也是一把双刃剑——分了之后,事务、JOIN、分页、全局 ID、数据倾斜等一系列问题随之而来。本文首先回答”什么时候才需要分”——很多公司根本不需要分库分表,过早分裂是工程上的过度设计。然后详细分析垂直拆分(按业务维度分库/分表)和水平拆分(按行分片)的实现策略、分片键的选型原则、常见分片算法(哈希取模 vs 一致性哈希 vs 范围分片)的适用场景,以及分库分表之后必须面对的跨库 JOIN、分布式事务、分页查询、全局 ID 等工程难题的解决思路。
第 1 章 分库分表之前:先问三个问题
1.1 问题一:你的数据量真的到了需要分的地步吗
很多开发者在数据量只有几百万行时就开始考虑分库分表。实际上:
- 一张合理设计的 InnoDB 表,有良好的索引,在千万级别的数据量下性能仍然很好
- 单表亿级是一个重要的分界线——此时 B+Tree 层高通常在 4 层左右,普通查询仍然可接受,但 DDL(加列、加索引)开始变得危险
- 单表数十亿级:查询性能开始明显下降,建议分表;或者考虑 TiDB、ClickHouse 等分布式数据库
此外,数据量本身不是唯一标准,并发写入量同样重要。如果一张订单表每秒有几万次写入,即使数据量不大,单机 MySQL 的写入吞吐也可能成为瓶颈(单机 MySQL 的写入 TPS 通常在 2000-10000 之间,取决于硬件和业务模式)。
1.2 问题二:还有没有其他手段
在考虑分库分表之前,以下方案应该优先尝试:
| 方案 | 适用场景 | 复杂度 |
|---|---|---|
| 加索引 | 查询慢但数据量不大 | 低 |
| SQL 优化 | 慢查询,执行计划不佳 | 低 |
| 读写分离 | 读多写少,读请求压力大 | 中 |
| 归档冷数据 | 表大但热数据少 | 中 |
| 分区表 | 按时间范围查询,数据量大 | 中 |
| 升级硬件(SSD、更大内存) | I/O 瓶颈 | 低 |
| TiDB/PolarDB 等分布式 DB | 彻底解决单机限制 | 中高 |
分库分表是最后的手段,而不是第一选择。它引入的复杂度会贯穿整个系统的生命周期,维护成本极高。
1.3 问题三:分之后你能应对什么问题
在决定分之前,必须清楚地知道分库分表会带来哪些新问题,并有应对方案:
- 跨库 JOIN 消失:原来一条 JOIN 语句能完成的查询,分库后需要在应用层做数据聚合
- 分布式事务复杂化:跨库事务无法用 InnoDB 的本地事务保证,需要 2PC 或 Saga 等方案
- 分页和排序困难:
LIMIT offset, n在分库后需要从每个库取数据再合并排序 - 全局唯一 ID:跨库的主键不能再用单机自增,需要分布式 ID 方案
- 数据倾斜风险:选错分片键,某些库的数据量远大于其他库
如果这些问题你没有清晰的解决方案,请三思。
第 2 章 垂直拆分:按业务维度解耦
2.1 垂直分库
垂直分库是将一个单体数据库中的不同业务表,拆分到不同的数据库实例中。例如:
- 单体:一个
ecommerce数据库,包含users、orders、products、inventory、payments等表 - 拆分后:
user_db:用户相关表(users、user_addresses、user_profiles)order_db:订单相关表(orders、order_items)product_db:商品相关表(products、categories、brands)payment_db:支付相关表(payments、refunds)
垂直分库的价值:
- 解耦:不同业务域使用独立的数据库,一个业务的慢查询不会影响其他业务
- 独立扩容:订单量增长时,只需扩容
order_db,不影响user_db - 安全隔离:支付数据库可以设置更严格的访问控制
- 与微服务架构对齐:每个微服务拥有自己的数据库,是微服务架构的最佳实践
垂直分库的代价:
- 原来可以用 JOIN 连接的表,现在分布在不同数据库,JOIN 不再可用
- 涉及多个业务库的操作需要分布式事务
2.2 垂直分表
垂直分表是将一张宽表(列很多)拆分为多张窄表,通常按照”热列”和”冷列”来拆分。
适用场景:当一张表有几十个字段,但每次查询通常只用到其中几个字段时,宽表的设计会浪费 I/O——InnoDB 读取行数据是以整行为单位的,即使你只 SELECT 了 3 个字段,InnoDB 也读取了整行(包含几十个字段)。
-- 宽表设计(一张表 50 列)
CREATE TABLE users (
id BIGINT PRIMARY KEY,
name VARCHAR(64),
email VARCHAR(128),
-- 以上是高频查询的列 (热列)
bio TEXT,
preferences JSON,
last_login_at DATETIME,
login_ip VARCHAR(64),
-- 以下是低频访问的列 (冷列)
...30 更多冷列
);
-- 垂直分表后
CREATE TABLE users ( -- 核心表,高频访问
id BIGINT PRIMARY KEY,
name VARCHAR(64),
email VARCHAR(128)
);
CREATE TABLE user_extends ( -- 扩展表,低频访问
user_id BIGINT PRIMARY KEY, -- 与 users 表 1:1 关系
bio TEXT,
preferences JSON,
...
);垂直分表的好处是:核心表变”瘦”,每个页可以容纳更多行,扫描同样行数需要的 I/O 更少,Buffer Pool 的缓存效率更高。
第 3 章 水平拆分:按行分片
3.1 水平分表 vs 水平分库
水平分表:将一张大表的行,按照某种规则(分片键)分散到同一个数据库实例中的多张结构相同的子表(如 orders_0、orders_1、…、orders_63)。解决单表数据量过大的问题,但不解决单机 I/O 和写入吞吐的限制。
水平分库:将数据分散到多个数据库实例中,每个实例上可能再有水平分表。真正解决了单机的存储和吞吐限制。
实践中两者通常结合:分库 + 分表(如 4 个库 × 16 张表 = 64 个分片)。
3.2 分片键的选型——最关键的决策
分片键(Sharding Key) 是决定一条记录属于哪个分片的列,是水平分片最关键的设计决策,一旦选定后很难更改。
分片键选型原则:
原则一:高基数(Cardinality)。分片键的取值应该足够分散,使数据均匀分布在各个分片上,避免数据倾斜。如果用 status(只有几种状态)做分片键,大部分数据可能集中在少数几个分片上。
原则二:业务关联性。分片键应该与最核心的查询条件一致。如果绝大多数查询都是”按用户查订单”,分片键应该是 user_id,这样同一用户的所有订单都在同一个分片,查询时只需访问一个分片(避免跨分片 scatter-gather 查询)。
原则三:稳定性。分片键的值不能经常变化。如果分片键的值变了,记录需要从一个分片迁移到另一个分片——这个操作代价极高。订单号、用户 ID 都是稳定的,而 status 之类的可变字段不适合做分片键。
常见的分片键选择:
| 业务场景 | 推荐分片键 | 说明 |
|---|---|---|
| 用户中心 | user_id | 所有用户数据按 user_id 分片 |
| 订单系统 | user_id 或 order_id | 用 user_id 查询方便,用 order_id 则全局唯一性强 |
| 商品系统 | shop_id | 同一店铺的商品在同一分片 |
| 消息系统 | sender_id 或 receiver_id | 取决于读取模式(发件箱/收件箱) |
3.3 三种分片算法的对比
3.3.1 哈希取模(Hash Modulo)
分片编号 = hash(分片键值) % 分片总数
优点:数据分布最均匀(对均匀分布的分片键)、实现简单、查询时可以精确路由到单分片(知道分片键值时)
缺点:扩缩容困难——当分片数从 64 变为 128 时,几乎所有数据的分片编号都会变化,需要全量迁移数据。通常使用虚节点(Virtual Node)或提前预分配足够多的分片来缓解。
3.3.2 一致性哈希(Consistent Hashing)
将哈希值空间组织成一个环(0 到 2³²-1),每个分片节点占据环上的一个范围。数据的哈希值落在哪个范围,就属于哪个节点。
优点:添加或删除节点时,只有相邻节点上的数据需要迁移,迁移量最小(约 1/N 的数据,N 为分片数)
缺点:数据分布可能不均匀(节点占据的环空间不一定相等),需要虚节点来改善均匀性;实现比取模复杂
3.3.3 范围分片(Range Sharding)
按照分片键的值范围分片,如:
user_id < 10000000→ 分片 010000000 <= user_id < 20000000→ 分片 1- …
优点:范围查询高效(按时间范围或 ID 范围的查询只访问连续的分片),扩容时只需添加新的范围段
缺点:热点问题严重——最新注册的用户都在最后一个分片(最近的时间范围),导致写入集中在少数分片(“末尾热点”),其他分片闲置
第 4 章 分库分表之后的工程难题
4.1 跨分片 JOIN 的解决方案
分库分表后,orders 和 users 可能在不同的数据库,无法直接 JOIN。常见解决方案:
方案一:应用层聚合
先查询一张表,然后用结果集的 ID 批量查询另一张表,在应用层组装结果。
// 查询订单
List<Order> orders = orderDao.queryByUserId(userId);
// 批量获取商品信息
List<Long> productIds = orders.stream().map(Order::getProductId).collect(...);
List<Product> products = productDao.batchGet(productIds);
// 在内存中组装
Map<Long, Product> productMap = products.stream().collect(...);
orders.forEach(o -> o.setProduct(productMap.get(o.getProductId())));方案二:数据冗余(宽表)
在写入时冗余存储 JOIN 所需的字段,避免查询时的 JOIN。如在 order_items 表中冗余存储 product_name 和 snapshot_price——即使 products 表在另一个库,查询订单详情时也不需要 JOIN。
方案三:数据同步到搜索/分析系统
对于复杂的多维度查询,将数据同步到 Elasticsearch 或 ClickHouse 等专门的搜索/分析系统,在这些系统上完成复杂的 JOIN 和聚合。
4.2 分布式事务
跨分片的写操作(如一个事务同时修改 user_db 和 order_db 的数据)无法使用 InnoDB 的本地事务,需要分布式事务方案:
强一致性(2PC/XA):
- ShardingSphere 支持基于 XA 协议的分布式事务
- 性能较差(需要协调器参与,延迟高),适合金融级强一致场景
最终一致性(Saga / TCC / 消息事务):
- 将分布式事务拆分为多个本地事务,通过补偿机制保证最终一致
- 性能好,但业务代码复杂(需要实现补偿逻辑)
- 适合大多数互联网业务(允许短暂不一致)
消息事务(推荐):
1. 执行本地事务 + 写入本地消息表(原子性由本地 InnoDB 事务保证)
2. 后台线程读取消息表,向消息队列发送消息
3. 消费者消费消息,执行另一个分片的操作
4. 如果消费失败,重试(幂等保证最终一致)
4.3 分页查询的处理
-- 在分库分表后,这个查询怎么实现?
SELECT * FROM orders WHERE user_id BETWEEN 100 AND 200 ORDER BY created_at DESC LIMIT 100000, 20;挑战:数据分散在多个分片,需要从每个分片各取数据,然后在应用层合并排序,再取第 100001-100020 条。这意味着需要从每个分片取 100020 条数据,总数据量是 分片数 × 100020——代价随偏移量线性增长,随分片数倍增。
解决思路:
- 禁止大偏移量分页:业务上限制最多翻 N 页(如最多 100 页),超过后只能用搜索系统
- 游标分页:使用上一页最后一条记录的时间戳/ID 作为下一页的起始条件,绕过 OFFSET
- 冗余存储到 ES:将需要复杂分页和排序的数据同步到 Elasticsearch,由 ES 负责全局排序和分页
4.4 全局唯一 ID
分库分表后,多个分片各自的自增 ID 会重复,需要全局唯一 ID 方案:
| 方案 | 实现 | 优点 | 缺点 |
|---|---|---|---|
| UUID v4 | 随机 128 bit | 简单,无依赖 | 随机,不趋势递增,影响 B+Tree 性能 |
| 雪花算法 | 时间戳+机器ID+序号 | 趋势递增,高性能 | 时钟回拨问题,需要机器 ID 分配 |
| 数据库号段模式 | 专用 ID 生成服务 | 稳定可靠 | 额外服务依赖 |
| Redis INCR | Redis 原子自增 | 简单,性能高 | Redis 持久化要求高 |
实践中雪花算法(Snowflake) 或其改良版(如百度 UidGenerator、美团 Leaf)是最常用的方案。
第 5 章 中间件的选择
主流的分库分表中间件:
| 中间件 | 定位 | 特点 |
|---|---|---|
| ShardingSphere-JDBC | 客户端分片 | 嵌入应用,无额外服务,性能最好,支持多种语言(主要 Java) |
| ShardingSphere-Proxy | 代理分片 | 独立服务,语言无关,但多一层网络 |
| MyCat | 代理分片 | 老牌中间件,社区活跃度下降 |
| Vitess | 大规模分片 | YouTube 开源,Kubernetes 原生,适合超大规模场景 |
对于大多数 Java 技术栈,ShardingSphere-JDBC 是当前最成熟、功能最全的选择。它支持分片、读写分离、分布式事务、数据加密等一系列功能,配置式开发,对业务代码侵入较小。
第 6 章 小结
本文的核心决策框架:
- 先问够不够用:单表亿级以下 + 读写分离 + 冷热分离,通常不需要分库分表
- 垂直先于水平:垂直分库(按业务域解耦)和垂直分表(热冷列拆分)成本低,先做垂直拆分
- 分片键是最关键决策:高基数、与核心查询对齐、稳定不变。选错了代价极高
- 三种分片算法各有适用场景:哈希取模适合均匀分布,一致性哈希适合弹性扩容,范围分片适合时序数据
- 分了之后的四大难题:跨库 JOIN(应用层聚合/数据冗余/搜索系统)、分布式事务(XA 强一致/Saga 最终一致)、分页(游标分页/ES)、全局 ID(雪花算法)
- 中间件选型:Java 技术栈首选 ShardingSphere-JDBC
思考题
mysqldump是逻辑备份——导出 SQL 语句。优点是跨版本兼容、灵活选择表/库;缺点是速度慢(需要逐行读取和生成 SQL)。在 1TB 数据库上mysqldump需要多长时间?--single-transaction选项使用 MVCC 保证一致性——但长时间运行的 mysqldump 会阻止 Undo Log 回收吗?- Percona XtraBackup 是物理备份——直接复制 InnoDB 数据文件,在备份结束后通过重放 Redo Log 保证一致性。XtraBackup 的增量备份只复制自上次备份后变更的数据页。在每天产生 100GB 变更的数据库中,增量备份比全量备份快多少?恢复增量备份时需要从全量备份开始逐步应用——恢复时间如何评估?
- Point-in-Time Recovery(PITR)通过 Binlog 将数据库恢复到任意时间点。流程:恢复最近的全量备份 → 应用 Binlog 到目标时间点。如果误操作发生在 14:30,最近的备份在 02:00——需要应用 12.5 小时的 Binlog。在 Binlog 量很大(每小时 10GB)的场景中,PITR 的恢复时间如何优化?