分库分表方案
单表超过 2000 万行就该分表——这个说法流传很广,但真正的问题不是行数,而是"查询能否稳定在索引可控的范围内"。分库分表是一次架构级手术,代价是跨库 JOIN、分布式事务、扩容、运维复杂度全面上升。这篇文章讲清楚什么时候该拆、怎么选分片键、ShardingSphere 如何落地,以及扩容时的数据迁移。面向已有一定数据量压力的后端开发与架构设计者。
一、什么时候该拆
先排除"不该拆"的情况。下面这些手段优先级都高于分库分表:
- 优化索引与 SQL:多数慢查询是索引问题,不是数据量问题。
- 归档历史数据:订单、日志类表按时间归档到冷库,主表只留热数据。
- 读写分离:读多写少的场景,加从库往往能撑很久。
- 缓存:热点数据前置到 Redis。
- 升级硬件:SSD、更大内存,单位成本常常低于改造。
判断是否真的要拆,看两个信号:
- 单表数据量超过 5000 万行,且索引优化后仍有大量慢查询。
- 单库的 QPS/TPS 或磁盘 IO 达到实例上限,垂直扩容不划算。
行数本身不是标准。一张只有主键查询的表,1 亿行也能保持毫秒级;一张有 10 个二级索引、频繁范围查询的表,500 万行就可能撑不住。
二、垂直拆分与水平拆分
垂直分库:按业务拆成独立库,如用户库、订单库、商品库。本质是微服务的数据库隔离,风险低、收益明确,应优先做。
垂直分表:把大字段(TEXT、BLOB)或低频列拆到扩展表。减少主表行宽,提高页内行数,间接提升 Buffer Pool 效率。
水平分表:同一张表按分片键拆成多张结构相同的表。这是通常说的"分表"。
水平分库:在水平分表基础上,把表分散到多个实例,突破单机 IO 与连接数上限。
实践中常见组合是"先垂直分库,再对订单类大表做水平分库分表"。
三、分片键的选择
分片键决定了路由,也决定了哪些查询会变成"广播查询"。选错分片键,后续几乎无法补救。
选择原则:
- 高基数:值要足够分散,避免数据倾斜。
status、type这类只有几个值的列不能用。 - 查询必带:绝大多数查询的
WHERE条件里都有它。否则每次查询都要扫所有分片。 - 分布均匀:避免热点。用
user_id取模通常均匀;用created_at按月分片,当前月一定是热点。 - 不可变:分片键一旦确定,修改意味着数据迁移。不要用可能变化的字段。
常见选择:
| 业务 | 分片键 | 说明 |
| --- | --- | --- |
| 订单 | user_id | 用户维度查询为主;商家维度需异构索引表 |
| 用户 | user_id | 天然均匀 |
| 日志 | created_at + 哈希 | 按时间范围查询友好 |
| 消息 | conversation_id | 会话维度聚合 |
多维度查询的解法:订单既要按用户查、又要按商家查。方案是主表按 user_id 分片,另建一张按 merchant_id 分片的索引表(只存 order_id 与定位信息),写入时双写。
四、路由算法
取模:shard = hash(key) % N。分布均匀,但扩容时 N 变化会导致几乎所有数据重新分布。
范围:按 ID 区间或时间区间。扩容方便(加新分片即可),但容易热点。
一致性哈希:解决扩容时的数据迁移量,但实现复杂,且在节点少时分布不均。
双倍扩容 + 基因法:先按 2 的幂扩容(2 → 4 → 8),每次只迁移一半数据。基因法把分片基因编码进 ID 低位,使 order_id 自带分片信息,避免二次路由。
// 基因法示意:user_id 决定分片,order_id 的低位携带同一基因
// 取 user_id 的低 4 位作为基因
long gene = userId & 0b1111;
long orderId = (snowflakeId << 4) | gene;
// 之后用 order_id 也能直接算出分片
int shard = (int) (orderId & 0b1111);
代价是 ID 变长、雪花算法需要改造,但换来"用订单号直接定位分片"的能力。
五、ShardingSphere 落地
以 ShardingSphere-JDBC 5.4 为例,Spring Boot 3.x + MySQL 8.0。
依赖:
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>sharding-jdbc-spring-boot-starter</artifactId>
<version>5.4.1</version>
</dependency>
配置(按 user_id 分 2 库 × 2 表):
spring:
shardingsphere:
datasource:
names: ds0,ds1
ds0:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://127.0.0.1:3306/order_db0?useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: root
ds1:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://127.0.0.1:3306/order_db1?useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: root
rules:
sharding:
tables:
t_order:
actual-data-nodes: ds$->{0..1}.t_order_$->{0..1}
database-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: db-inline
table-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: table-inline
sharding-algorithms:
db-inline:
type: INLINE
props:
algorithm-expression: ds$->{user_id % 2}
table-inline:
type: INLINE
props:
algorithm-expression: t_order_$->{user_id % 2}
props:
sql-show: true
初始化分片表(两个库各建两张):
CREATE DATABASE IF NOT EXISTS order_db0 DEFAULT CHARACTER SET utf8mb4;
CREATE DATABASE IF NOT EXISTS order_db1 DEFAULT CHARACTER SET utf8mb4;
CREATE TABLE IF NOT EXISTS order_db0.t_order_0 (
order_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
amount DECIMAL(12,2) NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (order_id),
KEY idx_user (user_id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
CREATE TABLE IF NOT EXISTS order_db0.t_order_1 LIKE order_db0.t_order_0;
CREATE TABLE IF NOT EXISTS order_db1.t_order_0 LIKE order_db0.t_order_0;
CREATE TABLE IF NOT EXISTS order_db1.t_order_1 LIKE order_db0.t_order_0;
注意:分片表的主键不能用
AUTO_INCREMENT,否则每个分片各自自增会产生重复 ID。必须由应用生成分布式 ID(雪花算法、号段模式),或使用 ShardingSphere 内置的SNOWFLAKE主键生成器。
六、跨分片查询
分库分表后,以下几类操作会退化:
| 操作 | 后果 | 应对 |
| --- | --- | --- |
| 不带分片键的查询 | 广播到所有分片 | 建异构索引表、ES |
| 跨分片 JOIN | 内存归并,性能差 | 冗余字段、宽表、应用层拼装 |
| 跨分片 ORDER BY + LIMIT | 各分片取 N 条再归并 | 增大 N 或改用搜索索引 |
| 跨分片 COUNT/SUM | 各分片聚合再合并 | 计数表、定时任务 |
| 跨分片事务 | 需要分布式事务 | 本地消息表、Seata、尽量避免 |
绑定表(Binding Table):如果 t_order 和 t_order_item 都按 user_id 分片,且路由算法一致,ShardingSphere 可以把它们当作绑定表,JOIN 时只在同一分片内进行,避免跨库。
binding-tables:
- t_order,t_order_item
广播表:字典表、配置表数据量小、变更少,可在每个库都存一份,JOIN 时无需跨库。
broadcast-tables:
- t_dict
七、分布式事务
跨分片写操作没有单机事务保证。选型顺序:
- 避免跨分片事务:把同一业务实体的数据放在同一分片,是最彻底的解法。
- 本地消息表 + 最终一致:业务表与消息表同库,靠定时任务投递,实现简单、可靠。
- Seata AT 模式:对业务侵入小,但依赖全局锁,高并发下有争用。
- TCC:性能好,但要为每个操作写 try/confirm/cancel,开发成本高。
对订单这类核心链路,推荐"本地消息表 + 幂等消费",而不是强一致的分布式事务。
八、扩容与数据迁移
从 2 库扩到 4 库,取模算法从 % 2 变成 % 4,需要迁移一半数据。双写方案步骤:
- 新老库同时写入(双写),读仍走老库。
- 用
pt-archiver或自研工具做存量数据迁移,校验行数与校验和。 - 开启新旧数据比对,确认一致。
- 灰度切读,按
user_id白名单逐步放大。 - 全量切读,停止双写,下线老库。
# pt-archiver 迁移示例:按主键分批搬运
pt-archiver \
--source h=127.0.0.1,D=order_db0,t=t_order_0 \
--dest h=127.0.0.1,D=order_db2,t=t_order_0 \
--where "1=1" \
--limit 1000 \
--commit-each \
--purge
--purge 会删除源数据,首次迁移务必先去掉该参数做演练。
九、常见坑 / 最佳实践
- 不要在数据量还没到瓶颈时拆。分库分表带来的复杂度是永久的,收益是临时的。先用归档、读写分离、缓存把问题压到极限,再考虑拆。
- 分片键必须覆盖 90% 以上的查询。如果大量查询不带分片键,等于每次全分片扫描,比单表还慢。选型前先统计线上 SQL 的
WHERE分布。 - 分片主键不能用自增。用雪花算法或号段模式,且 ID 生成器要保证时钟回拨安全。
- 扩容方案在第一天就要设计好。取模扩容需要全量迁移,双倍扩容只需迁一半;如果预计会持续增长,直接用 2 的幂分片数并预留扩容路径。
- 跨分片分页要限制深度。
LIMIT 10000, 20会在每个分片都取 10020 条再归并,深度分页在分片场景下代价是分片数的倍数,必须限制或改用游标。
十、小结
- 分库分表是最后手段,优先用索引优化、归档、读写分离、缓存解决。
- 分片键要满足高基数、查询必带、分布均匀、不可变四个条件。
- 取模扩容代价高,双倍扩容 + 基因法能显著降低迁移量。
- 跨分片 JOIN、排序、聚合、事务都会退化,用绑定表、广播表、冗余字段、本地消息表应对。
- 扩容必须走"双写 → 迁移 → 校验 → 灰度切读 → 下线"的完整流程,不能一步切换。
- 点赞
- 收藏
- 关注作者
评论(0)