当你的 MySQL 单表数据量突破 2000 万行,查询延迟从毫秒级飙升到秒级,分库分表就不再是"可选项"而是"必选项"。据统计,国内 Top 100 互联网公司中超过 85% 在核心业务上采用了分库分表方案,而其中近 60% 使用 Apache ShardingSphere 作为中间件。但分库分表绝非简单的"一刀切"——错误的分片策略可能让系统从一个坑跳进另一个更大的坑。本文将从原理到实战,带你掌握分库分表的完整技术栈。
🔧 一、分库分表核心概念与策略选择
1.1 垂直拆分 vs 水平拆分
分库分表的本质是将数据按照某种规则分散到多个数据库或多张表中,以突破单机存储和性能瓶颈。拆分方式分为两大类:
垂直拆分:按业务模块或字段拆分。垂直分库是将不同业务模块的表拆到不同数据库(如用户库、订单库、商品库);垂直分表是将一张宽表的冷热字段拆成多张表。
水平拆分:按数据行拆分。同一张表的数据按照某个分片键(Sharding Key)分散到多个库或表中。例如按用户 ID 取模,将订单表拆成 order_0 到 order_7 共 8 张表。
💡 **提示:**大多数场景下,先做垂直拆分(业务解耦),再做水平拆分(性能优化),这是最稳妥的渐进式方案。
1.2 常见分片策略对比
| 策略 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| Hash 取模 | shard_id = hash(key) % N |
数据分布均匀 | 扩容需要数据迁移 | 数据量大、无明显范围查询 |
| 范围分片 | 按 ID 或时间范围划分 | 扩容简单,天然支持范围查询 | 可能出现热点(新数据集中在一个分片) | 按时间归档的日志、流水表 |
| 一致性 Hash | 哈希环映射 | 扩容仅迁移部分数据 | 实现复杂,节点增减时数据不均 | 缓存分片、动态扩缩容 |
| 基因法 | 将分片信息嵌入 ID 低位 | 关联查询可直接定位分片 | ID 生成规则复杂 | 订单与子订单关联查询 |
1.3 分片键选择的原则
分片键(Sharding Key)的选择直接决定了分库分表的成败。选错分片键,后续的跨库查询和数据迁移将成为噩梦。
选择分片键的三个黄金法则:
- ✅ 高频查询条件:90% 以上的查询都应包含分片键,避免跨库查询
- ✅ 数据分布均匀:避免某些分片数据量远超其他分片(数据倾斜)
- ✅ 业务关联性:相关联的数据尽量落在同一个分片(如用户和用户的订单)
⚠️ **警告:**千万不要选择更新频繁的字段作为分片键!一旦分片键变更,数据需要跨库迁移,代价极高。
🚀 二、ShardingSphere 实战配置
Apache ShardingSphere 是目前最流行的分库分表中间件,支持 ShardingSphere-JDBC(嵌入式)和 ShardingSphere-Proxy(代理模式)两种部署方式。下面以 Spring Boot + ShardingSphere-JDBC 为例,演示完整的分库分表配置。
2.1 环境搭建与依赖配置
<!-- pom.xml 核心依赖 -->
<dependencies>
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-jdbc-core</artifactId>
<version>5.5.1</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.3.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
</dependencies>
2.2 按用户 ID 分库、按订单 ID 分表
假设我们有 2 个数据库 order_db_0 和 order_db_1,每个库中订单表拆为 4 张表 t_order_0 到 t_order_3。分库键为 user_id(取模 2),分表键为 order_id(取模 4)。
# application-sharding.yml
spring:
shardingsphere:
datasource:
names: ds0,ds1
ds0:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://localhost:3306/order_db_0?useSSL=false
username: root
password: root
ds1:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://localhost:3306/order_db_1?useSSL=false
username: root
password: root
rules:
sharding:
tables:
t_order:
actual-data-nodes: ds$->{0..1}.t_order_$->{0..3}
database-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: db-mod
table-strategy:
standard:
sharding-column: order_id
sharding-algorithm-name: table-mod
key-generate-strategy:
column: order_id
key-generator-name: snowflake
sharding-algorithms:
db-mod:
type: MOD
props:
sharding-count: 2
table-mod:
type: MOD
props:
sharding-count: 4
key-generators:
snowflake:
type: SNOWFLAKE
props:
sql-show: true
📌 记住:
sql-show: true在开发环境开启,可以看到 ShardingSphere 改写后的真实 SQL,是排查分片问题的利器。生产环境务必关闭,否则日志量爆炸。
2.3 代码中的透明使用
配置完成后,业务代码无需任何修改。ShardingSphere 会在 JDBC 层拦截 SQL,自动完成路由、改写、执行和结果归并。
// OrderRepository.java — 业务代码完全透明,无需感知分库分表
@Repository
public class OrderRepository {
@Autowired
private JdbcTemplate jdbcTemplate;
// 插入订单 — ShardingSphere 自动路由到正确的库和表
public void createOrder(Long userId, Long orderId, BigDecimal amount) {
String sql = "INSERT INTO t_order (order_id, user_id, amount, status, created_at) VALUES (?, ?, ?, ?, ?)";
jdbcTemplate.update(sql, orderId, userId, amount, "CREATED", new Date());
}
// 按 user_id 查询 — 精确路由到单个库,全表扫描(该库的4张表)
public List<Map<String, Object>> findByUserId(Long userId) {
String sql = "SELECT * FROM t_order WHERE user_id = ?";
return jdbcTemplate.queryForList(sql, userId);
}
// 跨库全表扫描 — 所有库所有表都会执行,结果归并排序(性能差,应避免)
public List<Map<String, Object>> findAllOrders(int page, int size) {
String sql = "SELECT * FROM t_order ORDER BY created_at DESC LIMIT ?, ?";
return jdbcTemplate.queryForList(sql, page * size, size);
}
}
⚠️ 警告:跨库分页查询是分库分表最大的性能陷阱。
LIMIT 100000, 20在每个分片上都会执行,然后在内存中归并排序取 Top 20。数据量大时内存溢出风险极高。解决方案是使用游标分页(记住上一页最后一条的 ID)或禁止深度翻页。
💡 三、分库分表后的核心难题与解决方案
3.1 跨库 JOIN 的替代方案
分库分表后,传统的 JOIN 查询无法直接使用(数据在不同库中)。常见解决方案:
// ❌ 错误写法:跨库 JOIN 直接报错或性能极差
// SELECT o.*, u.username FROM t_order o JOIN t_user u ON o.user_id = u.user_id
// ✅ 正确方案1:应用层组装(推荐,简单场景)
public OrderDetailVO getOrderDetail(Long userId, Long orderId) {
// 第一次查询:路由到正确的分片
Order order = orderRepository.findById(orderId);
// 第二次查询:用户表按 user_id 路由
User user = userRepository.findById(userId);
// 应用层组装
return OrderDetailVO.builder()
.orderId(order.getOrderId())
.amount(order.getAmount())
.username(user.getUsername())
.build();
}
// ✅ 正确方案2:数据冗余(高频查询场景)
// 将常用的用户字段(username, avatar)冗余到订单表中
// 插入时一次性写入,避免查询时二次查询
public void createOrderWithUser(Long userId, Long orderId, BigDecimal amount) {
User user = userRepository.findById(userId);
String sql = "INSERT INTO t_order (order_id, user_id, amount, username, status) VALUES (?, ?, ?, ?, ?)";
jdbcTemplate.update(sql, orderId, userId, amount, user.getUsername(), "CREATED");
}
3.2 分布式事务处理
分库分表后,跨库操作涉及分布式事务。ShardingSphere 内置了对 XA 和 Seata 的支持:
// 使用 ShardingSphere + Seata 实现分布式事务
// 1. 引入 seata-spring-boot-starter
// 2. 配置 Seata Server 地址
// 3. 在方法上使用 @GlobalTransactional
@Service
public class TransferService {
@Autowired
private AccountRepository accountRepository;
// 跨库转账:从 ds0 的账户 A 转到 ds1 的账户 B
@GlobalTransactional(name = "transfer-transaction", timeoutMills = 30000)
public void transfer(Long fromUserId, Long toUserId, BigDecimal amount) {
// 扣减发起方余额(路由到 ds0)
accountRepository.decreaseBalance(fromUserId, amount);
// 增加接收方余额(路由到 ds1)
accountRepository.increaseBalance(toUserId, amount);
// 如果任一操作失败,Seata 自动回滚所有参与方
}
}
💡 提示:能不用分布式事务就不用。优先考虑最终一致性方案:先执行本地事务,再通过消息队列通知其他库。例如:订单创建成功后发 MQ 消息,库存服务消费消息扣减库存。失败则重试,最终保证一致性。
3.3 全局 ID 生成方案对比
分库分表后,自增 ID 不再可用(不同库的表会产生重复 ID)。需要全局唯一的分布式 ID:
| 方案 | 性能 | 有序性 | 依赖 | 推荐度 |
|---|---|---|---|---|
| Snowflake | ⚡ 极高(单机 400 万/s) | 趋势递增 | 时钟同步 | ⭐⭐⭐⭐⭐ |
| UUID | ⚡ 高 | 无序 | 无 | ⭐⭐⭐ |
| 数据库号段 | 中等(10 万/s) | 递增 | 数据库 | ⭐⭐⭐⭐ |
| Redis INCR | 高(10 万/s) | 递增 | Redis | ⭐⭐⭐⭐ |
| Leaf(美团) | 极高 | 递增 | ZooKeeper/DB | ⭐⭐⭐⭐⭐ |
// Snowflake ID 生成器实现(去掉机器ID依赖,适合容器环境)
public class SnowflakeIdGenerator {
private final long epoch = 1700000000000L; // 自定义起始时间戳
private final long workerIdBits = 5L;
private final long datacenterIdBits = 5L;
private final long sequenceBits = 12L;
private final long maxWorkerId = ~(-1L << workerIdBits); // 31
private final long maxDatacenterId = ~(-1L << datacenterIdBits); // 31
private final long sequenceMask = ~(-1L << sequenceBits); // 4095
private final long workerIdShift = sequenceBits; // 12
private final long datacenterIdShift = sequenceBits + workerIdBits; // 17
private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits; // 22
private long workerId;
private long datacenterId;
private long sequence = 0L;
private long lastTimestamp = -1L;
public SnowflakeIdGenerator(long workerId, long datacenterId) {
if (workerId > maxWorkerId || workerId < 0) {
throw new IllegalArgumentException("Worker ID out of range");
}
if (datacenterId > maxDatacenterId || datacenterId < 0) {
throw new IllegalArgumentException("Datacenter ID out of range");
}
this.workerId = workerId;
this.datacenterId = datacenterId;
}
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
if (timestamp < lastTimestamp) {
throw new RuntimeException("Clock moved backwards");
}
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & sequenceMask;
if (sequence == 0) {
timestamp = waitNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - epoch) << timestampLeftShift)
| (datacenterId << datacenterIdShift)
| (workerId << workerIdShift)
| sequence;
}
private long waitNextMillis(long lastTimestamp) {
long timestamp = System.currentTimeMillis();
while (timestamp <= lastTimestamp) {
timestamp = System.currentTimeMillis();
}
return timestamp;
}
}
3.4 数据迁移:不停机双写方案
分库分表上线最大的挑战是存量数据迁移。推荐使用"双写 + 灰度切流"方案:
迁移流程:
- ✅ 全量同步:将老库数据通过 ETL 工具(如 DataX、Canal)同步到新分片
- ✅ 增量追赶:通过 Binlog 订阅(Canal)实时同步迁移期间的增量数据
- ✅ 数据校验:对比新旧库数据一致性,修复差异
- ✅ 双写阶段:业务代码同时写新旧两套库,读仍走旧库
- ✅ 灰度切读:按比例将读流量切到新库(10% → 50% → 100%)
- ✅ 停写旧库:确认无误后,停止写旧库,迁移完成
// 双写阶段的 Repository 代理
@Repository
public class DualWriteOrderRepository {
@Autowired @Qualifier("oldDataSource")
private JdbcTemplate oldJdbc; // 旧库(单库单表)
@Autowired @Qualifier("shardingDataSource")
private JdbcTemplate newJdbc; // 新库(分库分表)
@Value("${migration.read-source:old}")
private String readSource; // 控制读流量:old / new / compare
public void createOrder(Long userId, Long orderId, BigDecimal amount) {
String sql = "INSERT INTO t_order (order_id, user_id, amount, status) VALUES (?, ?, ?, ?)";
// 双写:新旧库同时写入
oldJdbc.update(sql, orderId, userId, amount, "CREATED");
newJdbc.update(sql, orderId, userId, amount, "CREATED");
}
public Order findById(Long orderId) {
if ("new".equals(readSource)) {
return newJdbc.queryForObject("SELECT * FROM t_order WHERE order_id=?", Order.class, orderId);
}
return oldJdbc.queryForObject("SELECT * FROM t_order WHERE order_id=?", Order.class, orderId);
}
}
⚠️ **警告:**双写期间务必设置写入超时和降级策略。如果新库写入失败,不能阻塞主流程。建议新库写入失败时记录到补偿表,异步重试。
🔐 四、避坑指南与最佳实践
分库分表不是银弹,很多团队在实施后才发现问题比解决方案更多。以下是实战中总结的血泪教训:
❌ 常见错误
- ❌ 过早分表:单表 500 万以下不需要分表,先优化索引和 SQL
- ❌ 过度分片:一次拆 64 个库 256 张表,运维成本远超收益
- ❌ 忽略全局索引:分表后按非分片键查询需要广播所有分片
- ❌ 在线 DDL 直接改表:分表后 DDL 变更需要在所有分片上执行
✅ 最佳实践
- ✅ 渐进式拆分:先垂直拆分,再水平拆分;先分库,再分表
- ✅ 分片数取 2 的幂:方便后续翻倍扩容(2→4→8→16)
- ✅ 预留足够的分片数:预估未来 3-5 年数据量,一次规划到位
- ✅ 使用基因法解决关联查询:将分片信息嵌入关联表的 ID 中
- ✅ 监控分片数据均衡度:定期检查各分片的数据量差异
⚡ **关键结论:**分库分表的终极建议——如果业务还没到必须分的程度,就不要分。优先考虑读写分离、缓存、SQL 优化、归档冷数据等方案。分库分表是最后手段,不是第一选择。
📊 总结
分库分表是解决数据库性能瓶颈的终极方案,但也是引入复杂度最高的方案。核心决策路径如下:
- 单表 < 500 万行:优化索引和 SQL,不需要分表
- 单表 500 万 ~ 2000 万行:考虑归档冷数据 + 读写分离
- 单表 > 2000 万行:开始规划分库分表
- 单表 > 5000 万行:必须分库分表
技术选型上,Java 生态首选 ShardingSphere-JDBC(嵌入式,零运维成本),Go 生态推荐 Vitess(Kubernetes 原生),对于不想引入中间件的团队,可以考虑 TiDB 或 CockroachDB 等分布式数据库直接替代 MySQL。
🔧 相关工具推荐:
- Apache ShardingSphere — 最流行的分库分表中间件
- Canal — MySQL Binlog 订阅,用于增量数据同步
- DataX — 阿里开源的数据同步工具
- TiDB — 兼容 MySQL 协议的分布式数据库,无需分库分表