"单表5000万数据,查询从0.1秒到10秒------我们为什么要拆数据库?"
一次数据库架构演进的完整复盘
面试官:谈谈 为什么要拆分数据库?
单体项目在构建之初,数据库的负载和数据量都不大,所以不需要对数据库做拆分,小型财务系统、文书系统、ERP系统、OA系统,用一个MySQL数据库实例基本就够用了。
就像《淘宝技术这十年》里面说到的,电商业务的数据量增长飞快,所以最开始的PHP+MySQL的架构已经不能满足实际要求了,于是淘宝想到的第一个办法就是把MySQL替换成Oracle。但是没过了多久,在08年前后,单节点的Oracle数据库也不好用了,于是淘宝终于告别了单节点数据库,开始拆分数据库。从一个节点,变成多个节点。
拆分数据库是有讲究的,比如说拆分方法有两种:垂直切分和水平切分。那你是先水平切分还是垂直切分呢?顺序无所谓?不,顺序有所谓,次序绝对不能错:先水平切分,然后垂直切分。
为什么要拆分数据库?五大核心原因
1. 性能瓶颈:单机性能到达天花板
真实数据:
- 单表数据量超过5000万,索引效率急剧下降
- 数据库连接数达到上限(默认151个)
- 磁盘IO成为瓶颈,查询延迟飙升
sql
sql
-- 拆分前:单表6000万数据
SELECT * FROM orders WHERE user_id = 123456 ORDER BY create_time DESC LIMIT 20;
-- 执行时间:8.2秒
-- 拆分后:单表50万数据
SELECT * FROM orders_01 WHERE user_id = 123456 ORDER BY create_time DESC LIMIT 20;
-- 执行时间:0.05秒
2. 可用性风险:单点故障的致命威胁
事故场景:
- 数据库服务器硬件故障 → 全站不可用
- MySQL bug导致crash → 所有业务停摆
- 维护操作(如ALTER TABLE)→ 长时间锁表
3. 团队协作:数据库成为开发瓶颈
团队痛点:
- 多个团队共用同一个数据库
- 表结构变更需要多方协调
- 一个团队的慢SQL影响所有业务
4. 业务复杂度:不同业务的不同需求
需求差异:
- 订单业务:高并发写入,强一致性
- 报表业务:复杂查询,弱一致性
- 用户业务:读多写少,高可用性
5. 成本控制:硬件成本的指数增长
成本对比:
- 单机高端服务器:50万/台
- 多台普通服务器:10万/台 × 5台 = 50万
- 但性能提升:5倍以上,且具备横向扩展能力
数据库拆分的六大方法
方法一:垂直分库(按业务拆分)
核心思想:不同业务使用不同数据库
拆分前架构:
text
单一数据库:shop_db
├── 用户表:users
├── 商品表:products
├── 订单表:orders
├── 支付表:payments
└── 日志表:logs
拆分后架构:
java
// 用户库:user_db
@Component
@DataSource("userDataSource")
public class UserMapper {
// 用户相关表:users, user_profile, user_address
}
// 商品库:product_db
@Component
@DataSource("productDataSource")
public class ProductMapper {
// 商品相关表:products, categories, inventory
}
// 订单库:order_db
@Component
@DataSource("orderDataSource")
public class OrderMapper {
// 订单相关表:orders, order_items, order_logs
}
配置示例:
yaml
spring:
datasource:
user:
url: jdbc:mysql://user-db:3306/user_db
product:
url: jdbc:mysql://product-db:3306/product_db
order:
url: jdbc:mysql://order-db:3306/order_db
方法二:水平分库(按数据分片)
核心思想:相同业务,不同数据分布在不同数据库
分片策略:
java
public class DatabaseShardingStrategy {
// 按用户ID分库:user_id % 4
public String getDataSourceName(Long userId) {
int dbIndex = userId % 4; // 分4个库
return "order_db_" + dbIndex;
}
// 按时间分库:每月一个库
public String getDataSourceName(Date createTime) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy_MM");
return "order_db_" + sdf.format(createTime);
}
}
方法三:垂直分表(按字段拆分)
拆分场景:表字段过多,冷热数据分离
拆分前:
sql
-- 用户表(40+字段)
CREATE TABLE users (
id BIGINT,
username VARCHAR(50),
password VARCHAR(100),
-- ... 20个常用字段
last_login_ip VARCHAR(45),
last_login_time DATETIME,
-- ... 20个不常用字段
personal_signature TEXT,
background_image VARCHAR(200)
);
拆分后:
sql
-- 热表:频繁查询的字段
CREATE TABLE users_hot (
id BIGINT,
username VARCHAR(50),
password VARCHAR(100),
email VARCHAR(100),
phone VARCHAR(20),
-- ... 其他常用字段
PRIMARY KEY (id)
);
-- 冷表:不常查询的字段
CREATE TABLE users_cold (
user_id BIGINT,
personal_signature TEXT,
background_image VARCHAR(200),
register_source VARCHAR(50),
-- ... 其他不常用字段
PRIMARY KEY (user_id)
);
方法四:水平分表(按数据行拆分)
核心思想:大表拆小表,减少单表数据量
分表策略:
java
public class TableShardingStrategy {
// 按用户ID分表:user_id % 16
public String getTableName(Long userId, String logicTable) {
int tableIndex = userId % 16; // 分16张表
return logicTable + "_" + tableIndex;
}
// 按时间分表:每月一张表
public String getTableName(Date createTime, String logicTable) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy_MM");
return logicTable + "_" + sdf.format(createTime);
}
}
SQL改写:
sql
-- 拆分前:
SELECT * FROM orders WHERE user_id = 123456;
-- 拆分后(需要应用层改写):
SELECT * FROM orders_8 WHERE user_id = 123456; -- 123456 % 16 = 8
方法五:读写分离
核心思想:写操作走主库,读操作走从库
架构设计:
java
@Configuration
public class DataSourceConfig {
@Bean
@Primary
public DataSource routingDataSource() {
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put("master", masterDataSource());
targetDataSources.put("slave1", slave1DataSource());
targetDataSources.put("slave2", slave2DataSource());
RoutingDataSource routingDataSource = new RoutingDataSource();
routingDataSource.setTargetDataSources(targetDataSources);
routingDataSource.setDefaultTargetDataSource(masterDataSource());
return routingDataSource;
}
}
// 通过注解控制数据源
@Service
public class OrderService {
@ReadOnly // 读从库
public Order getOrder(Long orderId) {
return orderMapper.selectById(orderId);
}
// 默认写主库
public void createOrder(Order order) {
orderMapper.insert(order);
}
}
方法六:混合拆分策略
真实案例:大型电商平台架构
java
public class HybridShardingStrategy {
public ShardingResult resolve(String logicTable, Long userId, Date createTime) {
// 1. 先按业务垂直分库
String dbName = "order_db";
// 2. 再按用户ID水平分库
int dbShard = userId % 8; // 分8个库
dbName = dbName + "_" + dbShard;
// 3. 最后按时间水平分表
String tableName = logicTable + "_" + getMonthSuffix(createTime);
return new ShardingResult(dbName, tableName);
}
}
拆分带来的挑战与解决方案
挑战一:分布式事务
解决方案:
java
// 使用Seata分布式事务框架
@GlobalTransactional
public void createOrder(Order order) {
// 1. 扣减库存(库存库)
inventoryService.deduct(order);
// 2. 创建订单(订单库)
orderService.create(order);
// 3. 扣减余额(用户库)
accountService.deductBalance(order);
}
挑战二:跨库查询
解决方案:
java
// 1. 字段冗余
public class OrderVO {
private Long orderId;
private Long userId;
private String userName; // 冗余用户名字段
private String productName; // 冗余商品名字段
}
// 2. 使用Elasticsearch构建查询视图
@Component
public class OrderSearchService {
public List<OrderVO> searchOrders(OrderQuery query) {
// 从ES查询,避免跨库JOIN
return elasticsearchTemplate.query(query);
}
}
挑战三:全局唯一ID
解决方案:
java
// 雪花算法生成分布式ID
@Component
public class SnowflakeIdGenerator {
public Long nextId() {
// 时间戳 + 机器ID + 序列号
return snowflake.nextId();
}
}
// 数据库号段模式
@Service
public class SegmentIdGenerator {
public Long nextId(String businessType) {
// 从数据库获取号段,内存中分配
return idSegmentService.getNextSegment(businessType);
}
}
拆分实施路线图
阶段一:准备期(1个月)
- 数据评估:分析数据量、增长趋势、访问模式
- 架构设计:确定拆分方案、分片键、路由规则
- 技术选型:选择中间件(ShardingSphere、MyCat等)
阶段二:实施期(2个月)
- 数据迁移:双写方案,逐步迁移
- 应用改造:DAO层重构,SQL改写
- 测试验证:功能测试、性能测试、数据一致性验证
阶段三:优化期(持续)
- 监控告警:慢查询、数据分布、连接数监控
- 弹性扩容:动态添加分片,在线数据迁移
- 故障演练:模拟节点故障,验证高可用性
什么时候应该考虑拆分?
立即拆分(红色警报):
- 单表数据量 > 5000万,查询性能明显下降
- 数据库服务器CPU持续 > 80%
- 频繁出现锁等待超时
规划拆分(黄色预警):
- 单表数据量 > 2000万,且每月增长 > 100万
- 业务快速发展,预计半年内达到性能瓶颈
- 团队规模扩大,需要解耦协作
暂不拆分(绿色安全):
- 单表数据量 < 1000万
- 业务稳定,无显著增长
- 团队规模小,协作顺畅
经验总结
成功关键:
- 循序渐进:从读写分离开始,再到垂直拆分,最后水平拆分
- 数据驱动:基于真实数据做决策,不要过早优化
- 工具支撑:使用成熟的中间件,不要重复造轮子
- 监控先行:建立完善的监控体系,提前发现问题
失败教训:
- 分片键选择不当:导致数据倾斜,热点问题
- 过度拆分:增加系统复杂度,维护成本高
- 准备不足:数据迁移过程中出现数据不一致
思考题:在你的项目中,如果订单表需要同时支持按用户查询和按商家查询,分片键应该怎么设计?欢迎在评论区分享你的架构方案!