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

文章目录
-
- 一、场景引入:分库分表的前夜
-
- [1.1 真实案例](#1.1 真实案例)
- [1.2 什么时候需要分库分表?](#1.2 什么时候需要分库分表?)
- 二、解决方案:分库分表架构
-
- [2.1 分库分表策略选择](#2.1 分库分表策略选择)
- [2.2 分片规则设计](#2.2 分片规则设计)
- 三、实战代码:核心功能实现
-
- [3.1 全局ID生成(雪花算法)](#3.1 全局ID生成(雪花算法))
- [3.2 分布式ID生成器(Redis实现)](#3.2 分布式ID生成器(Redis实现))
- [3.3 跨库查询方案](#3.3 跨库查询方案)
- [3.4 索引表设计](#3.4 索引表设计)
- 四、高级进阶:扩容与运维
-
- [4.1 数据迁移扩容](#4.1 数据迁移扩容)
- [4.2 动态配置路由规则](#4.2 动态配置路由规则)
- 五、预判问题与解答
- 六、面试高频考点
- 七、总结与最佳实践
-
- [7.1 核心要点回顾](#7.1 核心要点回顾)
- [7.2 性能提升数据](#7.2 性能提升数据)
- 八、参考与拓展
一、场景引入:分库分表的前夜
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后端技术干货!