分库分表是应对海量数据与高并发访问的核心数据库架构方案,旨在突破单机数据库在性能、存储与连接数上的瓶颈。
一、 分库分表的核心概念
- 分库:将数据分布到不同的数据库实例中。
- 分表:将数据分布到同一数据库实例的不同表中。
- 核心目标:解决单机数据库的存储上限与性能瓶颈。
二、 为何需要分库分表?
单机数据库的典型瓶颈
// 主要问题集中在:
// 1. 存储瓶颈:数据量超过服务器磁盘容量
// 2. 性能瓶颈:高并发下CPU、内存、IO不堪重负
// 3. 连接数限制:数据库连接数有限制
// 4. 运维困难:大数据量下的备份、恢复操作极其耗时
具体衡量指标:
- 数据量:单表数据量达到千万级别后,查询性能显著下降。
- 并发量:查询每秒(QPS)高达数千时,单机数据库难以支撑。
- 数据大小:单个数据库的数据量超过服务器磁盘总容量的70%。
三、 分库分表的四种类型
1. 垂直分库
根据业务模块进行拆分,将不同业务的表独立到不同的数据库中。
-- 拆分前:单库 `ecommerce`
-- 包含:users, products, orders, payments 等表
-- 拆分后:
-- 数据库 `user_db`:users表
-- 数据库 `product_db`:products表
-- 数据库 `order_db`:orders, payments表
优点:业务解耦,降低单库压力。
缺点:无法解决单表数据量过大的问题,跨库查询变得复杂。
2. 垂直分表
将一张宽表按列(字段)拆分,将频繁查询的列与不常用的大字段列分离。
-- 原表
CREATE TABLE users (
id BIGINT,
username VARCHAR(50),
password VARCHAR(100),
email VARCHAR(100),
profile_text TEXT, -- 不常用的大字段
avatar LONGBLOB, -- 图片数据
created_time DATETIME
);
-- 拆分后
-- 基础信息表(高频查询)
CREATE TABLE users_base (
id BIGINT,
username VARCHAR(50),
password VARCHAR(100),
email VARCHAR(100),
created_time DATETIME
);
-- 扩展信息表(低频查询)
CREATE TABLE users_ext (
user_id BIGINT,
profile_text TEXT,
avatar LONGBLOB
);
优点:提升高频查询性能,避免IO浪费。
缺点:需要关联查询,增加了事务一致性的复杂度。
3. 水平分库
将同一张表的数据,按照某种规则分布到不同的数据库实例中。
-- 按用户ID取模分库(例如分为2个库,4张表)
-- 数据库:user_db_0
-- ├── users_0 (user_id % 4 = 0)
-- └── users_1 (user_id % 4 = 1)
-- 数据库:user_db_1
-- ├── users_2 (user_id % 4 = 2)
-- └── users_3 (user_id % 4 = 3)
4. 水平分表
将同一张表的数据,按照某种规则分布到同一数据库实例的不同表中。
-- 按用户ID取模分表(在同一数据库内)
-- 数据库:user_db
-- ├── users_0 (user_id % 4 = 0)
-- ├── users_1 (user_id % 4 = 1)
-- ├── users_2 (user_id % 4 = 2)
-- └── users_3 (user_id % 4 = 3)
四、 核心分片策略(路由算法)
1. 范围分片
按字段值的范围(如时间、ID区间)进行分片。
public class RangeSharding {
// 按用户ID范围分片
public String getTableName(Long userId) {
if (userId >= 1 && userId <= 1000000) {
return “users_0”;
} else if (userId > 1000000 && userId <= 2000000) {
return “users_1”;
} else {
return “users_2”;
}
}
// 按时间范围分片(按月分表)
public String getTableName(Date createTime) {
SimpleDateFormat sdf = new SimpleDateFormat(”yyyy_MM”);
return “orders_” + sdf.format(createTime);
}
}
优点:易于扩展,支持高效的范围查询。
缺点:可能产生数据热点,导致负载不均。
2. 哈希分片
根据字段的哈希值取模来决定数据位置。
public class HashSharding {
// 基于用户ID的哈希取模分片
public String getTableName(Long userId, int tableCount) {
int hash = Math.abs(userId.hashCode());
int tableIndex = hash % tableCount;
return “users_” + tableIndex;
}
}
优点:数据分布相对均匀。
缺点:扩容时数据迁移量大,范围查询困难。
3. 地理位置分片
根据业务属性(如所属地区)进行分片。
public class GeoSharding {
public String getTableName(String province) {
switch (province) {
case “北京”: case “天津”: case “河北”:
return “users_north”;
case “上海”: case “江苏”: case “浙江”:
return “users_east”;
case “广东”: case “广西”: case “海南”:
return “users_south”;
default:
return “users_west”;
}
}
}
五、 挑战与解决方案
1. 分布式ID生成
使用雪花算法(Snowflake)生成全局唯一、趋势递增的ID。
@Component
public class SnowflakeIdGenerator {
// ... 雪花算法实现细节(数据中心ID、机器ID、序列号等)
public synchronized long nextId() {
// 核心逻辑:结合时间戳、机器标识和序列号生成ID
long timestamp = System.currentTimeMillis();
// ... 处理时钟回拨、同一毫秒内序列号递增等逻辑
return ((timestamp - 1609459200000L) << 22) |
(datacenterId << 17) |
(machineId << 12) |
sequence;
}
}
2. 跨库查询与JOIN
在应用层进行数据聚合,替代数据库层的JOIN。
@Service
public class OrderService {
public OrderDetail getOrderDetail(Long orderId, Long userId) {
// 1. 查询订单主信息
Order order = orderMapper.selectById(orderId);
// 2. 根据分片键(userId)定位到正确的库/表查询用户信息
User user = userMapper.selectByShardingKey(userId);
// 3. 查询订单商品列表
List<OrderItem> items = orderItemMapper.selectByOrderId(orderId);
// 4. 在应用层组装结果
return new OrderDetail(order, user, items);
}
}
3. 分布式事务
借助如Seata这类中间件,实现跨数据库的事务一致性。
@GlobalTransactional // Seata的全局事务注解
@Service
public class OrderCreateService {
public void createOrder(Order order) {
// 1. 扣减库存(操作商品库)
productService.reduceStock(order.getProductId(), order.getQuantity());
// 2. 创建订单(操作订单库)
orderMapper.insert(order);
// 3. 增加用户积分(操作用户库)
userService.addPoints(order.getUserId(), order.getAmount());
}
}
4. 分页查询
并行查询各分片,在内存中进行结果合并、排序与分页。
@Service
public class UserQueryService {
public PageResult<User> queryUsers(int page, int size) {
// 1. 并行查询所有分片,各取 (page+1)*size 条数据
List<CompletableFuture<List<User>>> futures = new ArrayList<>();
for (int i = 0; i < shardCount; i++) {
final int shardIndex = i;
futures.add(CompletableFuture.supplyAsync(() ->
userMapper.selectFromShard(shardIndex, size * (page + 1))
));
}
// 2. 合并所有结果
List<User> allUsers = futures.stream()
.map(CompletableFuture::join)
.flatMap(List::stream)
.collect(Collectors.toList());
// 3. 内存中排序并分页
List<User> pageData = allUsers.stream()
.sorted(Comparator.comparing(User::getId).reversed())
.skip(page * size)
.limit(size)
.collect(Collectors.toList());
return new PageResult<>(pageData, allUsers.size());
}
}
六、 主流中间件与配置
常用中间件对比
| 中间件 |
类型 |
特点 |
适用场景 |
| ShardingSphere |
客户端 |
功能丰富,生态完善,Apache顶级项目 |
各种复杂分片场景 |
| MyCat |
服务端(代理) |
配置简单,易于上手 |
中小型项目 |
| Vitess |
服务端(代理) |
YouTube开源,云原生(K8s)友好 |
大规模云原生环境 |
ShardingSphere 配置示例
以下是一个典型的水平分库分表配置,展示了如何使用数据库及中间件领域的热门工具ShardingSphere-JDBC。
# application-sharding.yml
spring:
shardingsphere:
datasource:
names: ds0, ds1
ds0:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://localhost:3306/ds0
username: root
password: root
ds1:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://localhost:3306/ds1
username: root
password: root
sharding:
tables:
users:
actual-data-nodes: ds$->{0..1}.users_$->{0..3} # 指定物理数据节点
table-strategy: # 分表策略
inline:
sharding-column: user_id
algorithm-expression: users_$->{user_id % 4}
database-strategy: # 分库策略
inline:
sharding-column: user_id
algorithm-expression: ds$->{user_id % 2}
七、 最佳实践与原则
1. 分片键选择原则
- 高基数:字段值分布均匀,如用户ID。
- 业务相关性:是核心查询条件,避免非分片键查询带来的全路由扫描。
- 避免热点:不会导致数据过度倾斜到某个分片。
- 稳定性:字段值不经常更新,否则可能触发数据迁移。
2. 分片数量规划
合理评估数据增长趋势,预留扩容空间。
public class ShardingCalculator {
public int calculateShardCount(long totalDataSize, int singleShardCapacity) {
// 单分片建议容量(如:1000万-5000万行)
int shardCount = (int) Math.ceil((double) totalDataSize / singleShardCapacity);
// 为未来扩展预留Buffer(例如翻倍)
return shardCount * 2;
}
}
3. 平滑迁移方案(双写迁移)
@Service
public class DataMigrationService {
public void migrateData() {
// 1. 双写阶段:新旧库同时写入
enableDoubleWrite();
// 2. 历史迁移:将存量数据迁移至新分片
migrateHistoricalData();
// 3. 数据校验:确保新旧数据一致性
validateDataConsistency();
// 4. 流量切换:逐步将读流量切至新库
switchReadTraffic();
// 5. 下线老库:停止写入老库,完成迁移
stopOldWrite();
}
}
八、 面试要点精粹
一个结构化的回答思路:
“分库分表是解决数据库扩展性瓶颈的核心手段,主要分为垂直拆分和水平拆分。垂直拆分(分库、分表)侧重业务解耦,水平拆分(分库、分表)侧重解决单点数据量过大问题。
实践中需要重点考量分片键设计、全局ID生成、跨片查询聚合、分布式事务等挑战。解决方案包括选用Snowflake等分布式ID算法、应用层聚合数据、采用Seata处理事务等。
目前业界普遍采用ShardingSphere这类成熟中间件,它封装了SQL解析、路由、执行、结果归并等复杂逻辑,能极大降低开发复杂度。”
九、 何时应避免分库分表?
- 数据量不大:单表数据远未达到千万级别。
- 并发压力小:QPS在单机数据库轻松承受范围内。
- 有更优解:可通过索引优化、读写分离、引入缓存(如Redis)等手段解决。
- 团队能力不足:缺乏足够的运维和故障排查经验,复杂的分布式架构可能带来更大风险。