🗂️ 分库分表后的性能优化:驾驭分布式数据库!

80 阅读9分钟

"数据库单表2000万了,老板说要分库分表,我慌了..." 😰

📖 为什么要分库分表?

想象一个图书馆:

  • 单库单表:所有书都堆在一个房间(查找慢、拥挤)📚📚📚
  • 分库分表:按类别分成多个房间(查找快、分散压力)📚|📚|📚

分库分表的目的

  1. 突破单表容量限制(MySQL 单表建议 < 2000万)
  2. 提升查询性能(数据少了,查询快了)
  3. 分散数据库压力(多个库并行处理)

🎯 分库分表的挑战

分库分表后的问题:

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 &lt; #{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. 迁移要平滑
   → 双写 → 迁移 → 灰度 → 切换

记住这个公式

分库分表性能 = 单库性能 × 库数 × 优化系数

优化系数取决于:
  - 路由算法是否高效
  - 是否避免了跨库操作
  - 批量操作是否优化

最后一句话

分库分表不是银弹:
  - 会增加系统复杂度
  - 需要解决很多分布式问题
  - 优先考虑其他优化手段

能不分就不分,非分不可时再分!🎯

祝你的分库分表又快又稳! 🗂️⚡


📚 扩展阅读