当前位置: 首页 > news >正文

SaaS平台的数据库扩展之路:从单库到读写分离再到分库分表的复盘

SaaS平台的数据库扩展之路:从单库到读写分离再到分库分表的复盘

数据库的扩展不是选择题,而是填空题——在什么量级用什么方案,什么时候该切换,答案是由数据量和业务特征填写的。本文复盘一个SaaS平台数据库架构的三次关键演进,包含每个阶段的触发条件、技术方案、迁移策略和踩坑记录。

一、数据库扩展的阶段模型

阶段数据量级触发条件核心架构
单库<500万行MySQL单实例
读写分离500万~5000万行读QPS>3000 或 单库CPU>70%一主多从
分库分表>5000万行单表>2000万行 或 写QPS>1000ShardingSphere/分片
NewSQL>亿级跨分片查询成为瓶颈TiDB/CockroachDB

二、阶段一→阶段二:读写分离

2.1 为什么要做读写分离

当时系统处于以下状态:

  • 订单表日增5万行,累计接近800万
  • 读QPS峰值3200,写QPS峰值180
  • 主库CPU持续在75%以上
  • 报表查询与业务查询共用主库,互相影响

读写比例约18:1——典型的读多写少场景,读写分离是最合适的方案。

2.2 实现方案

@Configuration public class ReadWriteSplittingConfig { @Bean public DataSource routingDataSource() { // 主库(写) HikariDataSource master = new HikariDataSource(); master.setJdbcUrl("jdbc:mysql://master.db.internal:3306/saas"); master.setMaximumPoolSize(50); master.setMinimumIdle(10); master.setConnectionTimeout(3000); // 从库1(读) HikariDataSource slave1 = new HikariDataSource(); slave1.setJdbcUrl("jdbc:mysql://slave1.db.internal:3306/saas"); slave1.setMaximumPoolSize(100); slave1.setReadOnly(true); // 从库2(读) HikariDataSource slave2 = new HikariDataSource(); slave2.setJdbcUrl("jdbc:mysql://slave2.db.internal:3306/saas"); slave2.setMaximumPoolSize(100); slave2.setReadOnly(true); // 读写分离路由 Map<Object, Object> dataSources = Map.of( "master", master, "slave1", slave1, "slave2", slave2 ); ReadWriteRoutingDataSource router = new ReadWriteRoutingDataSource(); router.setTargetDataSources(dataSources); router.setDefaultTargetDataSource(master); return router; } } public class ReadWriteRoutingDataSource extends AbstractRoutingDataSource { private static final ThreadLocal<Boolean> READ_ONLY = ThreadLocal.withInitial(() -> false); private final List<String> slaveKeys = List.of("slave1", "slave2"); private final AtomicInteger roundRobin = new AtomicInteger(0); @Override protected Object determineCurrentLookupKey() { if (READ_ONLY.get()) { // 读操作:轮询从库 int idx = roundRobin.getAndIncrement() % slaveKeys.size(); return slaveKeys.get(idx); } return "master"; } public static void setReadOnly(boolean readOnly) { READ_ONLY.set(readOnly); } public static void clear() { READ_ONLY.remove(); } } // AOP自动切换:@Transactional(readOnly=true) → 走从库 @Aspect @Component @Order(0) public class ReadOnlyDataSourceAspect { @Around("@annotation(transactional)") public Object route(ProceedingJoinPoint pjp, Transactional transactional) throws Throwable { try { ReadWriteRoutingDataSource.setReadOnly(transactional.readOnly()); return pjp.proceed(); } finally { ReadWriteRoutingDataSource.clear(); } } } // 主从延迟处理:写后立即读必须走主库 @Service public class OrderService { @Transactional public Order createOrder(CreateOrderRequest req) { Order order = orderRepo.save(buildOrder(req)); // 写后立即读:强制走主库,避免读到旧数据 ReadWriteRoutingDataSource.setReadOnly(false); Order freshOrder = orderRepo.findById(order.getId()); return freshOrder; } }

2.3 主从延迟的应对策略

@Component public class ReplicationLagMonitor { private final JdbcTemplate jdbc; /** * 定期检查主从延迟 */ @Scheduled(fixedRate = 5000) public void monitor() { for (String slave : List.of("slave1", "slave2")) { int lagSeconds = checkLag(slave); // 记录延迟指标 meterRegistry.gauge("mysql.replication.lag", Tags.of("slave", slave), lagSeconds); if (lagSeconds > 10) { // 延迟>10秒:临时摘除该从库 removeSlaveFromPool(slave); alertService.warn("从库 %s 复制延迟 %d 秒,已摘除".formatted( slave, lagSeconds)); } else if (lagSeconds < 3) { // 延迟恢复:重新加入 addSlaveToPool(slave); } } } private int checkLag(String slave) { String sql = "SHOW SLAVE STATUS"; Map<String, Object> status = jdbc.queryForMap(sql); return (int) status.getOrDefault("Seconds_Behind_Master", 999); } }

三、阶段二→阶段三:分库分表

3.1 分片策略设计

当订单表突破2000万行后,即使读写分离,单表写入也开始出现瓶颈。分库分表势在必行:

# ShardingSphere 配置 dataSources: ds0: url: jdbc:mysql://shard0.db.internal:3306/saas_order_0 ds1: url: jdbc:mysql://shard1.db.internal:3306/saas_order_1 ds2: url: jdbc:mysql://shard2.db.internal:3306/saas_order_2 ds3: url: jdbc:mysql://shard3.db.internal:3306/saas_order_3 rules: - !SHARDING tables: t_order: # 分库策略:tenant_id hash mod 4 actualDataNodes: ds$->{0..3}.t_order_$->{0..15} databaseStrategy: standard: shardingColumn: tenant_id shardingAlgorithmName: tenant_db_sharding # 分表策略:order_id hash mod 16(单库内16张表) tableStrategy: standard: shardingColumn: order_id shardingAlgorithmName: order_table_sharding t_order_item: # 绑定表:与order同分片,避免跨库JOIN actualDataNodes: ds$->{0..3}.t_order_item_$->{0..15} databaseStrategy: standard: shardingColumn: tenant_id shardingAlgorithmName: tenant_db_sharding tableStrategy: standard: shardingColumn: order_id shardingAlgorithmName: order_table_sharding shardingAlgorithms: tenant_db_sharding: type: MOD props: sharding-count: 4 order_table_sharding: type: HASH_MOD props: sharding-count: 16 # 广播表:每个分片都有完整副本 broadcastTables: - t_config - t_tenant_info

3.2 分布式ID生成

分库分表后,自增ID不再全局唯一,需要分布式ID方案:

@Component public class SnowflakeIdGenerator { private final long workerId; private final long datacenterId; private long sequence = 0L; private long lastTimestamp = -1L; // 雪花算法参数 private static final long EPOCH = 1704067200000L; // 2024-01-01 private static final long WORKER_ID_BITS = 5L; private static final long DATACENTER_ID_BITS = 5L; private static final long SEQUENCE_BITS = 12L; private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS); private static final long MAX_DATACENTER_ID = ~(-1L << DATACENTER_ID_BITS); private static final long WORKER_ID_SHIFT = SEQUENCE_BITS; private static final long DATACENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS; private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS + DATACENTER_ID_BITS; /** * 生成分布式唯一ID * 结构:[41位时间戳][5位数据中心][5位机器][12位序列号] */ public synchronized long nextId() { long timestamp = System.currentTimeMillis(); if (timestamp < lastTimestamp) { throw new RuntimeException( "时钟回拨! 上次:" + lastTimestamp + " 当前:" + timestamp); } if (timestamp == lastTimestamp) { sequence = (sequence + 1) & ((1 << SEQUENCE_BITS) - 1); if (sequence == 0) { // 当前毫秒序列号用完,等待下一毫秒 timestamp = waitNextMillis(lastTimestamp); } } else { sequence = 0L; } lastTimestamp = timestamp; return ((timestamp - EPOCH) << TIMESTAMP_SHIFT) | (datacenterId << DATACENTER_ID_SHIFT) | (workerId << WORKER_ID_SHIFT) | sequence; } }

3.3 平滑迁移策略

从单库迁移到分库分表,不能停机。平滑迁移三步走:

@Service public class MigrationCoordinator { private final OrderRepository oldRepo; // 旧单库 private final OrderRepository newRepo; // 新分片集群 private final FeatureFlagService featureFlag; /** * 双写模式:同时写入新旧两套存储 */ @Transactional public Order createOrder(Order order) { // 主写入:根据灰度比例决定 if (featureFlag.isEnabled("db_migration_write_new", order.getTenantId())) { order = newRepo.save(order); // 主写新库 } else { order = oldRepo.save(order); // 主写旧库 } // 异步双写到另一侧 CompletableFuture.runAsync(() -> { try { if (featureFlag.isEnabled("db_migration_write_new", order.getTenantId())) { oldRepo.save(order); // 异步补写旧库 } else { newRepo.save(order); // 异步补写新库 } } catch (Exception e) { // 双写失败不影响主流程,但需要记录差异 diffRecorder.record(order.getId(), e); } }, migrationExecutor); return order; } /** * 渐进式灰度切换 */ @Scheduled(cron = "0 0 * * * ?") public void progressiveRollout() { MigrationConfig config = configService.getConfig(); // 每小时自动扩大5%灰度比例(前提:错误率<0.1%) double currentErrorRate = metricsService.getMigrationErrorRate(); if (currentErrorRate < 0.001 && config.getTrafficPercentage() < 100) { int newPercentage = Math.min( config.getTrafficPercentage() + 5, 100); configService.updateTrafficPercentage(newPercentage); log.info("Migration traffic increased to {}%", newPercentage); } else if (currentErrorRate >= 0.001) { log.warn("Migration error rate {} exceeds threshold, pausing rollout", currentErrorRate); } } }

3.4 迁移后的性能对比

指标单库读写分离分库分表(4×16)
数据总量2100万2100万2100万
单表平均行数2100万2100万~33万
写入QPS上限8508503800
查询P99延迟(单条)180ms45ms12ms
查询P99延迟(分页)3200ms850ms120ms
存储空间(含索引)45GB45GB + 45GB×212GB × 4

四、特殊场景的处理

4.1 跨分片查询的应对

分库分表后最大的痛点是跨分片查询:

@Service public class CrossShardQueryService { /** * 策略1:应用层聚合(适用于简单统计) */ public OrderStats getStats(String tenantId) { // 并行查询所有分片 List<CompletableFuture<OrderStats>> futures = IntStream.range(0, 4).mapToObj(shardId -> CompletableFuture.supplyAsync(() -> orderRepo.getStatsByShard(tenantId, shardId)) ).toList(); // 聚合结果 return futures.stream() .map(CompletableFuture::join) .reduce(new OrderStats(), OrderStats::merge); } /** * 策略2:ES异构索引(适用于复杂搜索) */ @Transactional public void syncToElasticsearch(Order order) { // 通过Canal监听binlog → 异步同步到ES // ES中不存储全量字段,仅存储搜索需要的字段 OrderDocument doc = OrderDocument.builder() .orderId(order.getId()) .tenantId(order.getTenantId()) .amount(order.getAmount()) .status(order.getStatus()) .createdAt(order.getCreatedAt()) .build(); elasticsearchTemplate.save(doc); } }

五、总结

数据库扩展的三个核心经验:

  1. 不要在没必要时过度设计。单库阶段能撑到500万行,读写分离能撑到2000万行——在触发条件没达到之前,过度优化只会增加不必要的复杂度。

  2. 分片键的选择决定了架构上限。我们选择tenant_id作为分库键,order_id作为分表键,是因为SaaS场景下99%的查询都携带租户ID。如果选错了分片键,后期修正的成本远高于前期设计。

  3. 迁移方案比架构方案更重要。分库分表的架构方案调研了2周,但平滑迁移的方案设计和演练花了6周。双写→灰度→全量三步走,每一步都有充分的验证和回滚机制——生产环境的数据迁移,容错率是零。

http://www.cnnetsun.cn/news/3581240.html

相关文章:

  • 第21届全国大学生智能车竞赛安徽赛区参赛指南
  • 大模型智能体初始化与MCP协议实践指南
  • 单片机开发进阶技巧:(三)生产者-消费者模型
  • 《倒像》预告片技术解析:低成本短片创作的视觉语言与制作实践
  • 嵌入式Linux驱动开发面试20问与实战解析
  • 解决Android进程保活挑战的KeepAlive技术革新方案
  • Unity UIEffect:替代粒子系统,实现高性能UI特效的实战指南
  • Unity 3D手游开发入门:从环境搭建到性能优化的完整指南
  • 鸿蒙 ArkTS 实战:Important File Backup 从重要文件备份到生活安全工具完整解析
  • FreeCAD扫掠操作详解:参数化3D建模高效创建复杂几何形状
  • [技术讨论] 用Wireshark抓包无线网络数据,抓到好多东西,还蛮好玩的
  • 摔箱测试不是随便摔!ASTM D4169自由跌落测试和旋转棱跌落测试有何区别!
  • 中南林业科技大学食堂美食评价系统任务书
  • Spring Batch企业级批处理系统设计与优化实践
  • 嵌入式视频处理实战:HDVPSS缩放器与VENC编码器寄存器配置详解
  • 本地AI视频生成:从Stable Video Diffusion到ComfyUI实战指南
  • 大模型分类体系
  • 强化学习 / OPD】OpenClaw-RL 源码阅读笔记 --- (7)--- Policy Serving
  • TI C6000 DSP SYSCFG模块配置详解:从CHIPSIG到CFGCHIP的嵌入式系统核心控制
  • UVa 11669 Non Decreasing Prime Sequence
  • ComfyUI与Hermes Agent:自然语言控制AI绘画工作流
  • 临沂鑫旺2026 耐腐材质告别后期频繁更换
  • FTP服务部署与优化:vsftpd实战指南
  • Seedance3.0本地部署实战:免费AI视频生成与绘画完整指南
  • Spark MLlib分布式机器学习框架入门与实践
  • 嵌入式外设驱动核心:I2C与LCD控制器寄存器配置与中断处理实战
  • 前端开发环境配置常见问题与解决方案
  • AI工具如何提升学术论文写作效率与质量
  • 2026年AI学术写作工具评测与应用指南
  • Informer:长序列时间预测的Transformer优化方案