【Java项目技术亮点】分库分表+数据路由策略:单表5000万后的架构升级方案

单表数据量突破5000万,查询慢、写入慢、备份慢------当MySQL单表成为瓶颈,分库分表是绕不过去的话题。本文将揭秘大麦网、ShardingSphere等开源项目中的分库分表实战方案,手把手教你实现数据路由、全局ID生成、跨库查询等核心功能。

文章目录


一、场景引入:分库分表的前夜

1.1 真实案例

某互联网公司,用户量快速增长:

复制代码
时间线:
第1年:用户表500万,一切正常
第2年:用户表2000万,查询开始变慢
第3年:用户表5000万,DBA发出红色告警
第4年:用户表1亿,夜间备份需要6小时
第5年:用户表2亿,DDL变更直接锁表

症状:
- 用户登录查询从10ms飙升到500ms
- 索引树高度增加,B+树从3层变成4层
- 表空间文件超过200GB,备份耗时长
- ALTER TABLE操作需要数小时
- 主从延迟严重,影响读写分离效果

问题根源:MySQL InnoDB单表数据量超过5000万后,性能急剧下降。B+树层级增加导致IO次数增多,索引维护成本增大。

1.2 什么时候需要分库分表?

指标 安全值 危险值 说明
单表数据量 < 2000万 > 5000万 B+树层级增加,查询变慢
单库数据量 < 100GB > 500GB 备份恢复困难
QPS < 3000 > 5000 单机连接数瓶颈
并发连接数 < 2000 > 5000 连接池耗尽

二、解决方案:分库分表架构

2.1 分库分表策略选择

复制代码
分库分表策略对比:

┌─────────────────────────────────────────────────────────────────────┐
│                                                                      │
│  垂直拆分(按业务拆分)                                              │
│  ├── 垂直分库:用户库、订单库、商品库、支付库                        │
│  └── 垂直分表:订单表 → 订单基础表 + 订单详情表 + 订单扩展表          │
│                                                                      │
│  水平拆分(按数据分片)                                              │
│  ├── 水平分库:user_db_0, user_db_1, user_db_2, ...                  │
│  └── 水平分表:order_0, order_1, order_2, ...                        │
│                                                                      │
│  推荐组合:先垂直拆分,再水平拆分                                    │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

分片策略对比

策略 优点 缺点 适用场景
Hash取模 数据分布均匀 扩容困难(需要数据迁移) 数据量大,不需要频繁扩容
范围分片 扩容简单 数据分布不均匀 数据有明显范围特征
一致性Hash 扩容影响最小 实现复杂 需要频繁扩容

2.2 分片规则设计

java 复制代码
/**
 * 分片规则配置
 * 按用户ID Hash取模分库,按订单ID取模分表
 */
@Configuration
public class ShardingRuleConfig {
    
    /**
     * 分库规则:user_id % 4
     * 4个数据库:ds_0, ds_1, ds_2, ds_3
     */
    @Bean
    public DataSource shardingDataSource() throws SQLException {
        // 配置真实数据源
        Map<String, DataSource> dataSourceMap = new HashMap<>();
        dataSourceMap.put("ds_0", createDataSource("jdbc:mysql://db0:3306/app"));
        dataSourceMap.put("ds_1", createDataSource("jdbc:mysql://db1:3306/app"));
        dataSourceMap.put("ds_2", createDataSource("jdbc:mysql://db2:3306/app"));
        dataSourceMap.put("ds_3", createDataSource("jdbc:mysql://db3:3306/app"));
        
        // 分库规则
        ShardingRuleConfiguration shardingRuleConfig = new ShardingRuleConfiguration();
        
        // 订单表分库分表规则
        shardingRuleConfig.getTableRuleConfigs().add(
            getOrderTableRuleConfiguration()
        );
        
        // 用户表分库规则
        shardingRuleConfig.getTableRuleConfigs().add(
            getUserTableRuleConfiguration()
        );
        
        Properties props = new Properties();
        props.put("sql.show", "true");  // 打印SQL日志
        
        return ShardingDataSourceFactory.createDataSource(
            dataSourceMap, shardingRuleConfig, props
        );
    }
    
    /**
     * 订单表分片规则
     * 分库:user_id % 4
     * 分表:order_id % 16
     */
    private TableRuleConfiguration getOrderTableRuleConfiguration() {
        // 分库策略
        InlineShardingStrategyConfiguration dbStrategy = 
            new InlineShardingStrategyConfiguration("user_id", "ds_${user_id % 4}");
        
        // 分表策略
        InlineShardingStrategyConfiguration tableStrategy = 
            new InlineShardingStrategyConfiguration("order_id", "order_${order_id % 16}");
        
        return new TableRuleConfiguration(
            "t_order", 
            Arrays.asList("ds_0", "ds_1", "ds_2", "ds_3"),
            dbStrategy,
            tableStrategy
        );
    }
}

三、实战代码:核心功能实现

3.1 全局ID生成(雪花算法)

分库分表后,不能使用数据库自增ID(会冲突),需要全局唯一ID。

java 复制代码
/**
 * 基于雪花算法的全局ID生成器
 * 结构:1位符号 + 41位时间戳 + 10位机器ID + 12位序列号
 */
@Component
@Slf4j
public class SnowflakeIdGenerator {
    
    // 起始时间戳(2024-01-01)
    private static final long EPOCH = 1704067200000L;
    
    // 各部分占用的位数
    private static final long SEQUENCE_BITS = 12L;
    private static final long WORKER_ID_BITS = 10L;
    
    // 最大值
    private static final long MAX_SEQUENCE = ~(-1L << SEQUENCE_BITS);
    private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS);
    
    // 位移
    private static final long WORKER_ID_SHIFT = SEQUENCE_BITS;
    private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS;
    
    private final long workerId;
    private long sequence = 0L;
    private long lastTimestamp = -1L;
    
    public SnowflakeIdGenerator(@Value("${snowflake.worker-id:1}") long workerId) {
        if (workerId < 0 || workerId > MAX_WORKER_ID) {
            throw new IllegalArgumentException("Worker ID超出范围");
        }
        this.workerId = workerId;
        log.info("✅ SnowflakeIdGenerator初始化,workerId={}", workerId);
    }
    
    /**
     * 生成下一个ID(线程安全)
     */
    public synchronized long nextId() {
        long currentTimestamp = System.currentTimeMillis();
        
        // 时钟回拨检测
        if (currentTimestamp < lastTimestamp) {
            throw new RuntimeException("时钟回拨,拒绝生成ID");
        }
        
        // 同一毫秒内,序列号递增
        if (currentTimestamp == lastTimestamp) {
            sequence = (sequence + 1) & MAX_SEQUENCE;
            if (sequence == 0) {
                // 序列号溢出,等待下一毫秒
                currentTimestamp = waitNextMillis(currentTimestamp);
            }
        } else {
            sequence = 0;
        }
        
        lastTimestamp = currentTimestamp;
        
        return ((currentTimestamp - EPOCH) << TIMESTAMP_SHIFT)
            | (workerId << WORKER_ID_SHIFT)
            | sequence;
    }
    
    /**
     * 从ID中解析出时间戳
     */
    public static LocalDateTime parseTime(long id) {
        long timestamp = (id >> TIMESTAMP_SHIFT) + EPOCH;
        return LocalDateTime.ofInstant(
            Instant.ofEpochMilli(timestamp), ZoneId.systemDefault()
        );
    }
    
    /**
     * 从ID中解析出机器ID
     */
    public static long parseWorkerId(long id) {
        return (id >> WORKER_ID_SHIFT) & MAX_WORKER_ID;
    }
    
    private long waitNextMillis(long currentTimestamp) {
        long timestamp = System.currentTimeMillis();
        while (timestamp <= currentTimestamp) {
            timestamp = System.currentTimeMillis();
        }
        return timestamp;
    }
}

3.2 分布式ID生成器(Redis实现)

java 复制代码
/**
 * 基于Redis的分布式ID生成器
 * 适合不需要严格有序的场景
 */
@Component
@Slf4j
public class RedisIdGenerator {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    private static final String ID_KEY_PREFIX = "global:id:";
    
    /**
     * 生成分布式ID
     * 格式:yyyyMMdd + 8位自增序号
     */
    public String generateId(String bizType) {
        String key = ID_KEY_PREFIX + bizType + ":" + LocalDate.now();
        
        // Redis INCR原子自增
        Long seq = redisTemplate.opsForValue().increment(key);
        
        // 设置过期时间(第二天自动清理)
        redisTemplate.expire(key, 1, TimeUnit.DAYS);
        
        // 格式化:20250101 + 8位序号(补零)
        String dateStr = LocalDate.now().format(
            DateTimeFormatter.ofPattern("yyyyMMdd"));
        return dateStr + String.format("%08d", seq);
    }
    
    /**
     * 批量生成ID(性能优化)
     */
    public List<String> batchGenerateId(String bizType, int count) {
        String key = ID_KEY_PREFIX + bizType + ":" + LocalDate.now();
        
        // INCRBY一次获取count个ID
        Long endSeq = redisTemplate.opsForValue().increment(key, count);
        redisTemplate.expire(key, 1, TimeUnit.DAYS);
        
        Long startSeq = endSeq - count + 1;
        
        List<String> ids = new ArrayList<>(count);
        String dateStr = LocalDate.now().format(
            DateTimeFormatter.ofPattern("yyyyMMdd"));
        
        for (long i = startSeq; i <= endSeq; i++) {
            ids.add(dateStr + String.format("%08d", i));
        }
        
        return ids;
    }
}

3.3 跨库查询方案

java 复制代码
/**
 * 跨库查询服务
 * 分库分表后,跨库查询是最大的痛点
 */
@Service
@Slf4j
public class CrossShardQueryService {
    
    @Autowired
    private ShardingDataSource shardingDataSource;
    
    @Autowired
    private OrderMapper orderMapper;
    
    /**
     * 方案1:并行查询 + 内存合并
     * 适用于:已知分片键的查询
     */
    public PageResult<Order> queryByUserId(Long userId, int page, int size) {
        // ShardingSphere会自动根据user_id路由到对应的库和表
        // 不需要手动处理
        Page<Order> result = PageHelper.startPage(page, size)
            .doSelectPage(() -> orderMapper.selectByUserId(userId));
        
        return new PageResult<>(result.getResult(), result.getTotal());
    }
    
    /**
     * 方案2:全库扫描 + 合并排序
     * 适用于:不知道分片键的查询(如按手机号查用户)
     */
    public List<User> queryByPhone(String phone) {
        // 获取所有数据源
        Map<String, DataSource> dataSourceMap = getDataSourceMap();
        
        // 并行查询所有分片
        List<CompletableFuture<List<User>>> futures = dataSourceMap.entrySet()
            .stream()
            .map(entry -> CompletableFuture.supplyAsync(() -> {
                return queryFromShard(entry.getKey(), phone);
            }))
            .collect(Collectors.toList());
        
        // 合并结果
        List<User> results = futures.stream()
            .map(CompletableFuture::join)
            .flatMap(List::stream)
            .collect(Collectors.toList());
        
        return results;
    }
    
    /**
     * 方案3:建立索引表(推荐)
     * 将非分片键 → 分片键的映射关系存储在索引表中
     */
    public Long getUserIdByPhone(String phone) {
        // 从索引表查询(索引表不分片,或按phone分片)
        PhoneIndex index = phoneIndexMapper.selectByPhone(phone);
        if (index != null) {
            return index.getUserId();
        }
        return null;
    }
    
    /**
     * 方案4:数据异构到Elasticsearch
     * 将分库分表数据同步到ES,复杂查询走ES
     */
    public PageResult<Order> searchOrders(OrderSearchDTO dto) {
        // 从ES查询,获取订单ID列表
        List<Long> orderIds = orderEsService.search(dto);
        
        if (CollUtil.isEmpty(orderIds)) {
            return PageResult.empty();
        }
        
        // 根据订单ID回查分库分表(ShardingSphere自动路由)
        List<Order> orders = orderMapper.selectByIds(orderIds);
        
        return new PageResult<>(orders, (long) orders.size());
    }
}

3.4 索引表设计

java 复制代码
/**
 * 索引表(解决非分片键查询问题)
 * phone → user_id 的映射
 */
@Data
@TableName("t_phone_index")
public class PhoneIndex {
    
    @TableId(type = IdType.INPUT)
    private String phone;
    
    private Long userId;
    
    private LocalDateTime createTime;
}

/**
 * 索引表维护
 */
@Component
@Slf4j
public class IndexTableService {
    
    @Autowired
    private PhoneIndexMapper phoneIndexMapper;
    
    /**
     * 用户注册时,写入索引表
     */
    @Transactional
    public void createIndex(String phone, Long userId) {
        PhoneIndex index = new PhoneIndex();
        index.setPhone(phone);
        index.setUserId(userId);
        index.setCreateTime(LocalDateTime.now());
        phoneIndexMapper.insert(index);
    }
    
    /**
     * 用户修改手机号时,更新索引表
     */
    @Transactional
    public void updateIndex(String oldPhone, String newPhone, Long userId) {
        // 删除旧索引
        phoneIndexMapper.deleteById(oldPhone);
        // 创建新索引
        createIndex(newPhone, userId);
    }
}

四、高级进阶:扩容与运维

4.1 数据迁移扩容

java 复制代码
/**
 * 分库分表扩容服务
 * 从N个分片扩容到2N个分片
 */
@Component
@Slf4j
public class ShardingMigrationService {
    
    @Autowired
    private JdbcTemplate jdbcTemplate;
    
    @Autowired
    private SnowflakeIdGenerator idGenerator;
    
    /**
     * 双写扩容方案
     * 1. 新旧分片规则并行运行
     * 2. 新数据同时写入新旧分片
     * 3. 历史数据异步迁移
     * 4. 验证无误后切换到新规则
     */
    public void migrate(int oldShardCount, int newShardCount) {
        log.info("🔄 开始分片迁移: {} → {}", oldShardCount, newShardCount);
        
        // 1. 配置双写规则
        enableDualWrite(oldShardCount, newShardCount);
        
        // 2. 迁移历史数据
        for (int i = 0; i < oldShardCount; i++) {
            migrateShard(i, oldShardCount, newShardCount);
        }
        
        // 3. 数据校验
        boolean verified = verifyMigration(oldShardCount, newShardCount);
        
        if (verified) {
            // 4. 切换到新规则
            switchToNewRule(newShardCount);
            log.info("✅ 分片迁移完成");
        } else {
            log.error("❌ 数据校验失败,回滚");
            rollbackMigration();
        }
    }
    
    /**
     * 迁移单个分片的数据
     */
    private void migrateShard(int shardIndex, int oldCount, int newCount) {
        String sourceTable = "t_order_" + shardIndex;
        
        int offset = 0;
        int batchSize = 1000;
        
        while (true) {
            // 从旧分片读取数据
            List<Order> orders = jdbcTemplate.query(
                "SELECT * FROM " + sourceTable + " LIMIT ? OFFSET ?",
                new Object[]{batchSize, offset},
                new OrderRowMapper()
            );
            
            if (CollUtil.isEmpty(orders)) {
                break;
            }
            
            // 按新规则写入新分片
            for (Order order : orders) {
                int newShard = (int)(order.getOrderId() % newCount);
                String targetTable = "t_order_" + newShard;
                insertIntoShard(targetTable, order);
            }
            
            offset += batchSize;
            log.info("分片{}: 已迁移 {} 条", shardIndex, offset);
        }
    }
}

4.2 动态配置路由规则

java 复制代码
/**
 * 动态路由规则(通过配置中心)
 * 路由规则变更不需要重启应用
 */
@Component
@Slf4j
public class DynamicShardingRule {
    
    @Autowired
    private NacosConfigManager nacosConfigManager;
    
    private volatile ShardingConfig currentConfig;
    
    /**
     * 监听配置变更
     */
    @PostConstruct
    public void init() {
        // 初始加载
        String configJson = nacosConfigManager.getConfig("sharding-rule");
        currentConfig = JSON.parseObject(configJson, ShardingConfig.class);
        
        // 监听变更
        nacosConfigManager.addListener("sharding-rule", (newConfig) -> {
            log.info("📋 分片规则变更: {}", newConfig);
            ShardingConfig newRule = JSON.parseObject(newConfig, ShardingConfig.class);
            currentConfig = newRule;
        });
    }
    
    /**
     * 计算分片
     */
    public int calculateShard(Long id) {
        return (int)(id % currentConfig.getShardCount());
    }
    
    /**
     * 获取当前配置
     */
    public ShardingConfig getCurrentConfig() {
        return currentConfig;
    }
}

五、预判问题与解答

Q1:分库分表后,事务怎么办?

A

复制代码
分库分表后的事务方案:

1. 单库事务(推荐):
   - 同一个库内的多表操作,使用本地事务
   - 保证ACID

2. 跨库事务(分布式事务):
   - 方案A:Seata(AT模式,推荐)
   - 方案B:基于MQ的最终一致性
   - 方案C:TCC补偿型事务

3. 最佳实践:
   - 尽量避免跨库事务(通过合理的库表拆分)
   - 非跨库操作用本地事务
   - 必须跨库时用Seata

Q2:分库分表后,JOIN怎么办?

A

复制代码
跨库JOIN解决方案:

1. 应用层JOIN(推荐):
   - 先查主表获取关联ID
   - 再根据关联ID查从表
   - 在应用层合并数据

2. 全局表:
   - 数据量小且不常变化的表(如字典表、配置表)
   - 每个库都放一份完整数据

3. 数据异构到ES:
   - 将关联数据同步到ES
   - 在ES中做JOIN查询

4. 冗余字段:
   - 将常用关联字段冗余到主表
   - 通过消息队列保证一致性

Q3:如何选择分片键?

A

复制代码
分片键选择原则:

1. 高频查询条件:
   - 选择最常用的查询条件作为分片键
   - 如:用户表用user_id,订单表用order_id

2. 数据分布均匀:
   - 分片键的值应该分布均匀
   - 避免热点问题(如按时间分片会导致最新数据集中)

3. 避免跨片查询:
   - 尽量让常用查询都能命中分片键
   - 非分片键查询通过索引表解决

4. 不可变性:
   - 分片键一旦确定不能修改
   - 选择不会变化的字段

Q4:扩容时数据怎么迁移?

A

复制代码
扩容方案对比:

1. 停机迁移(简单但不推荐):
   - 停止应用 → 导出数据 → 按新规则导入 → 启动应用
   - 缺点:停机时间长

2. 双写迁移(推荐):
   - 新旧分片规则并行
   - 新数据双写
   - 历史数据异步迁移
   - 校验后切换

3. 一致性Hash扩容:
   - 使用一致性Hash算法
   - 扩容时只迁移部分数据
   - 影响最小

Q5:分库分表和微服务怎么配合?

A

复制代码
配合策略:

1. 先垂直拆分(微服务化):
   - 按业务域拆分为独立服务
   - 每个服务有自己的数据库

2. 再水平拆分(分库分表):
   - 单个服务内的数据量仍然过大
   - 对核心表进行水平分片

3. 最佳实践:
   - 垂直拆分优先(解决80%的问题)
   - 水平拆分兜底(单表超过5000万再考虑)

六、面试高频考点

考点1:分库分表的分片策略有哪些?

参考答案

复制代码
常见分片策略:

1. Hash取模:
   - 分片键 % N
   - 数据分布均匀
   - 扩容困难(需要数据迁移)

2. 范围分片:
   - 按ID范围或时间范围分片
   - 扩容简单(增加新分片即可)
   - 数据可能不均匀(热点问题)

3. 一致性Hash:
   - Hash环 + 虚拟节点
   - 扩容影响最小(只迁移1/N的数据)
   - 实现复杂

推荐:数据量大且稳定用Hash取模,需要频繁扩容用一致性Hash。

考点2:全局ID生成方案有哪些?

参考答案

复制代码
方案对比:

1. UUID:
   - 优点:简单,无需中心化
   - 缺点:无序,字符串存储空间大

2. 数据库自增(多主模式):
   - 不同库设置不同初始值和步长
   - 缺点:扩容困难

3. Redis INCR:
   - 原子自增,性能好
   - 缺点:依赖Redis

4. 雪花算法(推荐):
   - 本地生成,无网络依赖
   - 有序(按时间递增)
   - 缺点:依赖系统时钟

5. Leaf(美团开源):
   - 号段模式 + ZooKeeper
   - 高性能,适合高并发场景

考点3:分库分表后如何处理跨库查询?

参考答案

复制代码
跨库查询解决方案:

1. 索引表(推荐):
   - 建立非分片键→分片键的映射表
   - 先查索引表获取分片键,再路由查询
   - 维护成本:数据变更时需要同步更新索引

2. 异构到ES:
   - 将数据同步到Elasticsearch
   - 复杂查询走ES
   - 实时性取决于同步延迟

3. 全库扫描:
   - 并行查询所有分片
   - 内存合并结果
   - 性能差,仅适合低频查询

考点4:ShardingSphere的核心原理?

参考答案

复制代码
ShardingSphere核心原理:

1. SQL解析:
   - 解析SQL语句,提取表名、条件等信息

2. 路由计算:
   - 根据分片规则计算目标数据源
   - 支持单路由和多路由

3. SQL改写:
   - 将逻辑SQL改写为实际执行的物理SQL
   - 如:SELECT * FROM t_order → SELECT * FROM t_order_3

4. 结果归并:
   - 合并多个分片的查询结果
   - 支持排序、分页、聚合等归并

七、总结与最佳实践

7.1 核心要点回顾

复制代码
分库分表核心流程:

┌─────────────────────────────────────────────────────────────┐
│  1. 分片策略选择                                            │
│     ├── Hash取模(数据均匀,扩容难)                        │
│     ├── 范围分片(扩容简单,可能不均匀)                    │
│     └── 一致性Hash(扩容影响最小)                          │
│                                                              │
│  2. 全局ID生成                                              │
│     ├── 雪花算法(推荐,本地生成)                          │
│     ├── Redis INCR(简单,依赖Redis)                       │
│     └── 号段模式(高性能,适合超高并发)                    │
│                                                              │
│  3. 跨库查询                                                │
│     ├── 索引表(推荐,非分片键→分片键映射)                  │
│     ├── 数据异构到ES(复杂查询走ES)                         │
│     └── 全库扫描(低频查询兜底)                             │
│                                                              │
│  4. 扩容迁移                                                │
│     ├── 双写方案(新旧并行)                                 │
│     ├── 数据校验                                            │
│     └── 灰度切换                                            │
└─────────────────────────────────────────────────────────────┘

7.2 性能提升数据

某电商平台实测数据:

指标 优化前(单表2亿) 优化后(4库16表) 提升
单表数据量 2亿 1250万 94%↓
查询响应时间 500ms-2s 10-50ms 40倍↑
写入QPS 3000 12000 4倍↑
DDL变更时间 6小时 5分钟 72倍↑
备份时间 6小时 30分钟 12倍↑

八、参考与拓展


互动讨论:你们公司做了分库分表吗?用的什么方案?有没有遇到过跨库查询的坑?欢迎在评论区分享!

如果本文对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,持续获取更多Java后端技术干货!

相关推荐
程序员黑豆1 小时前
AI全栈开发 - Java:变量
java·前端·ai编程
wu_ye_m1 小时前
学习c语言第35天 函数声明和定义
c语言·开发语言·学习
布朗克1681 小时前
25 IO流高级操作——序列化、NIO与Files工具类
java·数据库·io·nio
njsgcs1 小时前
c# solidworks 创建装配体工程图+bom
开发语言·c#·solidworks
小研说技术1 小时前
Spring AI实现rag流程(简易版)
java·后端
亓才孓1 小时前
【本地项目引用外部库的类,想修改字段遇到的请缓存的问题】
java·maven
小林敲代码77881 小时前
记录一下IDEA中很多变量变色的方案
java·开发语言·spring boot·idea
南知意-2 小时前
IDEA 2026.1最新版安装教程
java·ide·intellij-idea·idea安装·idea激活
njsgcs2 小时前
c# solidworks 工程图获得展开视图不在固定面螺纹特征的位置
开发语言·c#·solidworks