摘要:从一次"单表8000万数据分表后发现更慢了"的架构灾难出发,深度剖析分库分表的5大难题。通过分片策略对比、雪花算法手写实现、跨库JOIN的4种解决方案、以及数据迁移的双写方案,揭秘什么时候该分库分表、如何选择分片策略、以及分页查询如何归并排序。配合时序图展示数据路由流程,给出ShardingSphere的完整配置和最佳实践。
💥 翻车现场
2025年6月,哈吉米接到了一个"艰巨"的任务。
技术总监:"用户表已经8000万数据了,查询越来越慢,你去做分库分表优化。"
哈吉米:"好的!"(内心:终于可以展示技术了)
一个月后,分库分表上线。
三天后,技术总监在群里@哈吉米。
技术总监:@哈吉米 为什么分表后查询更慢了?
哈吉米:???
紧急查看监控:
告警:
- 订单列表接口:从500ms增加到3秒
- 用户详情接口:从50ms增加到800ms
- 统计报表:直接超时
哈吉米:"卧槽,怎么反而更慢了?"
查看代码,发现问题:
// 查询某用户的所有订单
SELECT o.*, u.username, u.phone
FROM order_info o
LEFT JOIN user u ON o.user_id = u.id
WHERE o.user_id = 10086;
// 原来单表:500ms
// 分表后:超时(跨库JOIN失效了!)
哈吉米:"完了,分表后跨库JOIN不能用了!"
晚上,南北绿豆和阿西噶阿西来了。
南北绿豆:"分库分表不是银弹,有5大难题,你一个都没考虑!"
阿西噶阿西:"而且你分表的时机可能都不对!"
哈吉米:"……"(崩溃)
🤔 什么时候该分库分表?
南北绿豆:"先搞清楚,什么时候才需要分库分表。"
分表的4个信号
| 信号 | 阈值 | 影响 |
|---|---|---|
| 单表行数 | > 2000万 | 查询变慢 |
| 单表容量 | > 10GB | 磁盘IO高 |
| 并发查询 | > 5000 QPS | 数据库连接数不够 |
| 单库容量 | > 500GB | 备份恢复慢 |
不该分库分表的场景:
❌ 单表才500万数据
❌ 查询慢是因为没加索引
❌ 慢查询导致的性能问题
❌ 硬件配置太低(加内存、换SSD能解决)
结论:能不分就不分!
分表前的优化尝试
哈吉米:"所以要先尝试其他优化?"
阿西噶阿西:"对!分库分表是最后的手段。"
优化顺序:
1. SQL优化(加索引、改写SQL)
2. 读写分离(主从复制)
3. 缓存(Redis)
4. 升级硬件(加内存、SSD)
5. 垂直拆分(大字段拆表)
6. 分库分表 ← 最后才考虑
南北绿豆:"分库分表后,你要面对5大难题!"
🚨 难题1:跨库JOIN怎么办?
问题场景
-- 分表前(单库)
SELECT o.*, u.username
FROM order_info o
LEFT JOIN user u ON o.user_id = u.id
WHERE o.order_id = 100001;
-- 分表后(可能在不同库)
-- order_info在db_0库
-- user在db_1库
-- 无法跨库JOIN ❌
解决方案1:字段冗余(推荐⭐⭐⭐⭐⭐)
-- order_info表增加冗余字段
CREATE TABLE order_info (
order_id BIGINT PRIMARY KEY,
user_id BIGINT,
username VARCHAR(50), ← 冗余字段
phone VARCHAR(20), ← 冗余字段
...
);
-- 查询时不需要JOIN
SELECT * FROM order_info WHERE order_id = 100001;
优点:
- ✅ 查询快(不需要JOIN)
- ✅ 简单
缺点:
- ❌ 数据冗余
- ❌ 需要同步更新(用户改名时,订单表也要更新)
解决方案2:应用层JOIN
// 1. 先查订单
Order order = orderMapper.selectById(100001);
// 2. 再查用户
User user = userMapper.selectById(order.getUserId());
// 3. 应用层组装
OrderVO vo = new OrderVO();
vo.setOrder(order);
vo.setUsername(user.getUsername());
vo.setPhone(user.getPhone());
优点:
- ✅ 灵活
缺点:
- ❌ 多次查询(N+1问题)
- ❌ 代码复杂
解决方案3:全局表(小表复制)
如果user表很小(如几千条),可以在每个库都复制一份
db_0: order_info + user(全量复制)
db_1: order_info + user(全量复制)
db_2: order_info + user(全量复制)
这样每个库都能独立JOIN
适用场景:字典表、配置表等小表
解决方案4:ER分片(同一用户的数据在同一库)
分片策略:按user_id分片
user_id=10086的数据:
- user表 → db_0
- order表(user_id=10086的订单)→ db_0
这样同一用户的user和order在同一库,可以JOIN
优点:
- ✅ 同库可以JOIN
缺点:
- ❌ 跨用户查询仍然不能JOIN
- ❌ 数据倾斜(某些用户订单特别多)
🚨 难题2:分页查询怎么办?
问题场景
-- 分表前
SELECT * FROM order_info ORDER BY create_time DESC LIMIT 20;
-- 分表后(10个库)
-- 每个库查20条,汇总后再排序?
解决方案:归并排序
@Service
public class OrderShardingService {
public List<Order> pageOrders(Integer pageSize) {
// 1. 从每个分片查询pageSize条数据
List<List<Order>> shardResults = new ArrayList<>();
for (int i = 0; i < 10; i++) {
String dataSource = "ds_" + i;
List<Order> orders = orderMapper.selectByPage(dataSource, pageSize);
shardResults.add(orders);
}
// 2. 归并排序(所有分片的数据合并排序)
PriorityQueue<OrderWithIndex> heap = new PriorityQueue<>(
Comparator.comparing(o -> o.order.getCreateTime(), Comparator.reverseOrder())
);
// 初始化堆(每个分片的第1条数据)
for (int i = 0; i < shardResults.size(); i++) {
if (!shardResults.get(i).isEmpty()) {
heap.offer(new OrderWithIndex(shardResults.get(i).get(0), i, 0));
}
}
// 3. 取出前pageSize条
List<Order> result = new ArrayList<>();
while (!heap.isEmpty() && result.size() < pageSize) {
OrderWithIndex current = heap.poll();
result.add(current.order);
// 从同一分片取下一条
int shardIndex = current.shardIndex;
int nextIndex = current.indexInShard + 1;
if (nextIndex < shardResults.get(shardIndex).size()) {
heap.offer(new OrderWithIndex(
shardResults.get(shardIndex).get(nextIndex),
shardIndex,
nextIndex
));
}
}
return result;
}
@Data
@AllArgsConstructor
static class OrderWithIndex {
Order order;
int shardIndex; // 来自哪个分片
int indexInShard; // 在分片中的下标
}
}
归并排序流程图
graph TD
A[查询请求 LIMIT 20] --> B[路由到10个分片]
B --> C1[db_0: 查20条]
B --> C2[db_1: 查20条]
B --> C3[db_2: 查20条]
B --> C4[...]
B --> C5[db_9: 查20条]
C1 --> D[归并排序]
C2 --> D
C3 --> D
C4 --> D
C5 --> D
D --> E[取前20条]
E --> F[返回结果]
Note[每个分片查20条<br/>总共200条<br/>归并后取前20条]
style Note fill:#FFE4B5
问题:
- ❌ 需要查询10 × 20 = 200条,浪费了180条
- ❌ 深分页仍然很慢(每个分片都要扫描offset+size行)
🚨 难题3:分布式ID怎么生成?
为什么需要分布式ID?
单库:
使用AUTO_INCREMENT,自增ID:1, 2, 3, 4, ...
分库后:
db_0: id=1, 2, 3, ...
db_1: id=1, 2, 3, ... ← 重复了!
db_2: id=1, 2, 3, ...
问题:多个库的ID会冲突
解决方案:雪花算法(Snowflake)
结构(详见第8篇文章):
64位BIGINT:
0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000
1位 41位时间戳 5位 5位 12位序列号
符号 (支持69年) 数据中心 机器ID (支持4096/ms)
代码实现(简化版):
@Component
public class SnowflakeIdWorker {
private final long workerId;
private final long datacenterId;
private long sequence = 0L;
private long lastTimestamp = -1L;
private final long epoch = 1704038400000L; // 2024-01-01
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
if (timestamp < lastTimestamp) {
throw new RuntimeException("时钟回拨");
}
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & 4095;
if (sequence == 0) {
timestamp = waitNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - epoch) << 22)
| (datacenterId << 17)
| (workerId << 12)
| sequence;
}
private long waitNextMillis(long lastTimestamp) {
long timestamp = System.currentTimeMillis();
while (timestamp <= lastTimestamp) {
timestamp = System.currentTimeMillis();
}
return timestamp;
}
}
特点:
- ✅ 全局唯一
- ✅ 趋势递增(时间戳递增)
- ✅ 本地生成(不依赖数据库)
- ✅ 性能好(百万级QPS)
🚨 难题4:跨库事务怎么办?
问题场景
// 创建订单 + 扣库存
@Transactional
public void createOrder(Order order) {
// 订单表在db_0
orderMapper.insert(order);
// 库存表在db_1
stockMapper.decrease(order.getProductId(), order.getNum());
}
// 问题:@Transactional只能保证单库事务,跨库事务无效 ❌
解决方案1:本地消息表(推荐⭐⭐⭐⭐⭐)
@Transactional
public void createOrder(Order order) {
// 1. 插入订单(db_0)
orderMapper.insert(order);
// 2. 插入本地消息表(同一个库,同一个事务)
LocalMessage msg = new LocalMessage();
msg.setOrderId(order.getOrderId());
msg.setType("DECREASE_STOCK");
msg.setContent(JSON.toJSONString(order));
msg.setStatus(0); // 待发送
localMessageMapper.insert(msg);
}
// 定时任务:扫描本地消息表
@Scheduled(fixedDelay = 1000)
public void sendMessages() {
List<LocalMessage> messages = localMessageMapper.selectByStatus(0);
for (LocalMessage msg : messages) {
try {
// 调用库存服务(可能在其他库)
stockService.decrease(msg.getProductId(), msg.getNum());
// 更新消息状态
msg.setStatus(1); // 已发送
localMessageMapper.updateById(msg);
} catch (Exception e) {
// 重试或告警
log.error("消息发送失败", e);
}
}
}
时序图:
sequenceDiagram
participant App as 应用
participant DB0 as db_0(订单库)
participant LocalMsg as 本地消息表
participant Task as 定时任务
participant DB1 as db_1(库存库)
App->>DB0: 1. INSERT订单
App->>LocalMsg: 2. INSERT本地消息(同一事务)
App->>App: 3. COMMIT
Note over Task: 每1秒执行
Task->>LocalMsg: 4. 查询待发送消息
LocalMsg->>Task: 返回消息
Task->>DB1: 5. 扣减库存
DB1->>Task: 成功
Task->>LocalMsg: 6. 更新消息状态=已发送
Note over App,DB1: 最终一致性(可能延迟1-2秒)
优点:
- ✅ 实现简单
- ✅ 可靠(消息不会丢)
缺点:
- ⚠️ 最终一致性(不是强一致)
- ⚠️ 有延迟(1-2秒)
解决方案2:Seata分布式事务
// 使用Seata的@GlobalTransactional
@GlobalTransactional
public void createOrder(Order order) {
// 订单服务(db_0)
orderService.createOrder(order);
// 库存服务(db_1)
stockService.decrease(order.getProductId(), order.getNum());
// 两个操作要么都成功,要么都失败
}
优点:
- ✅ 强一致性
缺点:
- ❌ 性能差(2PC协议)
- ❌ 复杂度高
- ❌ 可能死锁
阿西噶阿西:"生产环境不推荐Seata,性能损耗太大,用本地消息表更实用。"
🎯 分片策略选择
南北绿豆:"分库分表的核心是分片策略。"
策略1:哈希分片(最常用⭐⭐⭐⭐⭐)
// 按user_id哈希
int shardIndex = user_id % 10; // 10个库
// 示例
user_id=10086 → 10086 % 10 = 6 → db_6
user_id=10087 → 10087 % 10 = 7 → db_7
优点:
- ✅ 数据分布均匀
- ✅ 简单
缺点:
- ❌ 扩容困难(10个库变20个库,所有数据要重新分片)
策略2:范围分片
// 按user_id范围
if (user_id < 1000000) → db_0
else if (user_id < 2000000) → db_1
else if (user_id < 3000000) → db_2
...
优点:
- ✅ 扩容简单(新增库,分配新范围)
缺点:
- ❌ 数据分布不均(新用户都在最新的库)
- ❌ 热点问题(最新的库压力大)
策略3:一致性哈希(扩容友好⭐⭐⭐⭐)
// 一致性哈希环
// 扩容时只需要迁移部分数据
优点:
- ✅ 扩容时只需迁移1/N的数据
- ✅ 数据分布均匀
缺点:
- ⚠️ 实现复杂
策略对比
| 策略 | 数据均匀度 | 扩容难度 | 推荐指数 |
|---|---|---|---|
| 哈希分片 | ⭐⭐⭐⭐⭐ | ⭐ | ⭐⭐⭐⭐⭐ |
| 范围分片 | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| 一致性哈希 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
推荐:
- 用户表:哈希分片(按user_id)
- 订单表:哈希分片(按user_id,和用户在同一库)
- 日志表:范围分片(按时间)
🛠️ ShardingSphere实战配置
# application.yml
spring:
shardingsphere:
datasource:
names: ds0,ds1,ds2,ds3,ds4,ds5,ds6,ds7,ds8,ds9
ds0:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://192.168.1.10:3306/shop_0
username: root
password: 123456
ds1:
jdbc-url: jdbc:mysql://192.168.1.11:3306/shop_1
# ... ds2-ds9
rules:
sharding:
tables:
# 用户表分片
user:
actual-data-nodes: ds$->{0..9}.user
database-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: user-mod
# 订单表分片(和user在同一库)
order_info:
actual-data-nodes: ds$->{0..9}.order_info
database-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: user-mod
sharding-algorithms:
user-mod:
type: MOD
props:
sharding-count: 10 # 10个库
代码使用:
// 不需要改代码,ShardingSphere自动路由
@Mapper
public interface OrderMapper {
@Insert("INSERT INTO order_info (...) VALUES (...)")
int insert(Order order);
// ShardingSphere会根据user_id自动路由到对应的库
@Select("SELECT * FROM order_info WHERE user_id = #{userId}")
List<Order> selectByUserId(Long userId);
}
🎓 面试标准答案
题目:什么时候需要分库分表?如何选择分片策略?
答案:
分库分表的时机:
- 单表行数 > 2000万
- 单表容量 > 10GB
- 并发查询 > 5000 QPS
- 优化索引、读写分离、缓存都无效
分片策略:
- 哈希分片:数据均匀,扩容难(推荐)
- 范围分片:扩容简单,数据不均
- 一致性哈希:平衡两者
5大难题:
- 跨库JOIN → 字段冗余/应用层JOIN
- 分页查询 → 归并排序
- 分布式ID → 雪花算法
- 跨库事务 → 本地消息表/Seata
- 数据迁移 → 双写/停机迁移
最佳实践:
- 能不分就不分
- 按业务关联分片(同一用户数据在同一库)
- 用ShardingSphere等中间件
- 提前规划扩容方案
🎉 结束语
三个月后,哈吉米终于把分库分表的坑都填完了。
哈吉米:"原来分库分表这么多坑!跨库JOIN、分页、事务、分布式ID……"
南北绿豆:"对,分库分表不是银弹,要慎重决定。"
阿西噶阿西:"记住:能不分就不分,要分就提前规划好所有问题。"
哈吉米:"还有字段冗余是解决跨库JOIN的最实用方案。"
南北绿豆:"对,用空间换时间,这是分库分表的核心思想。"
记忆口诀:
单表两千万,十G容量需分表
哈希分片最常用,数据均匀难扩容
跨库JOIN字段冗,应用层拼也能行
雪花算法生成ID,本地消息保事务
分页归并要排序,能不分表就不分
希望这篇文章能帮你理解分库分表的坑和解决方案!记住:分库分表是最后的手段,要充分评估成本和收益!💪