SpringBoot项目里用Sharding-JDBC做分库分表,我踩过的那些坑和解决方案
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%。
热点问题三大征兆 :
- 特定分片磁盘IOPS持续高位
- 线程池中大量等待获取连接的线程
- 慢查询日志集中出现在同一分片
通过分析业务特征,我们发现商品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条后再排序,必然丢失全局有序性。
我们通过两种方式解决:
- 业务层分页 (适合精确查询)
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());
}
- 二次查询法 (适合大数据量)
-- 第一阶段:获取各分片候选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连接池的流量标记方案:
实现步骤 :
- 自定义ConnectionProxy
public class ShadowConnectionProxy extends AbstractConnectionProxy {
private final boolean isShadow;
public Statement createStatement() {
if(isShadow) {
return new ShadowStatementProxy(this);
}
return super.createStatement();
}
}
- 扩展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);
}
- 在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的测试数据写入生产库。
更多推荐


所有评论(0)