SpringBoot项目里用Sharding-JDBC做分库分表,我踩过的那些坑和解决方案

电商后台系统的商品表数据量突破千万级时,单库查询性能断崖式下跌的场景,相信不少开发者都经历过。去年我们重构供应链系统时,决定对商品表进行水平拆分。本以为基于SpringBoot和Sharding-JDBC的成熟方案能快速落地,结果从环境配置到线上灰度,整整填平了十七个技术坑。今天分享其中最具代表性的五个"深坑",以及我们最终沉淀的解决方案。

1. 数据源配置的"双面陷阱"

当我们在application.yml里同时定义sharding和业务数据源时,第一个"惊喜"来得猝不及防。系统启动时控制台连续抛出DataSource初始化异常,仔细看日志才发现是Spring的自动配置机制在作祟。

典型症状

2023-05-18 14:23:17.541 ERROR 丨 Failed to configure a DataSource: 'dataSource' 

根本原因是SpringBoot默认会尝试自动配置数据源,而Sharding-JDBC需要接管所有数据源管理。我们在yml中这样配置反而制造了冲突:

# 错误示范(混用两种配置方式)
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/main_db
    username: root
    password: 123456

shardingsphere:
  datasource:
    names: ds0,ds1
    ds0: 
      type: com.zaxxer.hikari.HikariDataSource
      driver-class-name: com.mysql.jdbc.Driver
      jdbc-url: jdbc:mysql://localhost:3306/db0
      username: root
      password: 123456

解决方案矩阵

方案类型 具体操作 适用场景 副作用
禁用自动配置 @SpringBootApplication(exclude = DataSourceAutoConfiguration.class) 新项目初始化阶段 需手动管理所有数据源
明确主从 @Primary 注解标记业务数据源 需要保留非分片数据源 增加维护复杂度
全量接管 删除spring.datasource配置项 纯分片环境 最推荐方案

我们最终选择方案三,并补充了连接池的精细化配置:

shardingsphere:
  datasource:
    ds0:
      pool-name: "sharding-pool-${ds0}"
      maximum-pool-size: 20
      connection-timeout: 30000

2. 分片键的"热点风暴"

选择商品ID作为分片键看似合理,直到大促期间某个爆款商品的订单量占到全库80%时,我们才意识到问题的严重性。监控显示ds1节点CPU持续100%,而其他节点负载不足30%。

热点问题三大征兆

  1. 特定分片磁盘IOPS持续高位
  2. 线程池中大量等待获取连接的线程
  3. 慢查询日志集中出现在同一分片

通过分析业务特征,我们发现商品ID的尾数分布极度不均匀(供应商编号集中在特定区间)。最终采用组合分片策略:

public class GoodsShardingAlgorithm implements PreciseShardingAlgorithm<Long> {
    @Override
    public String doSharding(Collection<String> availableTargetNames, 
                           PreciseShardingValue<Long> shardingValue) {
        long goodsId = shardingValue.getValue();
        long supplierId = goodsId / 1000000; // 提取供应商编码
        long categoryId = goodsId % 100;     // 提取类目编码
        
        // 混合哈希计算
        int hash = Math.abs((supplierId + "-" + categoryId).hashCode());
        return "ds" + (hash % availableTargetNames.size());
    }
}

配合该算法,在控制台看到的实际路由效果:

原始商品ID 供应商编码 类目编码 最终路由结果
100345678 100 78 ds1
100345679 100 79 ds3
200123456 200 56 ds0

3. 分布式ID的"时钟回拨"

使用Snowflake算法生成商品ID三个月后,某天凌晨突然出现批量主键冲突。查证发现服务器时钟被NTP服务强制同步,导致workerID分配紊乱。

我们对比测试了三种ID生成方案:

方案对比表

方案 吞吐量(QPS) 全局唯一性 时钟依赖 实现复杂度
数据库自增序列 1,200 跨库不保证 ★★☆☆☆
Snowflake 280,000 强保证 强依赖 ★★★☆☆
Leaf-segment 45,000 强保证 ★★★★☆

最终采用改良版Leaf方案,关键配置如下:

// ID生成器配置
@Bean
public IDGenerator idGenerator() {
    SegmentIDGeneratorImpl generator = new SegmentIDGeneratorImpl();
    generator.setDataSource(dataSource); 
    generator.setStep(5000); // 号段步长
    generator.setRetryTimes(3); // 重试次数
    return generator;
}

实际压测数据显示,该方案在保证唯一性的前提下,将TPS稳定在4.2万左右,完全满足业务需求。

4. 跨库查询的"结果迷宫"

当运营需要查询"手机类目下销量TOP100的商品"时,简单的 order by sales_count desc limit 100 返回的结果令人困惑——明明db0中有销量5万的商品,结果集却出现了db3里销量仅800的商品。

问题本质 :Sharding-JDBC默认将limit下推到每个分片执行,再在内存中合并结果。各分片各自取100条后再排序,必然丢失全局有序性。

我们通过两种方式解决:

  1. 业务层分页 (适合精确查询)
public Page<Goods> queryTopSales(int categoryId, int limit) {
    // 先获取所有分片数据
    List<Goods> all = shardingTemplate.executeQuery(
        "select * from goods where category_id=? order by sales_count desc", 
        categoryId);
    
    // 内存排序分页
    return all.stream()
             .sorted(Comparator.comparingLong(Goods::getSalesCount).reversed())
             .limit(limit)
             .collect(Collectors.toList());
}
  1. 二次查询法 (适合大数据量)
-- 第一阶段:获取各分片候选ID
select id from goods where category_id=1 order by sales_count desc limit 1000;

-- 第二阶段:精确查询完整数据
select * from goods where id in (?,?,...) order by sales_count desc limit 100;

性能对比测试结果:

数据量 传统分页耗时 业务分页耗时 二次查询耗时
10万 320ms 280ms 210ms
100万 2.1s 1.8s 1.3s
1000万 超时 14s 8s

5. 影子库的"流量染色"

压测时如何避免污染真实数据?我们在预发环境搭建时发现,简单的库名区分无法解决SQL误操作风险。最终设计了一套基于HikariCP连接池的流量标记方案:

实现步骤

  1. 自定义ConnectionProxy
public class ShadowConnectionProxy extends AbstractConnectionProxy {
    private final boolean isShadow;
    
    public Statement createStatement() {
        if(isShadow) {
            return new ShadowStatementProxy(this);
        }
        return super.createStatement();
    }
}
  1. 扩展Hikari配置
@Bean
@ConditionalOnProperty(name = "spring.profiles.active", havingValue = "staging")
public DataSource shadowDataSource() {
    HikariConfig config = new HikariConfig();
    config.setPoolName("shadow-pool");
    config.setDataSourceClassName("com.mysql.jdbc.jdbc2.optional.MysqlDataSource");
    config.addDataSourceProperty("url", "jdbc:mysql://shadow-db:3306/...");
    
    return new ShadowDataSource(config);
}
  1. 在AOP切面中自动添加标记
@Around("@annotation(org.springframework.web.bind.annotation.GetMapping)")
public Object handleRequest(ProceedingJoinPoint joinPoint) {
    RequestContext.setShadow(RequestHolder.isPressureTest());
    try {
        return joinPoint.proceed();
    } finally {
        RequestContext.clear();
    }
}

这套方案上线后,压测期间所有SQL请求都准确路由到了影子库,某次全链路压测中成功避免了2.3TB的测试数据写入生产库。

更多推荐