分库分表后,我后悔了

摘要:从一次"单表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

这样同一用户的userorder在同一库,可以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

141位时间戳                              5512位序列号
符号  (支持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=1008610086 % 10 = 6 → db_6
user_id=1008710087 % 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);
}

🎓 面试标准答案

题目:什么时候需要分库分表?如何选择分片策略?

答案

分库分表的时机

  1. 单表行数 > 2000万
  2. 单表容量 > 10GB
  3. 并发查询 > 5000 QPS
  4. 优化索引、读写分离、缓存都无效

分片策略

  • 哈希分片:数据均匀,扩容难(推荐)
  • 范围分片:扩容简单,数据不均
  • 一致性哈希:平衡两者

5大难题

  1. 跨库JOIN → 字段冗余/应用层JOIN
  2. 分页查询 → 归并排序
  3. 分布式ID → 雪花算法
  4. 跨库事务 → 本地消息表/Seata
  5. 数据迁移 → 双写/停机迁移

最佳实践

  • 能不分就不分
  • 按业务关联分片(同一用户数据在同一库)
  • 用ShardingSphere等中间件
  • 提前规划扩容方案

🎉 结束语

三个月后,哈吉米终于把分库分表的坑都填完了。

哈吉米:"原来分库分表这么多坑!跨库JOIN、分页、事务、分布式ID……"

南北绿豆:"对,分库分表不是银弹,要慎重决定。"

阿西噶阿西:"记住:能不分就不分,要分就提前规划好所有问题。"

哈吉米:"还有字段冗余是解决跨库JOIN的最实用方案。"

南北绿豆:"对,用空间换时间,这是分库分表的核心思想。"


记忆口诀

单表两千万,十G容量需分表
哈希分片最常用,数据均匀难扩容
跨库JOIN字段冗,应用层拼也能行
雪花算法生成ID,本地消息保事务
分页归并要排序,能不分表就不分


希望这篇文章能帮你理解分库分表的坑和解决方案!记住:分库分表是最后的手段,要充分评估成本和收益!💪