数据库分库分表:从单库瓶颈到水平扩展的架构演进 数据库分库分表从单库瓶颈到水平扩展的架构演进一、单库性能天花板当数据库成为系统最大瓶颈关系型数据库在数据量和并发量增长到一定阶段后必然遇到性能瓶颈。MySQL 单表数据量超过 5000 万行后B 树索引层级增加查询性能显著下降单库的写入 QPS 上限约为 3000-5000取决于硬件和索引复杂度在高并发写入场景下远远不够单机存储容量受限于磁盘大小无法无限扩展。更棘手的是数据库的性能退化不是线性的——当活跃数据超过 Buffer Pool 容量时查询从内存访问退化为磁盘 IO性能可能骤降 10 倍以上。DDL 操作加字段、建索引在大表上执行时间以小时计期间锁表导致业务不可用。分库分表是解决这些问题的根本手段但它引入了分布式事务、跨库查询、数据迁移等新的复杂性。二、分库分表核心机制路由、分片与全局 ID 生成分库分表的本质是将数据按照一定规则分散到多个数据库和表中每个分片只承载部分数据和请求。下图展示了分库分表架构的核心组件flowchart TB App[应用服务] -- Proxy[分片代理 ShardingSphere-Proxy] Proxy -- Router[路由引擎 解析SQL确定分片] Router -- Shard0[分片0 db0.t_order_0] Router -- Shard1[分片1 db0.t_order_1] Router -- Shard2[分片2 db1.t_order_0] Router -- Shard3[分片3 db1.t_order_1] subgraph 全局ID生成 Snowflake[Snowflake 雪花算法] Leaf[Leaf 号段模式] end App -- Snowflake App -- Leaf subgraph 跨分片查询 Union[UNION 合并] Merge[内存归并排序] end Shard0 -- Union Shard1 -- Union Shard2 -- Union Shard3 -- Union Union -- Merge style Proxy fill:#ff9,stroke:#333 style Router fill:#9ff,stroke:#333 style Snowflake fill:#9f9,stroke:#3332.1 分片策略哈希分片与范围分片哈希分片Hash Sharding通过对分片键取模将数据均匀分散到各分片优点是数据分布均匀、热点分散缺点是范围查询需要扫描所有分片。范围分片Range Sharding按分片键的范围划分数据优点是范围查询只需扫描相关分片缺点是容易产生热点最新数据集中在最后一个分片。生产环境中常用复合策略以用户 ID 哈希分库保证数据均匀以创建时间范围分表方便按时间查询和归档。2.2 全局唯一 ID分布式环境下的主键生成分库分表后数据库自增 ID 无法保证全局唯一。Snowflake 雪花算法是最常用的解决方案64 位 ID 由时间戳 机器 ID 序列号组成既保证唯一性又保持趋势递增。Leaf 号段模式是美团的方案通过预分配号段减少对发号中心的依赖可用性更高。2.3 跨分片查询归并排序与二次查询分库分表后ORDER BY、LIMIT、GROUP BY 等操作需要在所有分片上执行然后在代理层归并。这带来了两个问题性能下降所有分片都要查询和内存消耗代理层需要缓存所有结果。深度分页LIMIT 100000, 10尤其严重每个分片都需要返回前 100010 条记录。三、生产级分库分表实现3.1 ShardingSphere-JDBC 分片配置# ShardingSphere-JDBC 分片规则配置 # 为什么用 ShardingSphere-JDBC 而非 Proxy 模式 # 因为 JDBC 模式是应用内直连数据库无中间件网络开销 # 性能比 Proxy 模式高 20%-30%适合对延迟敏感的核心链路 mode: type: Standalone repository: type: JDBC dataSources: ds_0: dataSourceClassName: com.zaxxer.hikari.HikariDataSource driverClassName: com.mysql.cj.jdbc.Driver jdbcUrl: jdbc:mysql://db0:3306/order_db_0 username: root password: ${DB_PASSWORD} # 为什么 HikariCP 连接池大小设为 20 # 因为分库后每个数据源的连接数需求减少 # 4 个数据源 x 20 连接 80 总连接足够支撑单实例 2000 QPS maximumPoolSize: 20 minimumIdle: 5 ds_1: dataSourceClassName: com.zaxxer.hikari.HikariDataSource driverClassName: com.mysql.cj.jdbc.Driver jdbcUrl: jdbc:mysql://db1:3306/order_db_1 username: root password: ${DB_PASSWORD} maximumPoolSize: 20 minimumIdle: 5 rules: - !SHARDING tables: t_order: # 实际数据节点2 个库 x 4 张表 8 个分片 actualDataNodes: ds_${0..1}.t_order_${0..3} tableStrategy: standard: shardingColumn: user_id shardingAlgorithmName: order_table_mod databaseStrategy: standard: shardingColumn: user_id shardingAlgorithmName: order_db_mod keyGenerateStrategy: column: id keyGeneratorName: snowflake shardingAlgorithms: order_db_mod: type: MOD props: sharding-count: 2 # 库数量 order_table_mod: type: MOD props: sharding-count: 4 # 表数量 keyGenerators: snowflake: type: SNOWFLAKE props: worker-id: 13.2 分布式事务Seata AT 模式/** * 跨库事务——Seata AT 模式 * 为什么用 AT 模式而非 TCC * 因为 AT 模式对业务代码侵入最小只需加注解 * TCC 需要实现 Try/Confirm/Cancel 三个方法改造成本高 * AT 模式的代价是性能需要记录 undo_log * 但对于订单场景正确性优先于性能 */ Service public class OrderService { /** * 创建订单——跨库事务订单库 库存库 * 为什么订单和库存要分库 * 因为订单数据量大亿级库存数据量小但并发高 * 分库后可以独立扩展避免互相影响 */ GlobalTransactional(timeoutMills 30000, name create-order) public OrderResult createOrder(CreateOrderRequest request) { // 步骤一在订单库创建订单 // Seata AT 模式在此步骤前会自动记录 undo_log Order order Order.builder() .userId(request.getUserId()) .skuId(request.getSkuId()) .quantity(request.getQuantity()) .status(OrderStatus.CREATED) .build(); orderMapper.insert(order); // 步骤二在库存库扣减库存 // 为什么先创建订单再扣库存 // 因为订单创建失败可以回滚库存扣减失败也可以回滚 // 但如果先扣库存再创建订单订单创建失败时库存需要回补 // 回补逻辑比回滚更复杂 int remain stockMapper.deduct(request.getSkuId(), request.getQuantity()); if (remain 0) { // 库存不足抛出异常触发全局回滚 throw new BusinessException(库存不足); } // 步骤三在账户库扣减余额 accountMapper.deduct(request.getUserId(), request.getAmount()); return OrderResult.success(order.getId()); } }3.3 深度分页优化游标分页/** * 深度分页优化——基于游标的分页查询 * 为什么用游标分页而非 OFFSET 分页 * 因为 OFFSET 100000 在分库分表场景下 * 每个分片都需要扫描前 100000 条记录 * 4 个分片 x 100000 400000 条记录在代理层归并 * 内存和 CPU 开销极大 */ Repository public class OrderQueryRepository { /** * 游标分页基于最后一条记录的 ID 继续查询 * 为什么游标分页更高效 * 因为 WHERE id last_id 只需扫描 last_id 之后的记录 * 不需要跳过前面的记录IO 量与实际返回量成正比 */ public ListOrder queryByCursor(Long userId, Long lastId, int pageSize) { LambdaQueryWrapperOrder wrapper new LambdaQueryWrapperOrder() .eq(Order::getUserId, userId) .gt(lastId ! null, Order::getId, lastId) .orderByAsc(Order::getId) .last(LIMIT pageSize); return orderMapper.selectList(wrapper); } /** * 优化版 OFFSET 分页二次查询法 * 为什么需要二次查询 * 因为直接 OFFSET 在分片场景下每个分片都需要返回完整偏移量 * 二次查询法先获取各分片的最小 ID再精确查询 */ public ListOrder queryByOffset(Long userId, int offset, int pageSize) { // 第一次查询获取各分片在 offset 位置的 ID // 通过 ShardingSphere 的 hint 管理器指定分片 ListOrder firstRound orderMapper.selectList( new LambdaQueryWrapperOrder() .eq(Order::getUserId, userId) .orderByAsc(Order::getId) .last(LIMIT offset , pageSize) ); if (firstRound.isEmpty()) { return Collections.emptyList(); } // 获取最小 ID 作为真正的查询起点 Long minId firstRound.stream() .map(Order::getId) .min(Long::compareTo) .orElse(0L); // 第二次查询基于最小 ID 精确查询 return orderMapper.selectList( new LambdaQueryWrapperOrder() .eq(Order::getUserId, userId) .ge(Order::getId, minId) .orderByAsc(Order::getId) .last(LIMIT pageSize) ); } }3.4 数据迁移双写方案/** * 数据迁移——双写 对比验证 * 为什么用双写而非停机迁移 * 因为核心业务不能停机双写可以在不停服的情况下 * 完成数据从旧库到新分片库的迁移 */ Component Slf4j public class DataMigrationService { private final AtomicBoolean migrationEnabled new AtomicBoolean(false); /** * 双写开关通过配置中心动态切换 * 为什么需要动态开关而非代码发布 * 因为双写期间如果新库出现问题需要立即关闭双写 * 代码发布流程太慢配置中心可以秒级生效 */ NacosValue(value ${migration.dual-write.enabled:false}, autoRefreshed true) public void setDualWriteEnabled(boolean enabled) { migrationEnabled.set(enabled); log.info(双写开关切换: {}, enabled); } /** * 双写逻辑同时写入旧库和新分片库 */ Transactional public void createOrderWithDualWrite(Order order) { // 写入旧库主库 legacyOrderMapper.insert(order); // 双写同时写入新分片库 if (migrationEnabled.get()) { try { shardingOrderMapper.insert(order); } catch (Exception e) { // 新库写入失败不影响主流程 // 为什么不回滚因为旧库是权威数据源 // 新库写入失败通过异步对账修复 log.error(新库写入失败, orderId{}, order.getId(), e); migrationErrorRecorder.record(order.getId(), e.getMessage()); } } } /** * 数据一致性校验对比旧库和新分片库的数据 * 为什么需要校验因为双写期间新库可能写入失败 * 必须定期校验确保数据一致 */ Scheduled(fixedDelay 300000) // 每 5 分钟校验一次 public void verifyConsistency() { if (!migrationEnabled.get()) return; long lastCheckId getLastCheckId(); ListOrder legacyOrders legacyOrderMapper .selectByIdRange(lastCheckId, lastCheckId 10000); for (Order legacy : legacyOrders) { Order sharding shardingOrderMapper .selectById(legacy.getId()); if (sharding null || !dataEquals(legacy, sharding)) { // 数据不一致以旧库为准修复新库 log.warn(数据不一致, orderId{}, legacy.getId()); repairData(legacy); } } } }四、架构权衡分库分表的代价与替代方案跨库事务的代价Seata AT 模式通过 undo_log 实现回滚但全局锁的持有时间较长在高并发场景下可能成为瓶颈。TCC 模式性能更好但改造成本高。最终一致性方案消息队列 本地消息表是最轻量的选择但需要业务容忍短暂不一致。跨库查询的代价分库分表后JOIN 查询、聚合查询、深度分页都变得复杂且低效。需要将跨库 JOIN 拆分为多次单库查询在应用层组装。这增加了代码复杂度和网络开销。对于报表类复杂查询建议使用独立的 OLAP 数据库如 ClickHouse而非在分片库上直接查询。运维复杂度的代价分库分表后DDL 变更需要在所有分片上执行数据迁移需要双写保障一致性故障排查需要跨多个数据源。运维复杂度与分片数量正相关建议分片数量不超过 16 个。替代方案在数据量尚未达到瓶颈时可以考虑读写分离 缓存 归档冷热分离的组合方案推迟分库分表。TiDB 等 NewSQL 数据库原生支持水平扩展但迁移成本和生态兼容性需要评估。适用边界分库分表适用于单表数据量超过 5000 万行、写入 QPS 超过 5000 的场景。对于数据量在千万级以内的场景优化索引 缓存 读写分离通常足够。五、总结分库分表是解决数据库性能瓶颈的根本手段但引入了分布式事务、跨库查询、数据迁移等新的复杂性。核心实现要点哈希分片保证数据均匀分布、Snowflake 生成全局唯一 ID、Seata AT 模式处理跨库事务、游标分页优化深度查询、双写方案保障数据迁移安全。落地路线上建议先通过读写分离和缓存延缓分库需求当单表数据量超过 5000 万时再启动分库分表。分片数量不宜过多16 个以内为佳。分库分表是不可逆的架构决策必须在充分评估数据增长趋势后再执行。