返回 筑基・数据元府藏真

分库分表设计与实践

博主
大约 21 分钟

分库分表设计与实践

问题引入:单表数据量过亿

三年前,我们的订单系统还是一个单库单表的架构。随着业务快速增长,订单表的数据量很快突破了1亿条,到了第三年,数据量已经达到了惊人的15亿条。这时我们遇到了一系列严重的问题:

场景:订单表数据量达到15亿
问题统计:
- 查询性能:简单主键查询从10ms上升到500ms
- 索引失效:大量查询被迫全表扫描
- 备份时间:完整备份需要36小时
- 恢复时间:数据恢复需要48小时
- DDL操作:添加索引需要锁表12小时
- 磁盘空间:单表占用2TB空间
- 并发能力:QPS从5000下降到800

具体故障案例:
1. 2023年双11,订单查询接口超时,导致大量用户无法查看订单
2. 2024年春节,数据库备份失败,因为备份时间超过了维护窗口
3. 2024年3月,添加新字段的DDL操作导致服务中断2小时
-- 问题查询示例
-- 1. 主键查询变慢
SELECT * FROM orders WHERE order_id = 123456789;  -- 500ms

-- 2. 索引失效,全表扫描
SELECT * FROM orders WHERE user_id = 10001 
ORDER BY create_time DESC LIMIT 10;  -- 30秒

-- 3. 统计查询几乎无法执行
SELECT COUNT(*) FROM orders WHERE status = 'PAID';  -- 超时

-- 4. 备份命令
mysqldump -u root -p orders > orders.sql  -- 36小时

根本原因

  1. 单表数据量过大,B+树层级加深
  2. 索引文件过大,内存无法缓存
  3. 表锁竞争严重,并发能力下降
  4. 维护操作(备份、DDL)耗时过长

现象描述:大数据量下的数据库问题

案例1:查询性能急剧下降

场景:订单主键查询

数据量与查询性能关系:
┌─────────────────────────────────────────────────────────────┐
│  数据量      查询时间      B+树层级      索引大小            │
├─────────────────────────────────────────────────────────────┤
│  100万       5ms          2层          20MB                │
│  1000万      10ms         3层          200MB               │
│  1亿         50ms         3层          2GB                 │
│  5亿         200ms        4层          10GB                │
│  10亿        500ms        4层          20GB                │
│  15亿        800ms        4层          30GB                │
└─────────────────────────────────────────────────────────────┘

问题分析:
- B+树从3层增加到4层,每次查询多一次磁盘IO
- 索引大小30GB,无法全部加载到内存
- 大量查询需要磁盘随机读取

影响:用户体验差,系统响应慢。

案例2:DDL操作锁表时间过长

场景:添加新字段

-- 添加一个字段
ALTER TABLE orders ADD COLUMN source VARCHAR(20); 

-- 执行时间线
T+0h      开始执行ALTER
T+2h      创建临时表完成
T+4h      数据拷贝50%
T+8h      数据拷贝完成
T+10h     重建索引
T+12h     完成,切换表

-- 期间:
-- - 表被锁定,所有写操作阻塞
-- - 读操作性能下降
-- - 连接池耗尽
-- - 服务几乎不可用
锁表期间的影响:
┌─────────────────────────────────────────────────────────────┐
│                    DDL操作期间系统状态                        │
├─────────────────────────────────────────────────────────────┤
│  时间      写请求      读请求      连接池      错误率        │
├─────────────────────────────────────────────────────────────┤
│  T+0h     阻塞        正常        50%        0%            │
│  T+2h     阻塞        变慢        70%        5%            │
│  T+6h     阻塞        很慢        90%        20%           │
│  T+10h    阻塞        几乎不可用   100%       50%          │
│  T+12h    恢复        恢复        恢复        恢复          │
└─────────────────────────────────────────────────────────────┘

案例3:备份恢复困难

场景:数据库备份与恢复

# 备份命令
mysqldump -u root -p --single-transaction orders > orders_backup.sql

# 备份时间统计
数据量      备份时间      备份文件大小
100GB       2小时        30GB
500GB       10小时       150GB
1TB         20小时       300GB
2TB         36小时       600GB

# 恢复命令
mysql -u root -p orders < orders_backup.sql

# 恢复时间通常是备份时间的1.5-2倍
2TB数据恢复需要:54-72小时

影响

  • 备份窗口不足,无法每日全量备份
  • 恢复时间过长,RTO无法满足业务要求
  • 备份文件过大,存储成本增加

原因分析:单表大数据量瓶颈

1. InnoDB存储结构限制

InnoDB B+树结构:

┌─────────────────────────────────────────────────────────────┐
│                    B+树索引结构                              │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  根节点                                                       │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ 键值: [10, 20, 30, 40, 50, ...]                     │   │
│  │ 指针: [▼, ▼, ▼, ▼, ▼, ▼]                            │   │
│  └─────────────────────────────────────────────────────┘   │
│       │      │      │      │      │      │                 │
│       ▼      ▼      ▼      ▼      ▼      ▼                 │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ 中间节点(非叶子节点)                                │   │
│  │ 每个节点约16KB,存储约1000个键值                      │   │
│  │ 1000万数据:2层                                       │   │
│  │ 10亿数据:3层                                         │   │
│  └─────────────────────────────────────────────────────┘   │
│       │      │      │      │      │      │                 │
│       ▼      ▼      ▼      ▼      ▼      ▼                 │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ 叶子节点(存储实际数据)                              │   │
│  │ 每个节点16KB,存储约200行数据                         │   │
│  │ 15亿数据需要约750万个叶子节点                         │   │
│  │ 叶子节点总大小:约120GB                              │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                              │
│  查询过程:                                                   │
│  1. 根节点 → 中间节点 → 叶子节点(3次IO)                    │
│  2. 如果索引不能全部缓存到内存,需要磁盘读取                  │
│  3. 15亿数据的索引约30GB,超过一般服务器的内存                │
│                                                              │
└─────────────────────────────────────────────────────────────┘

2. 性能瓶颈分析

瓶颈类型具体表现影响程度
磁盘IO索引无法缓存,大量随机读严重
内存压力Buffer Pool命中率下降严重
锁竞争表锁、行锁竞争加剧中等
维护成本备份、DDL操作耗时过长严重
扩展性单表无法水平扩展严重

3. 分库分表的必要性

分库分表目标:

┌─────────────────────────────────────────────────────────────┐
│  单库单表                    分库分表                        │
│  ┌─────────┐                ┌─────────┐ ┌─────────┐        │
│  │ 15亿数据 │      →        │ 1亿数据 │ │ 1亿数据 │ ...     │
│  │ 1个表   │                │ 表1     │ │ 表2     │        │
│  │ 2TB     │                │ 16张表  │ │ 16张表  │        │
│  │ 30GB索引│                │ 32库    │         │        │
│  └─────────┘                └─────────┘ └─────────┘        │
│                                                              │
│  优势:                                                       │
│  1. 单表数据量减少到1亿以下,查询性能提升10倍                  │
│  2. 索引大小减少到2GB,可以完全缓存到内存                      │
│  3. 可以水平扩展,支持更大规模数据                             │
│  4. 维护操作(备份、DDL)时间大幅减少                          │
│  5. 故障隔离,单点故障影响范围减小                             │
└─────────────────────────────────────────────────────────────┘

解决方案:分库分表设计

1. 分片策略详解

1.1 哈希分片(Hash Sharding)

哈希分片原理:

┌─────────────────────────────────────────────────────────────┐
│  分片键值      哈希计算        分片结果       目标分片        │
├─────────────────────────────────────────────────────────────┤
│  user_id=1    hash(1)=xx      % 16 = 1      shard_01       │
│  user_id=2    hash(2)=xx      % 16 = 2      shard_02       │
│  user_id=3    hash(3)=xx      % 16 = 3      shard_03       │
│  ...                                                      │
│  user_id=16   hash(16)=xx     % 16 = 0      shard_00       │
│  user_id=17   hash(17)=xx     % 16 = 1      shard_01       │
└─────────────────────────────────────────────────────────────┘

优点:
- 数据分布均匀,避免热点
- 实现简单,性能高

缺点:
- 范围查询需要扫描所有分片
- 扩容时需要迁移数据

适用场景:
- 用户ID、订单ID等均匀分布的字段
- 点查询为主的场景
// 哈希分片算法
public class HashShardingStrategy {
    
    private int shardCount;
    
    public HashShardingStrategy(int shardCount) {
        this.shardCount = shardCount;
    }
    
    public int getShardIndex(Object shardKey) {
        int hash = shardKey.hashCode();
        // 处理负数
        return Math.abs(hash) % shardCount;
    }
    
    // 一致性哈希(扩容时减少数据迁移)
    public int getShardIndexConsistent(Object shardKey, int virtualNodes) {
        // 使用TreeMap实现一致性哈希环
        TreeMap<Integer, Integer> hashRing = buildHashRing(virtualNodes);
        int hash = shardKey.hashCode();
        Map.Entry<Integer, Integer> entry = hashRing.ceilingEntry(hash);
        return entry != null ? entry.getValue() : hashRing.firstEntry().getValue();
    }
}

1.2 范围分片(Range Sharding)

范围分片原理:

┌─────────────────────────────────────────────────────────────┐
│  时间范围                    分片                           │
├─────────────────────────────────────────────────────────────┤
│  2023-01 ~ 2023-03    →     order_2023_q1                  │
│  2023-04 ~ 2023-06    →     order_2023_q2                  │
│  2023-07 ~ 2023-09    →     order_2023_q3                  │
│  2023-10 ~ 2023-12    →     order_2023_q4                  │
│  2024-01 ~ 2024-03    →     order_2024_q1                  │
└─────────────────────────────────────────────────────────────┘

优点:
- 支持高效的范围查询
- 扩容简单,新增分片即可
- 可以按时间归档历史数据

缺点:
- 可能存在热点(最新数据集中在最后一个分片)
- 需要预先规划分片范围

适用场景:
- 时间序列数据(订单、日志)
- 范围查询频繁的场景
// 范围分片算法
public class RangeShardingStrategy {
    
    private TreeMap<Comparable, String> rangeMap;
    
    public RangeShardingStrategy() {
        rangeMap = new TreeMap<>();
        // 初始化分片范围
        rangeMap.put("2023-01-01", "order_2023_q1");
        rangeMap.put("2023-04-01", "order_2023_q2");
        rangeMap.put("2023-07-01", "order_2023_q3");
        rangeMap.put("2023-10-01", "order_2023_q4");
        rangeMap.put("2024-01-01", "order_2024_q1");
    }
    
    public String getShardName(Comparable shardKey) {
        Map.Entry<Comparable, String> entry = rangeMap.floorEntry(shardKey);
        return entry != null ? entry.getValue() : rangeMap.firstEntry().getValue();
    }
}

1.3 列表分片(List Sharding)

列表分片原理:

┌─────────────────────────────────────────────────────────────┐
│  地区           分片                                         │
├─────────────────────────────────────────────────────────────┤
│  北京、天津、河北  →   shard_north                           │
│  上海、江苏、浙江  →   shard_east                            │
│  广东、福建、海南  →   shard_south                           │
│  四川、重庆、云南  →   shard_southwest                       │
│  其他             →   shard_default                          │
└─────────────────────────────────────────────────────────────┘

优点:
- 灵活控制数据分布
- 可以按业务需求分配

缺点:
- 需要维护映射关系
- 数据可能不均匀

适用场景:
- 按地区分片
- 按业务类型分片

1.4 复合分片(Composite Sharding)

复合分片原理(先范围后哈希):

第一层:按时间范围分库
┌─────────────────────────────────────────────────────────────┐
│  时间范围              数据库                                │
├─────────────────────────────────────────────────────────────┤
│  2024-01 ~ 2024-06    →   db_2024_h1                       │
│  2024-07 ~ 2024-12    →   db_2024_h2                       │
└─────────────────────────────────────────────────────────────┘

第二层:按用户ID哈希分表
┌─────────────────────────────────────────────────────────────┐
│  在每个数据库内,再分16张表                                  │
│  order_0, order_1, ..., order_15                           │
└─────────────────────────────────────────────────────────────┘

最终分片:
user_id=10001, create_time=2024-03-15
→ db_2024_h1.order_1 (10001 % 16 = 1)

优点:
- 结合范围和哈希的优势
- 支持时间范围查询
- 避免单库热点

适用场景:
- 大规模分布式系统
- 需要同时支持范围查询和水平扩展

2. 分片键选择原则

分片键选择决策树:

                    ┌─────────────────────────────────────┐
                    │         业务场景分析                 │
                    └─────────────────┬───────────────────┘
                                      │
            ┌─────────────────────────┼─────────────────────────┐
            ↓                         ↓                         ↓
    ┌───────────────┐        ┌───────────────┐        ┌───────────────┐
    │  点查询为主    │        │  范围查询为主  │        │  混合查询     │
    │  用户查询      │        │  时间范围      │        │  多维查询     │
    └───────┬───────┘        └───────┬───────┘        └───────┬───────┘
            │                        │                        │
            ↓                        ↓                        ↓
    ┌───────────────┐        ┌───────────────┐        ┌───────────────┐
    │   哈希分片    │        │   范围分片    │        │  复合分片     │
    │   用户ID      │        │   时间字段    │        │  时间+用户ID  │
    └───────────────┘        └───────────────┘        └───────────────┘

分片键选择原则:

  1. 高频查询字段

    • 选择查询最频繁的字段作为分片键
    • 确保大部分查询可以路由到单个分片
  2. 数据分布均匀

    • 避免数据倾斜
    • 避免热点问题
  3. 避免跨分片查询

    • 尽量减少需要扫描多个分片的查询
    • 避免跨分片JOIN
  4. 考虑未来扩容

    • 预留足够的分片数量
    • 考虑一致性哈希减少扩容迁移

3. ShardingSphere配置详解

3.1 基础配置

# application.yml
spring:
  shardingsphere:
    # 数据源配置
    datasource:
      names: ds0, ds1, ds2, ds3
      ds0:
        type: com.zaxxer.hikari.HikariDataSource
        driver-class-name: com.mysql.cj.jdbc.Driver
        jdbc-url: jdbc:mysql://host0:3306/db0?useSSL=false
        username: root
        password: password
        maximum-pool-size: 50
      ds1:
        type: com.zaxxer.hikari.HikariDataSource
        driver-class-name: com.mysql.cj.jdbc.Driver
        jdbc-url: jdbc:mysql://host1:3306/db1?useSSL=false
        username: root
        password: password
        maximum-pool-size: 50
      ds2:
        type: com.zaxxer.hikari.HikariDataSource
        driver-class-name: com.mysql.cj.jdbc.Driver
        jdbc-url: jdbc:mysql://host2:3306/db2?useSSL=false
        username: root
        password: password
        maximum-pool-size: 50
      ds3:
        type: com.zaxxer.hikari.HikariDataSource
        driver-class-name: com.mysql.cj.jdbc.Driver
        jdbc-url: jdbc:mysql://host3:3306/db3?useSSL=false
        username: root
        password: password
        maximum-pool-size: 50
    
    # 规则配置
    rules:
      sharding:
        tables:
          # 订单表分片配置
          orders:
            # 实际数据节点:ds0.orders_0 ~ ds3.orders_15
            actual-data-nodes: ds$->{0..3}.orders_$->{0..15}
            
            # 表分片策略
            table-strategy:
              standard:
                sharding-column: order_id
                sharding-algorithm-name: order-table-hash
            
            # 库分片策略
            database-strategy:
              standard:
                sharding-column: user_id
                sharding-algorithm-name: user-db-hash
            
            # 主键生成策略
            key-generate-strategy:
              column: order_id
              key-generator-name: snowflake
          
          # 用户表(全局表,每个库都有一份)
          users:
            actual-data-nodes: ds$->{0..3}.users
            
        # 分片算法
        sharding-algorithms:
          # 订单表哈希算法
          order-table-hash:
            type: INLINE
            props:
              algorithm-expression: orders_$->{order_id % 16}
          
          # 用户库哈希算法
          user-db-hash:
            type: INLINE
            props:
              algorithm-expression: ds$->{user_id % 4}
          
          # 一致性哈希算法(用于扩容)
          consistent-hash:
            type: CLASS_BASED
            props:
              strategy: STANDARD
              algorithm-class-name: com.example.sharding.ConsistentHashAlgorithm
        
        # 主键生成器
        key-generators:
          snowflake:
            type: SNOWFLAKE
            props:
              worker-id: 0
              max-vibration-offset: 1
        
        # 广播表(全局表)
        broadcast-tables:
          - users
          - regions
          - product_categories
    
    # 属性配置
    props:
      sql-show: true  # 显示实际执行的SQL
      sql-simple: true  # 简化SQL输出
      executor-size: 16  # 执行线程池大小
      max-connections-size-per-query: 1  # 每个查询最大连接数

3.2 自定义分片算法

// 自定义复合分片算法
@Component
public class CompositeShardingAlgorithm implements StandardShardingAlgorithm<Long> {
    
    @Override
    public String doSharding(Collection<String> availableTargetNames, 
                            PreciseShardingValue<Long> shardingValue) {
        Long value = shardingValue.getValue();
        String logicTableName = shardingValue.getLogicTableName();
        
        // 高4位:库索引(0-15)
        // 低4位:表索引(0-15)
        int dbIndex = (int) ((value >> 4) & 0x0F);
        int tableIndex = (int) (value & 0x0F);
        
        // 构建实际表名
        return logicTableName + "_" + tableIndex;
    }
    
    @Override
    public Collection<String> doSharding(Collection<String> availableTargetNames,
                                        RangeShardingValue<Long> shardingValue) {
        // 范围查询需要扫描所有分片
        return availableTargetNames;
    }
    
    @Override
    public void init() {
        // 初始化
    }
    
    @Override
    public String getType() {
        return "COMPOSITE";
    }
}

4. 分布式ID生成

4.1 雪花算法(Snowflake)

@Component
public class SnowflakeIdGenerator {
    
    // 起始时间戳(2024-01-01)
    private final long EPOCH = 1704067200000L;
    
    // 各部分位数
    private final long WORKER_ID_BITS = 5L;      // 工作机器ID占5位
    private final long DATACENTER_ID_BITS = 5L;  // 数据中心ID占5位
    private final long SEQUENCE_BITS = 12L;      // 序列号占12位
    
    // 最大值
    private final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS);
    private final long MAX_DATACENTER_ID = ~(-1L << DATACENTER_ID_BITS);
    private final long MAX_SEQUENCE = ~(-1L << SEQUENCE_BITS);
    
    // 位移
    private final long WORKER_ID_SHIFT = SEQUENCE_BITS;
    private final long DATACENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS;
    private final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS + DATACENTER_ID_BITS;
    
    private long workerId;
    private long datacenterId;
    private long sequence = 0L;
    private long lastTimestamp = -1L;
    
    public SnowflakeIdGenerator(long workerId, long datacenterId) {
        if (workerId > MAX_WORKER_ID || workerId < 0) {
            throw new IllegalArgumentException("Worker ID out of range");
        }
        if (datacenterId > MAX_DATACENTER_ID || 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) & MAX_SEQUENCE;
            if (sequence == 0) {
                // 序列号溢出,等待下一毫秒
                timestamp = waitNextMillis(lastTimestamp);
            }
        } else {
            // 不同毫秒,序列号重置
            sequence = 0L;
        }
        
        lastTimestamp = timestamp;
        
        // 组合ID
        return ((timestamp - EPOCH) << TIMESTAMP_SHIFT)
                | (datacenterId << DATACENTER_ID_SHIFT)
                | (workerId << WORKER_ID_SHIFT)
                | sequence;
    }
    
    private long waitNextMillis(long lastTimestamp) {
        long timestamp = System.currentTimeMillis();
        while (timestamp <= lastTimestamp) {
            timestamp = System.currentTimeMillis();
        }
        return timestamp;
    }
}

4.2 分片ID生成器(嵌入分片信息)

@Component
public class ShardingIdGenerator {
    
    @Autowired
    private SnowflakeIdGenerator snowflake;
    
    /**
     * 生成包含分片信息的ID
     * 
     * ID结构(64位):
     * - 高4位:分片键(库索引)
     * - 接下来4位:分片键(表索引)
     * - 低56位:雪花算法ID
     */
    public long generateId(Long userId) {
        // 计算分片键
        int dbIndex = (int) (userId % 16);    // 16个库
        int tableIndex = (int) (userId % 16); // 每个库16张表
        
        // 生成雪花ID
        long snowflakeId = snowflake.nextId();
        
        // 组合ID
        long shardKey = ((long) dbIndex << 4) | tableIndex;
        return (shardKey << 56) | (snowflakeId & 0x00FFFFFFFFFFFFFFL);
    }
    
    /**
     * 从ID中提取分片信息
     */
    public ShardInfo extractShardInfo(Long orderId) {
        int dbIndex = (int) ((orderId >> 60) & 0x0F);
        int tableIndex = (int) ((orderId >> 56) & 0x0F);
        
        return new ShardInfo(dbIndex, tableIndex);
    }
    
    @Data
    public static class ShardInfo {
        private final int dbIndex;
        private final int tableIndex;
    }
}

5. 数据迁移方案

5.1 双写迁移方案

双写迁移流程:

阶段1:双写准备
┌─────────────────────────────────────────────────────────────┐
│  应用层                                                      │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  写入操作                                            │   │
│  │    ↓                                                │   │
│  │  双写(旧库+新库)                                   │   │
│  │    ↓                                                │   │
│  │  读旧库                                              │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                              │
│  旧库(单库单表)        新库(分库分表)                    │
│  ┌─────────────┐        ┌─────────────┐                     │
│  │  orders     │        │ orders_0    │                     │
│  │  15亿数据   │        │ orders_1    │                     │
│  └─────────────┘        │ ...         │                     │
│                         │ orders_15   │                     │
│                         └─────────────┘                     │
└─────────────────────────────────────────────────────────────┘

阶段2:历史数据迁移
- 使用canal或 Maxwell 同步历史数据
- 校验数据一致性

阶段3:切读
- 灰度切换读操作到新库
- 监控对比新旧库结果

阶段4:停双写,只写新库
- 确认无问题后,停止写旧库
- 保留旧库作为备份
@Service
public class DualWriteMigrationService {
    
    @Autowired
    private OrderMapper oldOrderMapper;  // 旧库Mapper
    
    @Autowired
    private OrderMapper newOrderMapper;  // 新库Mapper(ShardingSphere)
    
    @Autowired
    private MigrationConfig migrationConfig;
    
    /**
     * 双写创建订单
     */
    public Order createOrder(Order order) {
        // 生成新库ID(包含分片信息)
        Long newOrderId = shardingIdGenerator.generateId(order.getUserId());
        order.setOrderId(newOrderId);
        
        // 写新库
        newOrderMapper.insert(order);
        
        // 异步写旧库(不阻塞主流程)
        if (migrationConfig.isDualWriteEnabled()) {
            asyncWriteToOld(order);
        }
        
        return order;
    }
    
    /**
     * 读操作(根据灰度配置决定读哪个库)
     */
    public Order getOrder(Long orderId) {
        if (migrationConfig.isReadFromNew(orderId)) {
            // 读新库
            return newOrderMapper.selectById(orderId);
        } else {
            // 读旧库
            return oldOrderMapper.selectById(orderId);
        }
    }
    
    @Async
    protected void asyncWriteToOld(Order order) {
        try {
            oldOrderMapper.insert(order);
        } catch (Exception e) {
            log.error("写旧库失败", e);
            // 记录失败,后续补偿
        }
    }
}

5.2 数据校验

@Component
public class DataConsistencyChecker {
    
    /**
     * 校验新旧库数据一致性
     */
    public void checkConsistency(Long startId, Long endId) {
        for (Long id = startId; id <= endId; id++) {
            Order oldOrder = oldOrderMapper.selectById(id);
            Order newOrder = newOrderMapper.selectById(id);
            
            if (oldOrder == null && newOrder == null) {
                continue;  // 都为空,一致
            }
            
            if (oldOrder == null || newOrder == null) {
                log.error("数据不一致: ID={}, 旧库={}, 新库={}", 
                         id, oldOrder, newOrder);
                continue;
            }
            
            if (!oldOrder.equals(newOrder)) {
                log.error("数据不一致: ID={}, 差异={}", 
                         id, DiffUtils.diff(oldOrder, newOrder));
            }
        }
    }
}

实战案例:订单系统分库分表改造

改造前架构

改造前(单库单表):
┌─────────────────────────────────────────────────────────────┐
│  数据库:db_order                                            │
│  表:orders(15亿数据,2TB)                                  │
│                                                              │
│  问题:                                                       │
│  - 查询慢(主键查询500ms)                                    │
│  - 无法水平扩展                                               │
│  - 维护困难(备份36小时)                                     │
└─────────────────────────────────────────────────────────────┘

改造后架构

改造后(16库 * 16表 = 256分片):
┌─────────────────────────────────────────────────────────────┐
│  数据库集群:16个库                                           │
│  每个库表数:16张表                                           │
│  总分片数:256                                                │
│  单分片数据量:约600万                                        │
│  单分片大小:约8GB                                            │
│                                                              │
│  分片策略:                                                   │
│  - 库分片:user_id % 16                                       │
│  - 表分片:order_id % 16                                      │
│                                                              │
│  效果:                                                       │
│  - 查询快(主键查询10ms)                                     │
│  - 可水平扩展                                                 │
│  - 维护简单(单分片备份10分钟)                                │
└─────────────────────────────────────────────────────────────┘

核心代码实现

@Service
public class ShardingOrderService {
    
    @Autowired
    private OrderMapper orderMapper;
    
    @Autowired
    private ShardingIdGenerator idGenerator;
    
    /**
     * 创建订单(自动分片)
     */
    @Transactional
    public Order createOrder(OrderRequest request) {
        // 生成订单ID(包含分片信息)
        Long orderId = idGenerator.generateId(request.getUserId());
        
        Order order = new Order();
        order.setOrderId(orderId);
        order.setUserId(request.getUserId());
        order.setAmount(request.getAmount());
        order.setStatus(OrderStatus.CREATED);
        order.setCreateTime(new Date());
        
        // 自动路由到对应分片
        orderMapper.insert(order);
        
        return order;
    }
    
    /**
     * 根据订单ID查询(自动路由)
     */
    public Order getOrderById(Long orderId) {
        // ShardingSphere自动解析orderId中的分片信息
        return orderMapper.selectById(orderId);
    }
    
    /**
     * 根据用户ID查询订单列表(需要路由到指定分片)
     */
    public List<Order> getOrdersByUserId(Long userId, int page, int size) {
        // 计算分片
        int dbIndex = (int) (userId % 16);
        
        // 使用Hint强制路由到指定分片
        HintManager hintManager = HintManager.getInstance();
        hintManager.setDatabaseShardingValue(dbIndex);
        
        try {
            return orderMapper.selectByUserId(userId, (page - 1) * size, size);
        } finally {
            hintManager.close();
        }
    }
    
    /**
     * 分页查询所有订单(需要扫描所有分片)
     */
    public PageResult<Order> getAllOrders(int page, int size) {
        // 这种查询需要扫描所有分片,性能较差
        // 建议:
        // 1. 限制查询条件
        // 2. 使用ES等搜索引擎
        // 3. 预聚合数据到宽表
        
        // 示例:按时间范围查询
        Date startTime = DateUtils.addDays(new Date(), -7);
        Date endTime = new Date();
        
        return orderMapper.selectByTimeRange(startTime, endTime, (page - 1) * size, size);
    }
}

跨分片查询解决方案

@Service
public class CrossShardQueryService {
    
    @Autowired
    private ElasticsearchClient esClient;
    
    /**
     * 方案1:使用Elasticsearch做全局搜索
     */
    public PageResult<Order> searchOrders(OrderSearchRequest request) {
        // 构建ES查询
        BoolQuery.Builder boolQuery = new BoolQuery.Builder();
        
        if (request.getUserId() != null) {
            boolQuery.must(TermQuery.of(t -> t.field("userId").value(request.getUserId()))._toQuery());
        }
        
        if (request.getStatus() != null) {
            boolQuery.must(TermQuery.of(t -> t.field("status").value(request.getStatus()))._toQuery());
        }
        
        if (request.getStartTime() != null && request.getEndTime() != null) {
            boolQuery.must(RangeQuery.of(r -> r.field("createTime")
                .gte(JsonData.of(request.getStartTime()))
                .lte(JsonData.of(request.getEndTime())))._toQuery());
        }
        
        SearchResponse<OrderDoc> response = esClient.search(s -> s
            .index("orders")
            .query(boolQuery.build()._toQuery())
            .from((request.getPage() - 1) * request.getSize())
            .size(request.getSize())
            .sort(SortOptions.of(so -> so.field(f -> f.field("createTime").order(SortOrder.Desc))))
        , OrderDoc.class);
        
        // 获取订单ID列表
        List<Long> orderIds = response.hits().hits().stream()
            .map(hit -> hit.source().getOrderId())
            .collect(Collectors.toList());
        
        // 根据ID查询详情(自动路由到对应分片)
        List<Order> orders = orderIds.stream()
            .map(orderMapper::selectById)
            .filter(Objects::nonNull)
            .collect(Collectors.toList());
        
        return new PageResult<>(orders, response.hits().total().value(), request.getPage(), request.getSize());
    }
    
    /**
     * 方案2:使用宽表预聚合
     */
    public OrderStatistics getOrderStatistics(Long userId) {
        // 查询预聚合的统计表(单表,按用户ID分片)
        return orderStatisticsMapper.selectByUserId(userId);
    }
}

性能测试数据

1. 分库分表前后性能对比

指标改造前(单表15亿)改造后(256分片)提升
主键查询500ms10ms50x
用户订单列表30s50ms600x
写入TPS5008,00016x
备份时间36小时10分钟/分片216x
恢复时间72小时15分钟/分片288x
DDL时间12小时30秒1440x

2. 分片数量与性能关系

分片数单分片数据量查询延迟维护成本
161亿50ms
642500万20ms
256600万10ms
1024150万5ms很高

3. 扩容性能测试

扩容方式数据迁移量停机时间复杂度
双倍扩容50%0(在线)
一致性哈希<10%0(在线)
重新分片100%需要

经验总结

✅ 最佳实践

  1. 预估3-5年数据量

    • 根据业务增长预测数据量
    • 预留足够的分片数量
    • 考虑未来扩容方案
  2. 分片键选择查询最频繁的字段

    • 确保大部分查询可以路由到单个分片
    • 避免跨分片查询
    • 考虑数据分布均匀性
  3. 避免跨分片查询

    • 使用ES等搜索引擎做全局查询
    • 构建宽表预聚合数据
    • 应用层聚合结果
  4. 数据迁移双写方案

    • 先双写,再迁移历史数据
    • 灰度切读,验证数据一致性
    • 最后停双写
  5. 全局表(广播表)

    • 小数据量表在每个分片都存一份
    • 避免跨分片JOIN
    • 注意数据同步延迟

❌ 常见错误

  1. 分片键选择不当

    // 错误:使用自增ID作为分片键
    // 导致数据倾斜,热点问题
    
    // 正确:使用用户ID作为分片键
    // 数据均匀分布
    
  2. 频繁跨分片JOIN

    -- 错误:跨分片JOIN
    SELECT o.*, u.name 
    FROM orders o 
    JOIN users u ON o.user_id = u.id
    -- orders和users在不同分片,需要跨分片查询
    
    -- 正确:将users设为广播表
    -- 每个分片都有users表,避免跨分片JOIN
    
  3. 分片数过少,无法扩容

    # 错误:只分4个片
    actual-data-nodes: ds$->{0..3}.orders_$->{0..3}
    
    # 正确:预留足够分片
    actual-data-nodes: ds$->{0..15}.orders_$->{0..15}
    
  4. 忽略分布式事务

    // 错误:跨分片事务
    @Transactional
    public void transfer(Long fromUserId, Long toUserId, BigDecimal amount) {
        // fromUser和toUser在不同分片
        // 普通事务无法保证一致性
    }
    
    // 正确:使用分布式事务框架
    @GlobalTransactional
    public void transfer(Long fromUserId, Long toUserId, BigDecimal amount) {
        // 使用Seata等分布式事务框架
    }
    

决策树:分库分表策略选择

                    ┌─────────────────────────────────────┐
                    │         数据规模评估                 │
                    └─────────────────┬───────────────────┘
                                      │
            ┌─────────────────────────┼─────────────────────────┐
            ↓                         ↓                         ↓
    ┌───────────────┐        ┌───────────────┐        ┌───────────────┐
    │  < 1000万     │        │  1000万-1亿   │        │  > 1亿        │
    │  小数据量     │        │  中等数据量   │        │  大数据量     │
    └───────┬───────┘        └───────┬───────┘        └───────┬───────┘
            │                        │                        │
            ↓                        ↓                        ↓
    ┌───────────────┐        ┌───────────────┐        ┌───────────────┐
    │  单表即可     │        │  分表不分库   │        │  分库分表     │
    │  优化索引     │        │  16-64张表   │        │  16库*16表   │
    └───────────────┘        └───────────────┘        └───────────────┘

检查清单

分库分表设计检查清单

  • 是否预估了3-5年数据量?
  • 分片键选择是否合理?
  • 分片数量是否足够?
  • 是否有跨分片查询?
  • 是否有分布式事务?
  • 数据迁移方案是否完善?
  • 是否有全局表(广播表)?
  • 是否有ID生成方案?
  • 是否有数据一致性校验?
  • 是否有监控告警?

分库分表运维检查清单

  • 各分片数据量是否均衡?
  • 是否有热点分片?
  • 跨分片查询是否过多?
  • 分片连接池是否正常?
  • 数据备份是否正常?
  • 扩容方案是否测试?
  • 故障转移是否可用?
  • 监控告警是否正常?

系列上一篇主从复制与读写分离实战

系列下一篇数据库缓存架构:Redis与数据库一致性

知识点测试

读完文章了?来测试一下你对知识点的掌握程度吧!

评论区

使用 GitHub 账号登录后即可发表评论,支持 Markdown 格式。

如果评论系统无法加载,请确保:

  • 您的网络可以访问 GitHub
  • giscus GitHub App 已安装到仓库
  • 仓库已启用 Discussions 功能