"数据库单表2000万了,老板说要分库分表,我慌了..." 😰
📖 为什么要分库分表?
想象一个图书馆:
- 单库单表:所有书都堆在一个房间(查找慢、拥挤)📚📚📚
- 分库分表:按类别分成多个房间(查找快、分散压力)📚|📚|📚
分库分表的目的:
- 突破单表容量限制(MySQL 单表建议 < 2000万)
- 提升查询性能(数据少了,查询快了)
- 分散数据库压力(多个库并行处理)
🎯 分库分表的挑战
分库分表后的问题:
1. 路由问题
→ 数据该写到哪个库哪个表?
2. 跨库JOIN
→ 数据分散在多个库,无法 JOIN
3. 跨库分页
→ 需要从多个库取数据再合并排序
4. 分布式事务
→ 多个库的事务一致性
5. 全局唯一ID
→ 自增ID不能用了
6. 数据迁移
→ 如何把旧数据迁移到新的分库分表
🔥 优化技巧一:路由优化
分片策略选择
常见分片策略:
1️⃣ Hash 分片(最常用)
userId % 4 → 库编号(0-3)
优点:数据均匀
缺点:扩容麻烦
2️⃣ Range 分片(按范围)
0-10000 → 库0
10001-20000 → 库1
优点:扩容简单
缺点:数据可能不均匀(热点问题)
3️⃣ 一致性Hash(推荐)
Hash(userId) → Hash环 → 最近的节点
优点:扩容时数据迁移少
缺点:实现复杂
实现:Sharding-JDBC路由
// 1. 配置分片规则
@Configuration
public class ShardingConfig {
@Bean
public DataSource dataSource() {
// 分库策略
DatabaseShardingStrategyConfiguration dbStrategy =
new DatabaseShardingStrategyConfiguration(
"user_id", // 分片键
new ModuloShardingAlgorithm() // 取模算法
);
// 分表策略
TableShardingStrategyConfiguration tableStrategy =
new TableShardingStrategyConfiguration(
"user_id",
new ModuloShardingAlgorithm()
);
// 分片规则
ShardingRuleConfiguration config = new ShardingRuleConfiguration();
config.setDefaultDatabaseShardingStrategyConfig(dbStrategy);
config.setDefaultTableShardingStrategyConfig(tableStrategy);
return ShardingDataSourceFactory.createDataSource(config);
}
}
// 2. 自定义分片算法
public class ModuloShardingAlgorithm implements PreciseShardingAlgorithm<Long> {
@Override
public String doSharding(Collection<String> availableTargetNames,
PreciseShardingValue<Long> shardingValue) {
Long userId = shardingValue.getValue();
// 4个库 × 8个表 = 32张表
int dbIndex = (int) (userId % 4); // 库编号 0-3
int tableIndex = (int) ((userId / 4) % 8); // 表编号 0-7
String targetName = "db" + dbIndex + ".orders_" + tableIndex;
log.info("路由:userId={} → {}", userId, targetName);
return targetName;
}
}
// 3. 使用(对业务透明)
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
public Order getOrder(Long userId, Long orderId) {
// Sharding-JDBC 自动路由到对应的库表
return orderMapper.selectOne(userId, orderId);
}
}
路由性能:
- 无分片:查询 2000万数据 → 500ms
- 32分片:查询 62.5万数据 → 15ms
- 性能提升:33倍! ⚡
🔥 优化技巧二:解决跨库JOIN
问题场景
-- 单库时的 JOIN 查询(简单)
SELECT o.*, u.name, u.phone
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.user_id = 123;
-- 分库后(orders 和 users 可能在不同库)
-- 无法直接 JOIN!😱
解决方案 1:冗余数据(空间换时间)
// ✅ 在 orders 表中冗余用户信息
@Table(name = "orders")
public class Order {
private Long id;
private Long userId;
// 冗余字段
private String userName; // 冗余用户名
private String userPhone; // 冗余手机号
private BigDecimal amount;
// ...
}
// 插入订单时同时写入用户信息
public void createOrder(Order order, User user) {
order.setUserName(user.getName());
order.setUserPhone(user.getPhone());
orderMapper.insert(order);
}
// 查询时不需要 JOIN
public Order getOrder(Long orderId) {
return orderMapper.selectById(orderId); // 一次查询搞定
}
优缺点:
- ✅ 查询快(不需要 JOIN)
- ✅ 性能高(单表查询)
- ❌ 数据冗余(占用更多空间)
- ❌ 一致性问题(用户改名后,订单中的名字不会自动更新)
解决方案 2:应用层 JOIN
// ✅ 分两步查询,应用层拼接
public OrderVO getOrderWithUser(Long userId, Long orderId) {
// 1. 查询订单
Order order = orderMapper.selectOne(userId, orderId);
// 2. 查询用户
User user = userMapper.selectById(userId);
// 3. 应用层拼接
OrderVO vo = new OrderVO();
vo.setOrder(order);
vo.setUser(user);
return vo;
}
解决方案 3:全局表
// ✅ 把小表(如字典表)复制到每个库
// 配置全局表
ShardingTableRuleConfiguration tableRule = new ShardingTableRuleConfiguration(
"dict_table",
"db${0..3}.dict_table" // 每个库都有这张表
);
tableRule.setTableStrategy(new NoneShardingStrategyConfiguration()); // 不分片
// 查询时可以直接 JOIN
SELECT o.*, d.name
FROM orders o
JOIN dict_table d ON o.type = d.code; // dict_table 在每个库都有
🔥 优化技巧三:解决跨库分页
问题场景
-- 查询用户的订单,按时间倒序,第1000页
SELECT * FROM orders
WHERE user_id = 123
ORDER BY create_time DESC
LIMIT 10000, 10;
-- 分库后:
-- 需要从 4 个库各取 10010 条数据
-- 合并后排序
-- 再取第 10000-10010 条
--
-- 总共查询:4 × 10010 = 40040 条数据!😱
解决方案 1:禁止跳页查询
// ❌ 不推荐:允许跳到任意页
@GetMapping("/orders")
public PageResult<Order> getOrders(@RequestParam int pageNum) {
// 用户可以跳到第 1000 页 → 性能差
}
// ✅ 推荐:只允许"下一页"
@GetMapping("/orders")
public PageResult<Order> getOrders(@RequestParam(required = false) Long lastId) {
// lastId 是上一页最后一条记录的 ID
List<Order> orders = orderMapper.selectAfter(lastId, 20);
return PageResult.of(orders);
}
<!-- MyBatis SQL -->
<select id="selectAfter" resultType="Order">
SELECT * FROM orders
WHERE user_id = #{userId}
<if test="lastId != null">
AND id < #{lastId} <!-- 基于 ID 的游标分页 -->
</if>
ORDER BY id DESC
LIMIT #{pageSize}
</select>
解决方案 2:二次查询
// ✅ 第一次:只查 ID
public PageResult<Order> getOrdersOptimized(int pageNum, int pageSize) {
int offset = (pageNum - 1) * pageSize;
// 1. 从4个库各查询 offset + pageSize 条 ID
List<Long> ids1 = db1.selectIds(offset + pageSize);
List<Long> ids2 = db2.selectIds(offset + pageSize);
List<Long> ids3 = db3.selectIds(offset + pageSize);
List<Long> ids4 = db4.selectIds(offset + pageSize);
// 2. 合并排序(只排序ID,轻量)
List<Long> allIds = new ArrayList<>();
allIds.addAll(ids1);
allIds.addAll(ids2);
allIds.addAll(ids3);
allIds.addAll(ids4);
Collections.sort(allIds);
// 3. 取出目标页的 ID
List<Long> targetIds = allIds.subList(offset, offset + pageSize);
// 4. 根据 ID 批量查询完整数据(走主键索引,快)
List<Order> orders = orderMapper.selectByIds(targetIds);
return PageResult.of(orders, allIds.size());
}
// 优势:
// - 第一次只查 ID(数据量小)
// - 第二次走主键索引(快)
// - 性能提升 10 倍以上
🔥 优化技巧四:批量操作优化
问题:路由到不同库的批量操作
// ❌ 逐个插入(慢!)
public void batchInsert(List<Order> orders) {
for (Order order : orders) {
// 每个 order 可能路由到不同的库
orderMapper.insert(order);
}
}
// 100条数据 = 100次网络往返
// ✅ 按库分组批量插入(快!)
public void batchInsertOptimized(List<Order> orders) {
// 1. 按库分组
Map<String, List<Order>> groupByDb = orders.stream()
.collect(Collectors.groupingBy(this::getDbName));
// 2. 每个库批量插入
for (Map.Entry<String, List<Order>> entry : groupByDb.entrySet()) {
String dbName = entry.getKey();
List<Order> batch = entry.getValue();
// 批量插入(一次网络往返)
orderMapper.insertBatch(dbName, batch);
}
}
// 100条数据 = 4次网络往返(假设4个库)
// 性能提升:25倍!⚡
🔥 优化技巧五:分布式事务优化
问题:跨库事务
// ❌ 不支持跨库事务
@Transactional
public void createOrder(Order order) {
// 插入订单(可能在 db0)
orderMapper.insert(order);
// 扣减库存(可能在 db1)
inventoryMapper.deduct(order.getProductId());
// 如果扣减库存失败,订单无法回滚!
}
解决方案:TCC 柔性事务
// ✅ TCC 模式
@Service
public class OrderService {
// Try:预留资源
@TwoPhaseBusinessAction(name = "createOrder", commitMethod = "commit", rollbackMethod = "rollback")
public boolean createOrder(Order order) {
// 1. 创建订单(状态=待支付)
orderMapper.insert(order);
// 2. 预扣库存(库存-1,冻结库存+1)
inventoryService.tryDeduct(order.getProductId());
return true;
}
// Confirm:确认提交
public boolean commit(BusinessActionContext context) {
Long orderId = (Long) context.getActionContext("orderId");
// 1. 更新订单状态(待支付 → 已支付)
orderMapper.updateStatus(orderId, "PAID");
// 2. 确认扣减库存(冻结库存-1)
inventoryService.confirmDeduct(orderId);
return true;
}
// Cancel:回滚
public boolean rollback(BusinessActionContext context) {
Long orderId = (Long) context.getActionContext("orderId");
// 1. 删除订单
orderMapper.deleteById(orderId);
// 2. 释放库存(库存+1,冻结库存-1)
inventoryService.cancelDeduct(orderId);
return true;
}
}
🔥 优化技巧六:数据迁移优化
平滑迁移方案
步骤:
1️⃣ 双写阶段
新数据:同时写旧库 + 新库
读数据:还是读旧库
2️⃣ 同步历史数据
用定时任务慢慢把旧库数据迁移到新库
3️⃣ 灰度切读
10%流量读新库,90%读旧库
监控一段时间,没问题继续增加
4️⃣ 全量切读
100%流量读新库
5️⃣ 停写旧库
不再写旧库,只写新库
迁移代码
@Service
public class OrderMigrationService {
@Autowired
private OldOrderMapper oldOrderMapper;
@Autowired
private NewOrderMapper newOrderMapper;
// 双写
public void createOrder(Order order) {
// 1. 写旧库
oldOrderMapper.insert(order);
// 2. 异步写新库
CompletableFuture.runAsync(() -> {
try {
newOrderMapper.insert(order);
} catch (Exception e) {
log.error("写新库失败", e);
// 记录到补偿表
}
});
}
// 历史数据迁移(定时任务)
@Scheduled(fixedRate = 60000) // 每分钟执行
public void migrateHistoryData() {
int batchSize = 1000;
Long lastId = getLastMigratedId(); // 从哪里开始
List<Order> orders = oldOrderMapper.selectBatch(lastId, batchSize);
if (orders.isEmpty()) {
return; // 迁移完成
}
// 批量插入新库
newOrderMapper.insertBatch(orders);
// 更新迁移进度
updateLastMigratedId(orders.get(orders.size() - 1).getId());
log.info("迁移了 {} 条数据", orders.size());
}
// 灰度读取
public Order getOrder(Long orderId) {
// 10%流量读新库
if (RandomUtils.nextInt(100) < 10) {
Order order = newOrderMapper.selectById(orderId);
if (order != null) {
return order;
}
}
// 读旧库
return oldOrderMapper.selectById(orderId);
}
}
📊 性能对比总结
优化前(单库单表)
orders 表:2000万条数据
查询性能:
主键查询:10ms
索引查询:100ms
全表扫描:超时
写入性能:
TPS:1000
瓶颈:
- 单表太大,查询慢
- 单库压力大
- 无法横向扩展
优化后(4库 × 8表)
32张表:每张约 62.5万条数据
查询性能:
主键查询:2ms(快5倍)⚡
索引查询:15ms(快7倍)⚡
分页查询:30ms(优化后)
写入性能:
TPS:8000(快8倍)⚡
优势:
- 单表数据少,查询快
- 多库分散压力
- 支持横向扩展
💡 面试加分回答模板
面试官:"分库分表后如何优化性能?"
标准回答:
"分库分表后主要从以下几个方面优化:
1. 路由优化:
- 选择合适的分片算法(Hash、Range、一致性Hash)
- 使用 Sharding-JDBC 自动路由
- 分片键选择要合理(通常用 userId、orderId)
2. 跨库JOIN优化:
- 冗余数据:把常用字段冗余到主表
- 应用层JOIN:分两次查询,应用层拼接
- 全局表:小表复制到每个库
3. 跨库分页优化:
- 禁止跳页,只允许"下一页"
- 二次查询:先查 ID,再查完整数据
- 基于游标的分页(lastId)
4. 批量操作优化:
- 按库分组批量操作
- 减少网络往返
5. 分布式事务:
- 优先避免跨库事务
- 必要时用 TCC 或 Saga
实际案例: 我们的订单表从 2000万拆分成 32张表后,查询性能提升了 5-7 倍,写入TPS 从 1000 提升到 8000。"
🎉 总结
分库分表优化的核心原则:
1. 路由要高效
→ 算法简单、分片均匀
2. 避免跨库操作
→ 冗余数据、应用层处理
3. 批量要分组
→ 按库分组批量操作
4. 分页要优化
→ 游标分页、二次查询
5. 迁移要平滑
→ 双写 → 迁移 → 灰度 → 切换
记住这个公式:
分库分表性能 = 单库性能 × 库数 × 优化系数
优化系数取决于:
- 路由算法是否高效
- 是否避免了跨库操作
- 批量操作是否优化
最后一句话:
分库分表不是银弹:
- 会增加系统复杂度
- 需要解决很多分布式问题
- 优先考虑其他优化手段
能不分就不分,非分不可时再分!🎯
祝你的分库分表又快又稳! 🗂️⚡
📚 扩展阅读