数据库架构演进实战——从单库到分布式数据库的迁移方法论
数据库架构演进实战——从单库到分布式数据库的迁移方法论
适读人群:面临数据库架构升级的技术负责人、架构师和高级后端工程师 | 阅读时长:约18分钟 | 核心价值:一套经过实战验证的数据库架构演进方法论,每个阶段都有可操作的决策框架
三年的数据库演进史
2020 年,我开始长期跟进一个做企业服务的创业公司的技术演进。他们从 0 到 1 搭建系统,到今天数据规模已经到了相当量级。这三年里,他们的数据库架构经历了五次大的演进。
最开始:1 台 4 核 8G 的云服务器,MySQL 单实例,所有业务数据在一个库里,一个开发一个运维,没有任何架构设计。系统跑了 9 个月,用户量从 0 增长到 1 万。
然后融资了,用户暴增。每隔 3-6 个月就要被迫做一次架构升级,每次升级都是在系统已经出现瓶颈的情况下打补丁,非常痛苦。
"要是当初知道这条路,能少走多少弯路。"CTO 小王三年后跟我说。
今天,我把这条演进之路系统整理出来,每个阶段都给出清晰的触发条件、方案选择和踩坑记录。
一、第一阶段:单库 + 主从复制
触发条件
- 单库 QPS 超过 2000,写操作稳定,但读操作增长明显
- 读操作占总 QPS 的 70% 以上
- 数据量在 1 亿行以内,B+树高度不超过 3-4 层
方案:一主一从(或一主多从)
# 主库:负责写操作
# 从库:负责读操作(报表、列表查询等)
# Spring Boot 配置双数据源
spring:
datasource:
master:
url: jdbc:mysql://master-host:3306/biz_db
username: root
password: ${MASTER_PASSWORD}
hikari:
maximum-pool-size: 20
slave:
url: jdbc:mysql://slave-host:3306/biz_db
username: readonly
password: ${SLAVE_PASSWORD}
hikari:
maximum-pool-size: 40
read-only: true # 连接级别设置只读,避免误写踩坑一:读写分离后的主从延迟问题
参考之前的主从复制文章,这里只补充一个实战经验:
延迟监控告警设置为 1 秒。超过 1 秒说明从库有明显落后,需要立刻排查原因(通常是大事务或 SQL thread 单线程瓶颈)。
超过 5 秒说明从库已经严重落后,此时应该临时把部分读流量切回主库,防止用户读到严重过期的数据。
二、第二阶段:读写分离 + 冷热数据分离
触发条件
- 热数据(近 3 个月)只占全量数据的 20%,但承担了 90% 的查询
- 全表 B+树层数达到 4-5 层,常规查询需要 5 次以上磁盘 IO
- 加了更多硬件(更大的 SSD),效果边际递减
方案:归档历史数据
把 3 个月以前的数据归档到历史库,热数据只保留最近 3 个月:
@Service
public class DataArchiveService {
@Autowired
private HotOrderMapper hotOrderMapper;
@Autowired
private ArchiveOrderMapper archiveOrderMapper;
/**
* 归档历史订单(每天凌晨 2 点执行)
* 每次归档 30 天前的数据,每批 5000 条
*/
@Scheduled(cron = "0 0 2 * * ?")
@Transactional
public void archiveOrders() {
LocalDate archiveBefore = LocalDate.now().minusDays(90);
int batchSize = 5000;
int totalArchived = 0;
while (true) {
// 查询需要归档的数据
List<Order> toArchive = hotOrderMapper.findOldOrders(
archiveBefore, batchSize);
if (toArchive.isEmpty()) break;
// 写入归档库
archiveOrderMapper.batchInsert(toArchive);
// 从热库删除(分批删,避免大事务)
List<Long> ids = toArchive.stream()
.map(Order::getId).collect(Collectors.toList());
hotOrderMapper.deleteByIds(ids);
totalArchived += toArchive.size();
log.info("已归档 {} 条订单", totalArchived);
try { Thread.sleep(200); } catch (InterruptedException e) { break; }
}
log.info("订单归档完成,共归档 {} 条", totalArchived);
}
}这一步往往被低估,但效果往往超出预期。我见过一个团队把 5 亿行的表拆出历史数据后,热数据只剩 3000 万行,所有查询速度提升了 5-10 倍,省了分库分表的复杂度。
三、第三阶段:垂直分库
触发条件
- 不同业务模块的数据互相影响(一个慢查询拖累整个库)
- 需要独立扩展某个业务模块的数据库资源
- 团队规模增大,不同团队负责不同业务模块,希望解耦数据层
方案:按业务域拆分数据库
原来:单库 biz_db(包含所有表)
拆分后:
order_db(订单、支付相关表)
user_db(用户、权限相关表)
product_db(商品、库存相关表)
message_db(消息、通知相关表)垂直分库的难点在于跨库关联:原来可以用 JOIN 的地方,现在必须在应用层做关联。
// 跨库关联的应用层实现
@Service
public class OrderListService {
@Autowired
private OrderRepository orderRepository; // 连接 order_db
@Autowired
private UserRepository userRepository; // 连接 user_db
@Autowired
private ProductRepository productRepository; // 连接 product_db
public List<OrderDetailDTO> getOrderDetails(List<Long> orderIds) {
// 1. 从订单库查订单
List<Order> orders = orderRepository.findByIds(orderIds);
// 2. 收集需要查询的 userId 和 productId
Set<Long> userIds = orders.stream()
.map(Order::getUserId).collect(Collectors.toSet());
Set<Long> productIds = orders.stream()
.flatMap(o -> o.getItems().stream().map(OrderItem::getProductId))
.collect(Collectors.toSet());
// 3. 批量查询用户信息(一次请求,不是 N 次)
Map<Long, User> userMap = userRepository.findByIds(userIds).stream()
.collect(Collectors.toMap(User::getId, u -> u));
// 4. 批量查询商品信息
Map<Long, Product> productMap = productRepository.findByIds(productIds).stream()
.collect(Collectors.toMap(Product::getId, p -> p));
// 5. 在内存中组装结果
return orders.stream()
.map(order -> assembleDTO(order, userMap, productMap))
.collect(Collectors.toList());
}
}踩坑二:跨库事务的处理
垂直分库后,原来在一个事务里完成的操作(比如"下单 + 扣库存")变成了跨库事务,不能再用本地事务保证原子性。
解法:优先用最终一致性(MQ 消息 + 本地消息表)替代分布式事务;对于必须强一致的核心操作,考虑 Seata TCC 方案。
四、第四阶段:水平分库分表
参考之前 ShardingSphere 的文章,这里补充一个架构决策点:
分库分表不是万能的,NewSQL 可能是更好的选项。
TiDB、OceanBase、PolarDB 等分布式数据库,兼容 MySQL 协议,内部自动做分片、副本、负载均衡。业务代码改动极小(几乎不改),但能做到水平扩展。
从工程效率角度看:
- ShardingSphere 分库分表:业务代码改动大(不能用跨分片 JOIN 等),但成本低(开源免费)
- TiDB/OceanBase:业务代码几乎不改,但需要较高的运维成本(推荐用云服务版本)
对于数据量在 10 亿行以上、有足够预算的团队,直接上分布式数据库,省去自己实现分库分表的复杂度,是值得考虑的选项。
五、第五阶段:数据库专项化
多元数据库融合
到了这个阶段,不同类型的数据会用不同类型的数据库:
MySQL(主库) → OLTP 事务型数据(订单、用户、商品)
Redis → 热点数据缓存、会话存储、分布式锁
Elasticsearch → 全文搜索、复杂条件查询、日志分析
ClickHouse → 数据分析、报表、OLAP 查询
MongoDB → 非结构化内容(文档、配置、日志)
InfluxDB/Prometheus → 时序数据(监控指标)这不是从一开始就规划好的,而是随着业务需求逐步演进的结果。每增加一种数据库,都要评估:运维成本是否能承受?团队是否有足够的能力维护?
// 多元数据库的数据同步(CDC 方案)
// 通过 Debezium 监听 MySQL binlog,同步到 Elasticsearch
@Component
public class OrderChangeEventHandler {
@Autowired
private ElasticsearchOperations esOperations;
// 接收 MySQL binlog 变更事件(通过 MQ 解耦)
@KafkaListener(topics = "mysql.order_db.orders")
public void handleOrderChange(OrderChangeEvent event) {
switch (event.getOperation()) {
case "CREATE":
case "UPDATE":
// 同步到 ES,用于搜索
esOperations.save(OrderDocument.from(event.getAfter()));
break;
case "DELETE":
esOperations.delete(event.getBefore().getId().toString(),
OrderDocument.class);
break;
}
}
}演进的核心原则
回顾这五个阶段,有几条一以贯之的原则:
能不动就不动:每一次架构演进都有成本,不要为了追求"优雅架构"提前演进。等到真正出现瓶颈,再有针对性地升级。
每次只解决一个主要问题:读压力大 → 加从库;写压力大 → 分库分表;查询复杂 → 引入搜索引擎。不要一次性做太多改变。
保留回滚路径:每次大的架构变更,都要设计好回滚方案。有时候新方案有问题,能快速退回来,比死扛更重要。
数据库架构演进没有终点,只有当前阶段的最优解。
