SaaS平台的数据库扩展之路:从单库到读写分离再到分库分表的复盘
SaaS平台的数据库扩展之路:从单库到读写分离再到分库分表的复盘
数据库的扩展不是选择题,而是填空题——在什么量级用什么方案,什么时候该切换,答案是由数据量和业务特征填写的。本文复盘一个SaaS平台数据库架构的三次关键演进,包含每个阶段的触发条件、技术方案、迁移策略和踩坑记录。
一、数据库扩展的阶段模型
| 阶段 | 数据量级 | 触发条件 | 核心架构 |
|---|---|---|---|
| 单库 | <500万行 | — | MySQL单实例 |
| 读写分离 | 500万~5000万行 | 读QPS>3000 或 单库CPU>70% | 一主多从 |
| 分库分表 | >5000万行 | 单表>2000万行 或 写QPS>1000 | ShardingSphere/分片 |
| 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_info3.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上限 | 850 | 850 | 3800 |
| 查询P99延迟(单条) | 180ms | 45ms | 12ms |
| 查询P99延迟(分页) | 3200ms | 850ms | 120ms |
| 存储空间(含索引) | 45GB | 45GB + 45GB×2 | 12GB × 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); } }五、总结
数据库扩展的三个核心经验:
不要在没必要时过度设计。单库阶段能撑到500万行,读写分离能撑到2000万行——在触发条件没达到之前,过度优化只会增加不必要的复杂度。
分片键的选择决定了架构上限。我们选择
tenant_id作为分库键,order_id作为分表键,是因为SaaS场景下99%的查询都携带租户ID。如果选错了分片键,后期修正的成本远高于前期设计。迁移方案比架构方案更重要。分库分表的架构方案调研了2周,但平滑迁移的方案设计和演练花了6周。双写→灰度→全量三步走,每一步都有充分的验证和回滚机制——生产环境的数据迁移,容错率是零。
