ShardingSphere分库分表实战:从配置到踩坑的全链路记录

发布时间:2026/7/6 16:53:34

ShardingSphere分库分表实战:从配置到踩坑的全链路记录 ShardingSphere分库分表实战从配置到踩坑的全链路记录一、单表过亿不是问题问题是过亿之后的所有操作都开始变慢订单表在日增百万、运行一年后达到3.6亿行的规模。优化过程经历了加索引→分区表→读写分离→最终不得不分库分表。这个演进路径揭示了一个事实索引优化和读写分离解决的是查询性能问题而分库分表解决的是写入瓶颈和存储容量问题——当单库的写入TPS达到物理极限通常MySQL在5000-8000 TPS或单表数据量导致DDL操作不可执行时水平拆分成为必选项。本文记录一次使用ShardingSphere实现分库分表的全过程包括分片策略选择、分布式ID方案、跨库查询处理、数据迁移方案以及上线后遇到的三个典型问题。二、底层机制与原理深度剖析ShardingSphere-JDBC在应用层通过JDBC协议拦截实现分片核心处理流程如下sequenceDiagram participant App as 应用程序 participant SS as ShardingSphere-JDBC participant Router as 分片路由器 participant Rewriter as SQL改写器 participant Executor as 执行引擎 participant DS0 as 数据源0br/ds0.order_0 participant DS1 as 数据源1br/ds1.order_1 participant Merger as 结果合并器 App-SS: SELECT * FROM t_orderbr/WHERE user_id 12345br/AND create_time 2026-06-01 SS-Router: SQL解析 路由计算 Note over Router: 1. 解析SQL提取分片键 user_id12345 Note over Router: 2. 分片算法: user_id % 2 1 Note over Router: 3. 路由目标: ds1.order_1 Router-Rewriter: 路由结果: [ds1.order_1] Note over Rewriter: 将逻辑表 t_order 改写为br/物理表 order_1 Rewriter-Executor: 改写后SQL → ds1 Executor-DS1: SELECT * FROM order_1br/WHERE user_id12345br/AND create_time2026-06-01 DS1--Executor: 结果集 Note over Merger: 单路由结果无需合并 Merger--App: 返回结果路由计算是分库分表的核心。ShardingSphere支持多种分片算法其中使用最广泛的是标准分片StandardShardingAlgorithm分片键Sharding Key决定数据落在哪个分片上的字段。选择分片键是整个拆分设计中最关键的决策。分片算法Sharding Algorithm将分片键的值映射到具体的物理分片。常用算法包括取模MOD、哈希取模HASH_MOD、范围RANGE、边界INTERVAL。分片键选择的三个原则查询条件中出现频率最高。如果90%的查询都带user_id条件而只有10%带order_id那么user_id优先作为分片键。数据分布均匀。如果用户ID分布极不均匀少数用户产生大量数据取模算法会导致数据倾斜需要引入哈希或一致性哈希。避免跨分片查询。分片键决定了查询能否精确路由。如果核心业务查询不带分片键会产生全分片扫描广播查询性能随分片数增加线性下降。三、生产级配置与代码实现3.1 ShardingSphere分库分表配置# application-sharding.yml spring: shardingsphere: # 数据源配置2个物理库 datasource: names: ds0, ds1 ds0: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://10.0.0.1:3306/order_db_0?useSSLtrueserverTimezoneAsia/Shanghai username: ${DB_USER} password: ${DB_PASSWORD} # 连接池配置分库分表后连接数成倍增加需要精细控制 hikari: maximum-pool-size: 30 minimum-idle: 10 connection-timeout: 3000 idle-timeout: 600000 max-lifetime: 1800000 ds1: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://10.0.0.2:3306/order_db_1?useSSLtrueserverTimezoneAsia/Shanghai username: ${DB_USER} password: ${DB_PASSWORD} hikari: maximum-pool-size: 30 minimum-idle: 10 connection-timeout: 3000 idle-timeout: 600000 max-lifetime: 1800000 # 分片规则 rules: sharding: # 绑定表关系order和order_item使用相同分片键避免不必要的笛卡尔积 binding-tables: - t_order, t_order_item # 广播表每个库都有一份完整数据适合配置类小表 broadcast-tables: - t_config - t_district # 分库策略 default-database-strategy: standard: sharding-column: user_id # 分库键 sharding-algorithm-name: database-inline # 各表的分表策略 tables: t_order: # 实际数据节点ds0.order_0, ds0.order_1, ds1.order_0, ds1.order_1 actual-data-nodes: ds$-{0..1}.t_order_$-{0..1} # 分库策略与默认策略一致时可省略 database-strategy: standard: sharding-column: user_id sharding-algorithm-name: database-inline # 分表策略同一库内按order_id二次拆分 table-strategy: standard: sharding-column: order_id sharding-algorithm-name: table-inline # 主键生成策略 key-generate-strategy: column: id key-generator-name: snowflake t_order_item: actual-data-nodes: ds$-{0..1}.t_order_item_$-{0..1} database-strategy: standard: sharding-column: user_id # 与t_order使用相同分库键 sharding-algorithm-name: database-inline table-strategy: standard: sharding-column: order_id # 与t_order使用相同分表键 sharding-algorithm-name: table-inline # 分片算法定义 sharding-algorithms: # 分库算法user_id取模2 database-inline: type: INLINE props: algorithm-expression: ds$-{user_id % 2} # 分表算法order_id取模2 table-inline: type: INLINE props: algorithm-expression: t_order_$-{order_id % 2} # 分布式主键生成器 key-generators: snowflake: type: SNOWFLAKE props: worker-id: ${SNOWFLAKE_WORKER_ID:0} # 属性配置 props: # 打印SQL开发环境开启生产环境关闭 sql-show: false # SQL简单日志生产环境可开启用于问题排查 sql-simple: true # 最大连接数限制防止连接数爆炸 max-connections-size-per-query: 13.2 分布式ID生成方案对比与实现/** * 分布式ID生成器选型与实现 * * 分库分表后数据库自增ID不可用各分片独立自增会导致ID冲突。 * 以下是四种主流方案的对比与实现。 */ public class DistributedIdComparison { /** * 方案1Snowflake雪花算法 - 推荐方案 * * 1位符号位 41位时间戳 10位工作机器ID 12位序列号 * * 优点纯本地生成不依赖外部服务趋势递增对MySQL索引友好 * 缺点依赖机器时钟时钟回拨会导致ID重复 * * 时钟回拨处理策略 * - 回拨 10ms等待即可 * - 回拨 10ms-1s使用备用workId生成 * - 回拨 1s抛出异常拒绝服务触发告警 */ public static class SafeSnowflakeIdGenerator { private final long workerId; private final long epoch 1700000000000L; // 自定义起始时间 private long lastTimestamp -1L; private long sequence 0L; // 位分配 private static final long WORKER_ID_BITS 10L; private static final long SEQUENCE_BITS 12L; private static final long MAX_WORKER_ID ~(-1L WORKER_ID_BITS); private static final long SEQUENCE_MASK ~(-1L SEQUENCE_BITS); private static final long TIMESTAMP_SHIFT SEQUENCE_BITS WORKER_ID_BITS; private static final long WORKER_ID_SHIFT SEQUENCE_BITS; // 时钟回拨处理 private static final long MAX_BACKWARD_MS 1000L; // ... 省略具体实现 } /** * 方案2ShardingSphere内置SNOWFLAKE - 开箱即用 * * 优点无需额外实现配置即可使用 * 缺点worker-id需要手动分配K8s环境下Pod重启ID可能冲突 * * 解决方案通过ZooKeeper/Redis注册worker-id */ /** * 方案3美团Leaf - 号段模式 * * 优点全局递增对MySQL索引最友好 * 缺点需要独立部署Leaf服务增加依赖 * 适用场景需要严格递增ID的场景如订单号需要满足业务时序 */ /** * 方案4数据库号段表 - 最小依赖方案 * * 适用场景不想引入额外组件的过渡方案 * * CREATE TABLE id_allocator ( * biz_type VARCHAR(32) PRIMARY KEY, * max_id BIGINT NOT NULL, * step INT NOT NULL DEFAULT 1000, * updated_at DATETIME NOT NULL * ); * * -- 获取号段原子操作 * UPDATE id_allocator SET max_id max_id step, updated_at NOW() * WHERE biz_type order; * -- 然后 SELECT max_id, step 获取当前号段 * * 缺点DB单点可用多行业务类型分散压力ID可能不连续号段浪费 */ }3.3 跨库Join的处理策略/** * 跨分片查询处理策略 * * 分库分表后以下查询会触发跨分片问题 * 1. 不带分片键的查询 → 全分片广播 * 2. JOIN的表使用了不同分片键 → 笛卡尔积 * 3. 聚合查询COUNT/SUM/AVG→ 需要结果合并 */ Service public class OrderQueryService { private final JdbcTemplate jdbcTemplate; private final RedisTemplateString, Object redisTemplate; /** * 场景1按分片键查询精确路由最优 * * ShardingSphere自动将SQL路由到目标分片性能无影响 */ public Order getOrderByUserIdAndOrderId(Long userId, Long orderId) { String sql SELECT * FROM t_order WHERE user_id ? AND order_id ?; return jdbcTemplate.queryForObject(sql, new OrderRowMapper(), userId, orderId); } /** * 场景2不按分片键查询全分片广播需谨慎使用 * * 按状态查询已完成的订单由于status不是分片键 * 需要在所有分片执行查询后合并结果。 * * 优化策略 * - 限制分页大小避免单次合并过多数据 * - 使用游标分页WHERE id lastSeenId替代OFFSET分页 * - 建立ES索引将复杂查询卸载到搜索引擎 */ public ListOrder getOrdersByStatus(String status, int limit) { // 全分片查询 → ShardingSphere自动合并 String sql SELECT * FROM t_order WHERE status ? ORDER BY create_time DESC LIMIT ?; return jdbcTemplate.query(sql, new OrderRowMapper(), status, limit); // 注意LIMIT在全分片场景下的行为是先各分片取limit条再合并取top limit条 // 如果各分片数据分布不均可能与预期不符 } /** * 场景3跨分片的关联查询通过服务层拆分解决 * * 原SQL * SELECT o.*, u.name, u.avatar * FROM t_order o JOIN t_user u ON o.user_id u.id * WHERE o.create_time ? * * 拆分后 * 1. 查询订单按时间范围 * 2. 提取user_id集合去重 * 3. 批量查询用户信息 * 4. 在应用层关联 * * 看似增加了代码复杂度但这正是微服务架构下推荐的数据与业务边界对齐的做法。 */ public ListOrderWithUser getOrdersWithUser(Instant afterTime, int limit) { // Step 1: 查询订单 ListOrder orders jdbcTemplate.query( SELECT * FROM t_order WHERE create_time ? LIMIT ?, new OrderRowMapper(), afterTime, limit); if (orders.isEmpty()) { return Collections.emptyList(); } // Step 2: 收集user_id批量查询用户信息 SetLong userIds orders.stream() .map(Order::getUserId) .collect(Collectors.toSet()); MapLong, User userMap batchGetUsers(userIds); // Step 3: 应用层关联 return orders.stream() .map(order - { User user userMap.getOrDefault(order.getUserId(), User.UNKNOWN); return OrderWithUser.of(order, user); }) .collect(Collectors.toList()); } private MapLong, User batchGetUsers(SetLong userIds) { // 优先查缓存 // 缓存未命中则批量查DBIN查询 String sql SELECT * FROM t_user WHERE id IN (:ids); // 实际实现使用NamedParameterJdbcTemplate return Collections.emptyMap(); } }3.4 数据平滑迁移方案/** * 单表到分库分表的数据迁移策略 * * 迁移原则零停机、可回滚、可校验 * * 双写方案流程 * Phase 1: 存量数据迁移低峰期离线任务 * Phase 2: 增量数据双写老表 新分片同时写入 * Phase 3: 数据一致性校验对比老表和新分片 * Phase 4: 灰度读切换逐步将读流量切到新分片 * Phase 5: 全量读切换 停写老表 * Phase 6: 老表归档后删除 */ Component public class ShardingMigrationCoordinator { /** * 双写拦截器 * * 在Phase 2阶段写入操作同时写老库和新分片。 * 核心要求新分片写入失败不影响老库写入异步忽略错误。 */ Component ConditionalOnProperty(name sharding.migration.dual-write, havingValue true) public static class DualWriteInterceptor implements Interceptor { private final JdbcTemplate oldDbTemplate; private final JdbcTemplate shardingTemplate; private final ExecutorService asyncExecutor Executors.newFixedThreadPool(4); Override public Object intercept(Invocation invocation) throws Throwable { // 1. 先写老库保证核心链路 Object result invocation.proceed(); // 2. 异步写新分片失败不影响主流程 Object[] args invocation.getArgs(); asyncExecutor.submit(() - { try { writeToSharding(args); } catch (Exception e) { // 记录迁移失败日志供后续补偿 log.error(双写新分片失败: args{}, args, e); recordMigrationFailure(args); } }); return result; } } /** * 数据一致性校验 * * 校验方式 * 1. 行数对比快速检查 * 2. 抽样数据对比随机抽取1000条逐字段对比 * 3. 校验和对比全量数据MD5适合中等数据量 */ public ConsistencyReport verifyConsistency( String oldTable, String shardingTable, int sampleSize) { // 行数对比 long oldCount countRows(oldTable); long newCount countShardingRows(shardingTable); if (oldCount ! newCount) { return ConsistencyReport.mismatch(oldCount, newCount); } // 抽样对比 ListConsistencyReport.RowDiff diffs sampleCompare( oldTable, shardingTable, sampleSize); return ConsistencyReport.builder() .totalRows(oldCount) .sampledRows(sampleSize) .mismatchedRows(diffs.size()) .diffs(diffs) .build(); } }四、边界分析与架构权衡1. 分片键选择的一票否决陷阱如果选择了order_id作为分片键那么所有按user_id的查询如我的订单列表都需要广播到所有分片。假设16个分片简单查询的延迟从单库的5ms变为广播后的80ms串行或15ms并行但需要额外线程池。更严重的是当分片数扩展时延迟线性增长。因此分片键的选择需要基于哪类查询是核心链路、不容许性能退化来决策。2. 分布式事务的成本分库后跨库事务需要引入分布式事务方案Seata、Saga等显著增加系统复杂度。优化策略通过业务设计避免跨库事务——将需要事务一致性的数据放在同一分片使用相同的分库键。例如订单和订单明细使用相同的user_id分片保证同一用户的订单相关数据在同一物理库。3. 分片数与扩容策略分片数不能随便改。从4分片扩到8分片需要全量数据迁移耗时可能长达数小时到数天。建议初次分片时预留一定的分片冗余如预估2年内数据量后翻倍或者使用一致性哈希算法减少扩容时的数据迁移量。ShardingSphere 5.x支持在线扩缩容Scaling但迁移过程中的数据一致性仍需业务配合。4. 连接数爆炸分库后连接数 数据源数 × 连接池大小。假设4个分片、每库30连接 120连接。再加上默认的ShardingSphere连接用于路由计算和结果合并总连接数可能达到150以上。这要求精细配置HikariCP连接池不能简单照搬单库配置监控每个数据源的连接使用率考虑使用ShardingSphere-Proxy替代ShardingSphere-JDBC来统一管理连接。五、总结分库分表是数据库架构演进的最后手段不是性能优化的第一选项。在决定分库分表之前应当已经穷尽了索引优化、查询优化、缓存、读写分离等手段。落地过程中的三个关键经验第一分片键的选择比分片算法的选择重要十倍。选择了错误的分片键任何算法都无法弥补由此带来的全分片查询开销。第二数据迁移方案要在上线前至少做三次全流程演练。一次在测试环境一次在预发环境的脱敏数据一次在预发环境的全量数据。迁移脚本的边界条件断点续传、数据冲突处理、回滚策略远比正常流程复杂。第三监控先行。在分片后的第一时间配置各分片的数据量增长曲线、查询延迟分布、慢SQL告警。分库分表掩盖了单表的数据膨胀问题但如果没有监控某个分片可能悄悄变成新的热点。

相关新闻